@animus-labs/cortex 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/budget-guard.d.ts +75 -0
  4. package/dist/budget-guard.d.ts.map +1 -0
  5. package/dist/budget-guard.js +142 -0
  6. package/dist/budget-guard.js.map +1 -0
  7. package/dist/compaction/compaction.d.ts +99 -0
  8. package/dist/compaction/compaction.d.ts.map +1 -0
  9. package/dist/compaction/compaction.js +302 -0
  10. package/dist/compaction/compaction.js.map +1 -0
  11. package/dist/compaction/failsafe.d.ts +57 -0
  12. package/dist/compaction/failsafe.d.ts.map +1 -0
  13. package/dist/compaction/failsafe.js +135 -0
  14. package/dist/compaction/failsafe.js.map +1 -0
  15. package/dist/compaction/index.d.ts +381 -0
  16. package/dist/compaction/index.d.ts.map +1 -0
  17. package/dist/compaction/index.js +979 -0
  18. package/dist/compaction/index.js.map +1 -0
  19. package/dist/compaction/microcompaction.d.ts +219 -0
  20. package/dist/compaction/microcompaction.d.ts.map +1 -0
  21. package/dist/compaction/microcompaction.js +536 -0
  22. package/dist/compaction/microcompaction.js.map +1 -0
  23. package/dist/compaction/observational/buffering.d.ts +225 -0
  24. package/dist/compaction/observational/buffering.d.ts.map +1 -0
  25. package/dist/compaction/observational/buffering.js +354 -0
  26. package/dist/compaction/observational/buffering.js.map +1 -0
  27. package/dist/compaction/observational/constants.d.ts +70 -0
  28. package/dist/compaction/observational/constants.d.ts.map +1 -0
  29. package/dist/compaction/observational/constants.js +507 -0
  30. package/dist/compaction/observational/constants.js.map +1 -0
  31. package/dist/compaction/observational/index.d.ts +219 -0
  32. package/dist/compaction/observational/index.d.ts.map +1 -0
  33. package/dist/compaction/observational/index.js +641 -0
  34. package/dist/compaction/observational/index.js.map +1 -0
  35. package/dist/compaction/observational/observer.d.ts +97 -0
  36. package/dist/compaction/observational/observer.d.ts.map +1 -0
  37. package/dist/compaction/observational/observer.js +424 -0
  38. package/dist/compaction/observational/observer.js.map +1 -0
  39. package/dist/compaction/observational/recall-tool.d.ts +27 -0
  40. package/dist/compaction/observational/recall-tool.d.ts.map +1 -0
  41. package/dist/compaction/observational/recall-tool.js +93 -0
  42. package/dist/compaction/observational/recall-tool.js.map +1 -0
  43. package/dist/compaction/observational/reflector.d.ts +94 -0
  44. package/dist/compaction/observational/reflector.d.ts.map +1 -0
  45. package/dist/compaction/observational/reflector.js +167 -0
  46. package/dist/compaction/observational/reflector.js.map +1 -0
  47. package/dist/compaction/observational/types.d.ts +271 -0
  48. package/dist/compaction/observational/types.d.ts.map +1 -0
  49. package/dist/compaction/observational/types.js +15 -0
  50. package/dist/compaction/observational/types.js.map +1 -0
  51. package/dist/context-manager.d.ts +134 -0
  52. package/dist/context-manager.d.ts.map +1 -0
  53. package/dist/context-manager.js +170 -0
  54. package/dist/context-manager.js.map +1 -0
  55. package/dist/cortex-agent.d.ts +1020 -0
  56. package/dist/cortex-agent.d.ts.map +1 -0
  57. package/dist/cortex-agent.js +3589 -0
  58. package/dist/cortex-agent.js.map +1 -0
  59. package/dist/error-classifier.d.ts +48 -0
  60. package/dist/error-classifier.d.ts.map +1 -0
  61. package/dist/error-classifier.js +152 -0
  62. package/dist/error-classifier.js.map +1 -0
  63. package/dist/event-bridge.d.ts +166 -0
  64. package/dist/event-bridge.d.ts.map +1 -0
  65. package/dist/event-bridge.js +381 -0
  66. package/dist/event-bridge.js.map +1 -0
  67. package/dist/index.d.ts +55 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +57 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/mcp-client.d.ts +119 -0
  72. package/dist/mcp-client.d.ts.map +1 -0
  73. package/dist/mcp-client.js +474 -0
  74. package/dist/mcp-client.js.map +1 -0
  75. package/dist/model-wrapper.d.ts +58 -0
  76. package/dist/model-wrapper.d.ts.map +1 -0
  77. package/dist/model-wrapper.js +86 -0
  78. package/dist/model-wrapper.js.map +1 -0
  79. package/dist/noop-logger.d.ts +4 -0
  80. package/dist/noop-logger.d.ts.map +1 -0
  81. package/dist/noop-logger.js +8 -0
  82. package/dist/noop-logger.js.map +1 -0
  83. package/dist/prompt-diagnostics.d.ts +47 -0
  84. package/dist/prompt-diagnostics.d.ts.map +1 -0
  85. package/dist/prompt-diagnostics.js +230 -0
  86. package/dist/prompt-diagnostics.js.map +1 -0
  87. package/dist/provider-manager.d.ts +224 -0
  88. package/dist/provider-manager.d.ts.map +1 -0
  89. package/dist/provider-manager.js +563 -0
  90. package/dist/provider-manager.js.map +1 -0
  91. package/dist/provider-registry.d.ts +115 -0
  92. package/dist/provider-registry.d.ts.map +1 -0
  93. package/dist/provider-registry.js +305 -0
  94. package/dist/provider-registry.js.map +1 -0
  95. package/dist/schema-converter.d.ts +20 -0
  96. package/dist/schema-converter.d.ts.map +1 -0
  97. package/dist/schema-converter.js +48 -0
  98. package/dist/schema-converter.js.map +1 -0
  99. package/dist/skill-preprocessor.d.ts +46 -0
  100. package/dist/skill-preprocessor.d.ts.map +1 -0
  101. package/dist/skill-preprocessor.js +237 -0
  102. package/dist/skill-preprocessor.js.map +1 -0
  103. package/dist/skill-registry.d.ts +107 -0
  104. package/dist/skill-registry.d.ts.map +1 -0
  105. package/dist/skill-registry.js +330 -0
  106. package/dist/skill-registry.js.map +1 -0
  107. package/dist/skill-tool.d.ts +54 -0
  108. package/dist/skill-tool.d.ts.map +1 -0
  109. package/dist/skill-tool.js +88 -0
  110. package/dist/skill-tool.js.map +1 -0
  111. package/dist/sub-agent-manager.d.ts +90 -0
  112. package/dist/sub-agent-manager.d.ts.map +1 -0
  113. package/dist/sub-agent-manager.js +192 -0
  114. package/dist/sub-agent-manager.js.map +1 -0
  115. package/dist/token-estimator.d.ts +23 -0
  116. package/dist/token-estimator.d.ts.map +1 -0
  117. package/dist/token-estimator.js +27 -0
  118. package/dist/token-estimator.js.map +1 -0
  119. package/dist/tool-contract.d.ts +68 -0
  120. package/dist/tool-contract.d.ts.map +1 -0
  121. package/dist/tool-contract.js +35 -0
  122. package/dist/tool-contract.js.map +1 -0
  123. package/dist/tool-result-persistence.d.ts +89 -0
  124. package/dist/tool-result-persistence.d.ts.map +1 -0
  125. package/dist/tool-result-persistence.js +152 -0
  126. package/dist/tool-result-persistence.js.map +1 -0
  127. package/dist/tools/bash/index.d.ts +71 -0
  128. package/dist/tools/bash/index.d.ts.map +1 -0
  129. package/dist/tools/bash/index.js +485 -0
  130. package/dist/tools/bash/index.js.map +1 -0
  131. package/dist/tools/bash/interactive.d.ts +47 -0
  132. package/dist/tools/bash/interactive.d.ts.map +1 -0
  133. package/dist/tools/bash/interactive.js +262 -0
  134. package/dist/tools/bash/interactive.js.map +1 -0
  135. package/dist/tools/bash/safety.d.ts +149 -0
  136. package/dist/tools/bash/safety.d.ts.map +1 -0
  137. package/dist/tools/bash/safety.js +1116 -0
  138. package/dist/tools/bash/safety.js.map +1 -0
  139. package/dist/tools/edit.d.ts +57 -0
  140. package/dist/tools/edit.d.ts.map +1 -0
  141. package/dist/tools/edit.js +310 -0
  142. package/dist/tools/edit.js.map +1 -0
  143. package/dist/tools/glob.d.ts +34 -0
  144. package/dist/tools/glob.d.ts.map +1 -0
  145. package/dist/tools/glob.js +268 -0
  146. package/dist/tools/glob.js.map +1 -0
  147. package/dist/tools/grep.d.ts +53 -0
  148. package/dist/tools/grep.d.ts.map +1 -0
  149. package/dist/tools/grep.js +673 -0
  150. package/dist/tools/grep.js.map +1 -0
  151. package/dist/tools/index.d.ts +62 -0
  152. package/dist/tools/index.d.ts.map +1 -0
  153. package/dist/tools/index.js +52 -0
  154. package/dist/tools/index.js.map +1 -0
  155. package/dist/tools/read.d.ts +43 -0
  156. package/dist/tools/read.d.ts.map +1 -0
  157. package/dist/tools/read.js +459 -0
  158. package/dist/tools/read.js.map +1 -0
  159. package/dist/tools/runtime.d.ts +62 -0
  160. package/dist/tools/runtime.d.ts.map +1 -0
  161. package/dist/tools/runtime.js +116 -0
  162. package/dist/tools/runtime.js.map +1 -0
  163. package/dist/tools/shared/cwd-tracker.d.ts +32 -0
  164. package/dist/tools/shared/cwd-tracker.d.ts.map +1 -0
  165. package/dist/tools/shared/cwd-tracker.js +44 -0
  166. package/dist/tools/shared/cwd-tracker.js.map +1 -0
  167. package/dist/tools/shared/edit-history.d.ts +55 -0
  168. package/dist/tools/shared/edit-history.d.ts.map +1 -0
  169. package/dist/tools/shared/edit-history.js +72 -0
  170. package/dist/tools/shared/edit-history.js.map +1 -0
  171. package/dist/tools/shared/edit-matcher.d.ts +83 -0
  172. package/dist/tools/shared/edit-matcher.d.ts.map +1 -0
  173. package/dist/tools/shared/edit-matcher.js +359 -0
  174. package/dist/tools/shared/edit-matcher.js.map +1 -0
  175. package/dist/tools/shared/file-mutation-lock.d.ts +22 -0
  176. package/dist/tools/shared/file-mutation-lock.d.ts.map +1 -0
  177. package/dist/tools/shared/file-mutation-lock.js +35 -0
  178. package/dist/tools/shared/file-mutation-lock.js.map +1 -0
  179. package/dist/tools/shared/gitignore.d.ts +17 -0
  180. package/dist/tools/shared/gitignore.d.ts.map +1 -0
  181. package/dist/tools/shared/gitignore.js +59 -0
  182. package/dist/tools/shared/gitignore.js.map +1 -0
  183. package/dist/tools/shared/pdf-extractor.d.ts +96 -0
  184. package/dist/tools/shared/pdf-extractor.d.ts.map +1 -0
  185. package/dist/tools/shared/pdf-extractor.js +196 -0
  186. package/dist/tools/shared/pdf-extractor.js.map +1 -0
  187. package/dist/tools/shared/read-registry.d.ts +66 -0
  188. package/dist/tools/shared/read-registry.d.ts.map +1 -0
  189. package/dist/tools/shared/read-registry.js +65 -0
  190. package/dist/tools/shared/read-registry.js.map +1 -0
  191. package/dist/tools/shared/safe-env.d.ts +18 -0
  192. package/dist/tools/shared/safe-env.d.ts.map +1 -0
  193. package/dist/tools/shared/safe-env.js +70 -0
  194. package/dist/tools/shared/safe-env.js.map +1 -0
  195. package/dist/tools/sub-agent.d.ts +91 -0
  196. package/dist/tools/sub-agent.d.ts.map +1 -0
  197. package/dist/tools/sub-agent.js +89 -0
  198. package/dist/tools/sub-agent.js.map +1 -0
  199. package/dist/tools/task-output.d.ts +38 -0
  200. package/dist/tools/task-output.d.ts.map +1 -0
  201. package/dist/tools/task-output.js +186 -0
  202. package/dist/tools/task-output.js.map +1 -0
  203. package/dist/tools/tool-search/index.d.ts +40 -0
  204. package/dist/tools/tool-search/index.d.ts.map +1 -0
  205. package/dist/tools/tool-search/index.js +110 -0
  206. package/dist/tools/tool-search/index.js.map +1 -0
  207. package/dist/tools/tool-search/registry.d.ts +82 -0
  208. package/dist/tools/tool-search/registry.d.ts.map +1 -0
  209. package/dist/tools/tool-search/registry.js +238 -0
  210. package/dist/tools/tool-search/registry.js.map +1 -0
  211. package/dist/tools/undo-edit.d.ts +51 -0
  212. package/dist/tools/undo-edit.d.ts.map +1 -0
  213. package/dist/tools/undo-edit.js +231 -0
  214. package/dist/tools/undo-edit.js.map +1 -0
  215. package/dist/tools/web-fetch/cache.d.ts +49 -0
  216. package/dist/tools/web-fetch/cache.d.ts.map +1 -0
  217. package/dist/tools/web-fetch/cache.js +89 -0
  218. package/dist/tools/web-fetch/cache.js.map +1 -0
  219. package/dist/tools/web-fetch/index.d.ts +53 -0
  220. package/dist/tools/web-fetch/index.d.ts.map +1 -0
  221. package/dist/tools/web-fetch/index.js +513 -0
  222. package/dist/tools/web-fetch/index.js.map +1 -0
  223. package/dist/tools/write.d.ts +59 -0
  224. package/dist/tools/write.d.ts.map +1 -0
  225. package/dist/tools/write.js +316 -0
  226. package/dist/tools/write.js.map +1 -0
  227. package/dist/types.d.ts +881 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +16 -0
  230. package/dist/types.js.map +1 -0
  231. package/dist/working-tags.d.ts +44 -0
  232. package/dist/working-tags.d.ts.map +1 -0
  233. package/dist/working-tags.js +103 -0
  234. package/dist/working-tags.js.map +1 -0
  235. package/package.json +87 -0
  236. package/src/budget-guard.ts +170 -0
  237. package/src/compaction/compaction.ts +386 -0
  238. package/src/compaction/failsafe.ts +185 -0
  239. package/src/compaction/index.ts +1199 -0
  240. package/src/compaction/microcompaction.ts +709 -0
  241. package/src/compaction/observational/buffering.ts +430 -0
  242. package/src/compaction/observational/constants.ts +532 -0
  243. package/src/compaction/observational/index.ts +837 -0
  244. package/src/compaction/observational/observer.ts +510 -0
  245. package/src/compaction/observational/recall-tool.ts +130 -0
  246. package/src/compaction/observational/reflector.ts +221 -0
  247. package/src/compaction/observational/types.ts +343 -0
  248. package/src/context-manager.ts +237 -0
  249. package/src/cortex-agent.ts +4297 -0
  250. package/src/error-classifier.ts +199 -0
  251. package/src/event-bridge.ts +508 -0
  252. package/src/index.ts +292 -0
  253. package/src/mcp-client.ts +582 -0
  254. package/src/model-wrapper.ts +128 -0
  255. package/src/noop-logger.ts +9 -0
  256. package/src/prompt-diagnostics.ts +296 -0
  257. package/src/provider-manager.ts +823 -0
  258. package/src/provider-registry.ts +386 -0
  259. package/src/schema-converter.ts +51 -0
  260. package/src/skill-preprocessor.ts +314 -0
  261. package/src/skill-registry.ts +378 -0
  262. package/src/skill-tool.ts +130 -0
  263. package/src/sub-agent-manager.ts +236 -0
  264. package/src/token-estimator.ts +26 -0
  265. package/src/tool-contract.ts +113 -0
  266. package/src/tool-result-persistence.ts +197 -0
  267. package/src/tools/bash/index.ts +633 -0
  268. package/src/tools/bash/interactive.ts +302 -0
  269. package/src/tools/bash/safety.ts +1297 -0
  270. package/src/tools/edit.ts +422 -0
  271. package/src/tools/glob.ts +330 -0
  272. package/src/tools/grep.ts +819 -0
  273. package/src/tools/index.ts +110 -0
  274. package/src/tools/read.ts +580 -0
  275. package/src/tools/runtime.ts +173 -0
  276. package/src/tools/shared/cwd-tracker.ts +50 -0
  277. package/src/tools/shared/edit-history.ts +96 -0
  278. package/src/tools/shared/edit-matcher.ts +457 -0
  279. package/src/tools/shared/file-mutation-lock.ts +40 -0
  280. package/src/tools/shared/gitignore.ts +61 -0
  281. package/src/tools/shared/pdf-extractor.ts +290 -0
  282. package/src/tools/shared/read-registry.ts +93 -0
  283. package/src/tools/shared/safe-env.ts +82 -0
  284. package/src/tools/sub-agent.ts +171 -0
  285. package/src/tools/task-output.ts +236 -0
  286. package/src/tools/tool-search/index.ts +167 -0
  287. package/src/tools/tool-search/registry.ts +278 -0
  288. package/src/tools/undo-edit.ts +314 -0
  289. package/src/tools/web-fetch/cache.ts +112 -0
  290. package/src/tools/web-fetch/index.ts +604 -0
  291. package/src/tools/write.ts +385 -0
  292. package/src/types.ts +1057 -0
  293. package/src/working-tags.ts +118 -0
@@ -0,0 +1,582 @@
1
+ /**
2
+ * McpClientManager: connects to MCP servers and wraps discovered tools
3
+ * as AgentTool objects for registration with pi-agent-core.
4
+ *
5
+ * Handles two transport types:
6
+ * - Stdio: spawns a subprocess, communicates via stdin/stdout
7
+ * - HTTP: connects to an already-running Streamable HTTP server
8
+ *
9
+ * Tool discovery via tools/list, tool execution via tools/call.
10
+ * Connections are persistent (kept alive between ticks) with health monitoring.
11
+ * Reconnect on subprocess crash (3 attempts before deregistering).
12
+ *
13
+ * References:
14
+ * - docs/cortex/mcp-integration.md
15
+ * - docs/cortex/plans/phase-3-plugin-tools.md
16
+ */
17
+
18
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
19
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
20
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
21
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
22
+ import { Type } from 'typebox';
23
+ import type { McpTransportConfig, McpConnectionState, McpStdioConfig, McpHttpConfig, CortexLogger } from './types.js';
24
+ import type { CortexTool } from './tool-contract.js';
25
+ import { NOOP_LOGGER } from './noop-logger.js';
26
+ import { buildSafeEnv } from './tools/shared/safe-env.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Tool contract
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Backward-compatible export name for Cortex's canonical tool contract.
34
+ */
35
+ export type AgentTool = CortexTool;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Internal connection record
39
+ // ---------------------------------------------------------------------------
40
+
41
+ interface McpConnection {
42
+ serverName: string;
43
+ config: McpTransportConfig;
44
+ client: Client;
45
+ transport: StdioClientTransport | StreamableHTTPClientTransport;
46
+ tools: AgentTool[];
47
+ connected: boolean;
48
+ reconnectAttempts: number;
49
+ /** Subprocess PID for stdio transports (used for process cleanup). */
50
+ pid: number | null;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Constants
55
+ // ---------------------------------------------------------------------------
56
+
57
+ const MAX_RECONNECT_ATTEMPTS = 3;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // McpClientManager
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export class McpClientManager {
64
+ private connections = new Map<string, McpConnection>();
65
+
66
+ /**
67
+ * Callback invoked whenever the aggregate tool set changes.
68
+ * CortexAgent uses this to resync live tools after connect/disconnect/reconnect.
69
+ */
70
+ onToolsChanged?: () => void;
71
+
72
+ /**
73
+ * Callback invoked when a subprocess is spawned (for PID tracking).
74
+ * The consumer (CortexAgent) uses this to track PIDs for exit cleanup.
75
+ */
76
+ onSubprocessSpawned?: (pid: number) => void;
77
+
78
+ /**
79
+ * Callback invoked when a subprocess exits (for PID tracking).
80
+ */
81
+ onSubprocessExited?: (pid: number) => void;
82
+
83
+ /**
84
+ * Consumer-set environment variable overrides that bypass the security blocklist.
85
+ * Merged ON TOP of the sanitized environment for all stdio subprocesses.
86
+ * Used for macOS dock icon suppression vars (DYLD_INSERT_LIBRARIES, etc.).
87
+ */
88
+ envOverrides?: Record<string, string>;
89
+
90
+ /** Logger for MCP diagnostics. Set by CortexAgent after construction. */
91
+ logger: CortexLogger = NOOP_LOGGER;
92
+
93
+ /**
94
+ * Connect to an MCP server and discover its tools.
95
+ *
96
+ * Spawns a subprocess (stdio) or connects to a URL (http), performs
97
+ * the MCP handshake, calls tools/list, and wraps each discovered
98
+ * tool as an AgentTool with namespaced name.
99
+ *
100
+ * @param serverName - Unique name for this server (used for tool namespacing)
101
+ * @param config - Transport configuration
102
+ * @throws Error if connection or tool discovery fails
103
+ */
104
+ async connect(serverName: string, config: McpTransportConfig): Promise<void> {
105
+ // Disconnect existing connection with this name first
106
+ if (this.connections.has(serverName)) {
107
+ await this.disconnect(serverName);
108
+ }
109
+
110
+ this.logger.info('[MCP] connecting', { serverName, transport: config.transport });
111
+
112
+ const transport = this.createTransport(config);
113
+ const client = new Client(
114
+ { name: `cortex-${serverName}`, version: '1.0.0' },
115
+ { capabilities: {} },
116
+ );
117
+
118
+ try {
119
+ await client.connect(transport as Transport);
120
+ } catch (err) {
121
+ this.logger.error('[MCP] connection failed', { serverName, error: err instanceof Error ? err.message : String(err) });
122
+ throw new Error(`MCP connection failed for "${serverName}": ${err instanceof Error ? err.message : String(err)}`);
123
+ }
124
+
125
+ // Track subprocess PID for stdio transports
126
+ let pid: number | null = null;
127
+ if (transport instanceof StdioClientTransport) {
128
+ pid = transport.pid;
129
+ if (pid != null) {
130
+ this.onSubprocessSpawned?.(pid);
131
+ }
132
+ }
133
+
134
+ // Discover tools
135
+ let tools: AgentTool[];
136
+ try {
137
+ tools = await this.discoverTools(serverName, client);
138
+ } catch (err) {
139
+ this.logger.error('[MCP] tool discovery failed', { serverName, error: err instanceof Error ? err.message : String(err) });
140
+ // Close the connection since tool discovery failed
141
+ try {
142
+ await client.close();
143
+ } catch {
144
+ // Best-effort cleanup
145
+ }
146
+ if (pid != null) {
147
+ this.onSubprocessExited?.(pid);
148
+ }
149
+ throw new Error(`MCP tool discovery failed for "${serverName}": ${err instanceof Error ? err.message : String(err)}`);
150
+ }
151
+
152
+ const connection: McpConnection = {
153
+ serverName,
154
+ config,
155
+ client,
156
+ transport,
157
+ tools,
158
+ connected: true,
159
+ reconnectAttempts: 0,
160
+ pid,
161
+ };
162
+
163
+ // Wire close handler for reconnect on unexpected disconnect
164
+ transport.onclose = () => {
165
+ this.handleDisconnect(serverName);
166
+ };
167
+
168
+ this.connections.set(serverName, connection);
169
+ this.logger.info('[MCP] connected', { serverName, toolCount: tools.length, tools: tools.map(t => t.name) });
170
+ this.onToolsChanged?.();
171
+ }
172
+
173
+ /**
174
+ * Disconnect from a specific MCP server.
175
+ * Closes the transport and removes all tools from that server.
176
+ *
177
+ * @param serverName - The server name to disconnect
178
+ */
179
+ async disconnect(serverName: string): Promise<void> {
180
+ const conn = this.connections.get(serverName);
181
+ if (!conn) return;
182
+
183
+ this.logger.info('[MCP] disconnecting', { serverName });
184
+ conn.connected = false;
185
+
186
+ try {
187
+ await conn.client.close();
188
+ } catch (err) {
189
+ this.logger.warn('[MCP] error closing client', { serverName, error: err instanceof Error ? err.message : String(err) });
190
+ }
191
+
192
+ if (conn.pid != null) {
193
+ this.onSubprocessExited?.(conn.pid);
194
+ }
195
+
196
+ this.connections.delete(serverName);
197
+ this.onToolsChanged?.();
198
+ }
199
+
200
+ /**
201
+ * Close all MCP connections.
202
+ * Kills all stdio subprocesses and closes HTTP connections.
203
+ */
204
+ async closeAll(): Promise<void> {
205
+ const names = [...this.connections.keys()];
206
+ const results = await Promise.allSettled(
207
+ names.map(name => this.disconnect(name)),
208
+ );
209
+
210
+ for (let i = 0; i < results.length; i++) {
211
+ const result = results[i]!;
212
+ if (result.status === 'rejected') {
213
+ this.logger.warn('[MCP] failed to disconnect', { serverName: names[i], error: String((result as PromiseRejectedResult).reason) });
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Get all AgentTool objects from all connected MCP servers.
220
+ * Returns tools namespaced as serverName__toolName.
221
+ */
222
+ getTools(): AgentTool[] {
223
+ const allTools: AgentTool[] = [];
224
+ for (const conn of this.connections.values()) {
225
+ if (conn.connected) {
226
+ allTools.push(...conn.tools);
227
+ }
228
+ }
229
+ return allTools;
230
+ }
231
+
232
+ /**
233
+ * Get tool names from a specific server.
234
+ */
235
+ getServerToolNames(serverName: string): string[] {
236
+ const conn = this.connections.get(serverName);
237
+ if (!conn || !conn.connected) return [];
238
+ return conn.tools.map(t => t.name);
239
+ }
240
+
241
+ /**
242
+ * Get the connection state for all servers.
243
+ */
244
+ getConnectionStates(): McpConnectionState[] {
245
+ const states: McpConnectionState[] = [];
246
+ for (const conn of this.connections.values()) {
247
+ states.push({
248
+ serverName: conn.serverName,
249
+ config: conn.config,
250
+ connected: conn.connected,
251
+ reconnectAttempts: conn.reconnectAttempts,
252
+ toolNames: conn.tools.map(t => t.name),
253
+ });
254
+ }
255
+ return states;
256
+ }
257
+
258
+ /**
259
+ * Check if a specific server is connected.
260
+ */
261
+ isConnected(serverName: string): boolean {
262
+ const conn = this.connections.get(serverName);
263
+ return conn?.connected ?? false;
264
+ }
265
+
266
+ /**
267
+ * Get the number of active connections.
268
+ */
269
+ get connectionCount(): number {
270
+ let count = 0;
271
+ for (const conn of this.connections.values()) {
272
+ if (conn.connected) count++;
273
+ }
274
+ return count;
275
+ }
276
+
277
+ // -----------------------------------------------------------------------
278
+ // Private: Transport creation
279
+ // -----------------------------------------------------------------------
280
+
281
+ private createTransport(config: McpTransportConfig): StdioClientTransport | StreamableHTTPClientTransport {
282
+ if (config.transport === 'stdio') {
283
+ return this.createStdioTransport(config);
284
+ }
285
+ return this.createHttpTransport(config);
286
+ }
287
+
288
+ private createStdioTransport(config: McpStdioConfig): StdioClientTransport {
289
+ // Sanitize environment variables for the subprocess.
290
+ // Strip dangerous vars (LD_PRELOAD, NODE_OPTIONS, etc.) to prevent
291
+ // injection via environment. Uses the same blocklist as the Bash tool.
292
+ // envOverrides are merged ON TOP, bypassing the blocklist for
293
+ // consumer-specified variables (e.g., macOS dock icon suppression).
294
+ const baseEnv = config.env ?? process.env;
295
+ const safeEnv = buildSafeEnv(baseEnv, undefined, this.envOverrides);
296
+
297
+ // Build params object, only including defined optional fields to satisfy
298
+ // exactOptionalPropertyTypes
299
+ const params: {
300
+ command: string;
301
+ args: string[];
302
+ env: Record<string, string>;
303
+ cwd?: string;
304
+ stderr: 'pipe';
305
+ } = {
306
+ command: config.command,
307
+ args: config.args ?? [],
308
+ env: safeEnv,
309
+ stderr: 'pipe',
310
+ };
311
+ if (config.cwd !== undefined) params.cwd = config.cwd;
312
+
313
+ return new StdioClientTransport(params);
314
+ }
315
+
316
+ private createHttpTransport(config: McpHttpConfig): StreamableHTTPClientTransport {
317
+ const url = new URL(config.url);
318
+
319
+ if (config.headers && Object.keys(config.headers).length > 0) {
320
+ return new StreamableHTTPClientTransport(url, {
321
+ requestInit: {
322
+ headers: config.headers,
323
+ },
324
+ });
325
+ }
326
+
327
+ return new StreamableHTTPClientTransport(url);
328
+ }
329
+
330
+ // -----------------------------------------------------------------------
331
+ // Private: Tool discovery and wrapping
332
+ // -----------------------------------------------------------------------
333
+
334
+ /**
335
+ * Discover tools from a connected MCP server and wrap them as AgentTools.
336
+ */
337
+ private async discoverTools(serverName: string, client: Client): Promise<AgentTool[]> {
338
+ const response = await client.listTools();
339
+ const tools: AgentTool[] = [];
340
+
341
+ for (const mcpTool of response.tools) {
342
+ tools.push(this.wrapMcpTool(
343
+ serverName,
344
+ {
345
+ name: mcpTool.name,
346
+ description: mcpTool.description,
347
+ inputSchema: mcpTool.inputSchema as Record<string, unknown> | undefined,
348
+ },
349
+ client,
350
+ ));
351
+ }
352
+
353
+ return tools;
354
+ }
355
+
356
+ /**
357
+ * Wrap a single MCP tool definition as an AgentTool.
358
+ *
359
+ * Key details:
360
+ * - Name is prefixed with serverName__ for namespacing
361
+ * - JSON Schema from MCP is wrapped via Type.Unsafe() for TypeBox
362
+ * - execute() calls tools/call on the MCP connection using the original name
363
+ * - Errors are caught and returned as error results (not thrown)
364
+ */
365
+ private wrapMcpTool(
366
+ serverName: string,
367
+ mcpTool: { name: string; description?: string | undefined; inputSchema?: Record<string, unknown> | undefined },
368
+ client: Client,
369
+ ): AgentTool {
370
+ const namespacedName = `${serverName}__${mcpTool.name}`;
371
+
372
+ // Wrap the MCP JSON Schema as a TypeBox type via Type.Unsafe()
373
+ const inputSchema = mcpTool.inputSchema ?? { type: 'object', properties: {} };
374
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
375
+ const parameters = Type.Unsafe(inputSchema as any);
376
+
377
+ return {
378
+ name: namespacedName,
379
+ description: mcpTool.description ?? '',
380
+ parameters,
381
+ // Marks this tool as MCP-sourced so CortexAgent's deferred-tool
382
+ // partitioning can identify it without rechecking by name prefix.
383
+ isMcp: true,
384
+ execute: async (args: unknown): Promise<unknown> => {
385
+ try {
386
+ const result = await client.callTool({
387
+ name: mcpTool.name, // Original name (no prefix)
388
+ arguments: (args ?? {}) as Record<string, unknown>,
389
+ });
390
+
391
+ // Return text content from MCP result
392
+ if (result.isError) {
393
+ const errorText = Array.isArray(result.content)
394
+ ? result.content
395
+ .filter((c): c is { type: 'text'; text: string } =>
396
+ typeof c === 'object' && c !== null && 'type' in c && c.type === 'text')
397
+ .map(c => c.text)
398
+ .join('\n')
399
+ : String(result.content);
400
+ throw new Error(errorText || 'MCP tool call failed');
401
+ }
402
+
403
+ const normalizedContent: Array<
404
+ | { type: 'text'; text: string }
405
+ | { type: 'image'; data: string; mimeType: string }
406
+ > = [];
407
+
408
+ if (Array.isArray(result.content)) {
409
+ for (const item of result.content) {
410
+ if (!item || typeof item !== 'object') {
411
+ normalizedContent.push({ type: 'text', text: String(item) });
412
+ continue;
413
+ }
414
+
415
+ const block = item as Record<string, unknown>;
416
+ const type = block['type'];
417
+
418
+ if (type === 'text' && typeof block['text'] === 'string') {
419
+ normalizedContent.push({ type: 'text', text: block['text'] });
420
+ continue;
421
+ }
422
+
423
+ if (type === 'image' &&
424
+ typeof block['data'] === 'string' &&
425
+ typeof block['mimeType'] === 'string') {
426
+ normalizedContent.push({
427
+ type: 'image',
428
+ data: block['data'],
429
+ mimeType: block['mimeType'],
430
+ });
431
+ continue;
432
+ }
433
+
434
+ normalizedContent.push({
435
+ type: 'text',
436
+ text: JSON.stringify(block, null, 2),
437
+ });
438
+ }
439
+ }
440
+
441
+ if (normalizedContent.length === 0) {
442
+ const structuredContent = (result as Record<string, unknown>)['structuredContent'];
443
+ if (structuredContent !== undefined) {
444
+ normalizedContent.push({
445
+ type: 'text',
446
+ text: JSON.stringify(structuredContent, null, 2),
447
+ });
448
+ } else {
449
+ normalizedContent.push({
450
+ type: 'text',
451
+ text: String(result.content ?? ''),
452
+ });
453
+ }
454
+ }
455
+
456
+ return {
457
+ content: normalizedContent,
458
+ details: {
459
+ structuredContent: (result as Record<string, unknown>)['structuredContent'] ?? null,
460
+ rawContent: result.content ?? null,
461
+ },
462
+ };
463
+ } catch (err) {
464
+ // Re-throw as a standard error for pi-agent-core to handle
465
+ if (err instanceof Error) throw err;
466
+ throw new Error(`MCP tool call "${mcpTool.name}" failed: ${String(err)}`);
467
+ }
468
+ },
469
+ };
470
+ }
471
+
472
+ // -----------------------------------------------------------------------
473
+ // Private: Reconnect handling
474
+ // -----------------------------------------------------------------------
475
+
476
+ /**
477
+ * Handle unexpected disconnection from an MCP server.
478
+ * Attempts reconnection up to MAX_RECONNECT_ATTEMPTS times.
479
+ */
480
+ private handleDisconnect(serverName: string): void {
481
+ const conn = this.connections.get(serverName);
482
+ if (!conn) return;
483
+
484
+ // Already disconnected intentionally
485
+ if (!conn.connected) return;
486
+
487
+ conn.connected = false;
488
+ conn.reconnectAttempts++;
489
+
490
+ if (conn.pid != null) {
491
+ this.onSubprocessExited?.(conn.pid);
492
+ conn.pid = null;
493
+ }
494
+
495
+ this.logger.warn('[MCP] unexpected disconnect', { serverName, attempt: conn.reconnectAttempts, maxAttempts: MAX_RECONNECT_ATTEMPTS });
496
+
497
+ if (conn.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
498
+ this.logger.error('[MCP] reconnect exhausted, deregistering', { serverName, maxAttempts: MAX_RECONNECT_ATTEMPTS });
499
+ this.connections.delete(serverName);
500
+ this.onToolsChanged?.();
501
+ return;
502
+ }
503
+
504
+ // Attempt reconnect asynchronously
505
+ this.attemptReconnect(serverName, conn.config).catch((err) => {
506
+ this.logger.error('[MCP] reconnect attempt failed', { serverName, error: err instanceof Error ? err.message : String(err) });
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Attempt to reconnect to an MCP server.
512
+ */
513
+ private async attemptReconnect(serverName: string, config: McpTransportConfig): Promise<void> {
514
+ // Brief delay before reconnect
515
+ await new Promise(resolve => setTimeout(resolve, 1000));
516
+
517
+ const existing = this.connections.get(serverName);
518
+ if (!existing) return; // Was deregistered during delay
519
+
520
+ let client: Client | null = null;
521
+ let pid: number | null = null;
522
+
523
+ try {
524
+ // Attempt fresh connection
525
+ const transport = this.createTransport(config);
526
+ client = new Client(
527
+ { name: `cortex-${serverName}`, version: '1.0.0' },
528
+ { capabilities: {} },
529
+ );
530
+
531
+ await client.connect(transport as Transport);
532
+
533
+ // Track subprocess PID
534
+ if (transport instanceof StdioClientTransport) {
535
+ pid = transport.pid;
536
+ if (pid != null) {
537
+ this.onSubprocessSpawned?.(pid);
538
+ }
539
+ }
540
+
541
+ // Rediscover tools
542
+ const tools = await this.discoverTools(serverName, client);
543
+
544
+ // Wire close handler
545
+ transport.onclose = () => {
546
+ this.handleDisconnect(serverName);
547
+ };
548
+
549
+ // Update connection record
550
+ existing.client = client;
551
+ existing.transport = transport;
552
+ existing.tools = tools;
553
+ existing.connected = true;
554
+ existing.pid = pid;
555
+ // Keep reconnectAttempts as-is (reset only on fresh connect)
556
+
557
+ this.logger.info('[MCP] reconnected', { serverName, toolCount: tools.length });
558
+ this.onToolsChanged?.();
559
+ } catch (err) {
560
+ // Clean up resources from partial connection
561
+ if (client) {
562
+ try { await client.close(); } catch { /* best-effort */ }
563
+ }
564
+ if (pid != null) {
565
+ this.onSubprocessExited?.(pid);
566
+ }
567
+
568
+ this.logger.warn('[MCP] reconnect failed', { serverName, error: err instanceof Error ? err.message : String(err) });
569
+ existing.reconnectAttempts++;
570
+ if (existing.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
571
+ this.logger.error('[MCP] max reconnect attempts exceeded, deregistering', { serverName });
572
+ this.connections.delete(serverName);
573
+ this.onToolsChanged?.();
574
+ } else {
575
+ // Schedule another attempt since transport.onclose may not fire
576
+ this.attemptReconnect(serverName, config).catch((retryErr) => {
577
+ this.logger.error('[MCP] subsequent reconnect failed', { serverName, error: retryErr instanceof Error ? retryErr.message : String(retryErr) });
578
+ });
579
+ }
580
+ }
581
+ }
582
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * CortexModel: branded opaque type for type-safe model passing.
3
+ *
4
+ * Wraps pi-ai's Model<any> with a branded type to prevent consumers
5
+ * from accidentally passing raw pi-ai models where cortex models
6
+ * are expected (and vice versa).
7
+ *
8
+ * The consumer can read provider, modelId, and contextWindow for
9
+ * display and configuration. The underlying pi-ai Model object is
10
+ * accessed internally by CortexAgent when constructing the
11
+ * pi-agent-core Agent.
12
+ *
13
+ * Reference: provider-manager.md
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Branded type
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Opaque model handle. The consumer receives this from ProviderManager
22
+ * and passes it to CortexAgent. The consumer never inspects its internals
23
+ * beyond the declared fields.
24
+ *
25
+ * Internally, this wraps pi-ai's Model<T> type.
26
+ */
27
+ export interface CortexModel {
28
+ /** @internal Brand tag for nominal type safety. */
29
+ readonly __brand: 'CortexModel';
30
+ /** Provider identifier (e.g., 'anthropic', 'openai', 'google'). */
31
+ readonly provider: string;
32
+ /** Model identifier (e.g., 'claude-sonnet-4-20250514'). */
33
+ readonly modelId: string;
34
+ /** Context window size in tokens. */
35
+ readonly contextWindow: number;
36
+ }
37
+
38
+ // The symbol key used to store the underlying pi-ai model.
39
+ const INNER_MODEL = Symbol.for('cortex.innerModel');
40
+
41
+ /**
42
+ * Internal storage shape: a CortexModel with the hidden pi-ai model
43
+ * attached via a Symbol key.
44
+ */
45
+ interface WrappedModel extends CortexModel {
46
+ [INNER_MODEL]: unknown;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Public API
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Wrap a pi-ai Model object into a CortexModel.
55
+ *
56
+ * @param model - The pi-ai Model object to wrap
57
+ * @param provider - The provider identifier
58
+ * @param modelId - The model identifier
59
+ * @param contextWindow - The context window size (default: 200000)
60
+ * @returns An opaque CortexModel handle
61
+ */
62
+ export function wrapModel(
63
+ model: unknown,
64
+ provider: string,
65
+ modelId: string,
66
+ contextWindow?: number,
67
+ ): CortexModel {
68
+ const wrapped: WrappedModel = {
69
+ __brand: 'CortexModel' as const,
70
+ provider,
71
+ modelId,
72
+ contextWindow: contextWindow ?? extractContextWindow(model) ?? 200_000,
73
+ [INNER_MODEL]: model,
74
+ };
75
+ return wrapped;
76
+ }
77
+
78
+ /**
79
+ * Unwrap a CortexModel to retrieve the underlying pi-ai Model object.
80
+ *
81
+ * @param cortexModel - The CortexModel to unwrap
82
+ * @returns The underlying pi-ai Model object
83
+ * @throws Error if the object is not a valid CortexModel
84
+ */
85
+ export function unwrapModel(cortexModel: CortexModel): unknown {
86
+ if (!isCortexModel(cortexModel)) {
87
+ throw new Error('Expected a CortexModel created by wrapModel()');
88
+ }
89
+ return (cortexModel as WrappedModel)[INNER_MODEL];
90
+ }
91
+
92
+ /**
93
+ * Check whether a value is a valid CortexModel (has the correct brand
94
+ * and contains a wrapped inner model).
95
+ *
96
+ * @param value - The value to check
97
+ * @returns True if the value is a valid CortexModel
98
+ */
99
+ export function isCortexModel(value: unknown): value is CortexModel {
100
+ if (!value || typeof value !== 'object') return false;
101
+ const obj = value as Record<string | symbol, unknown>;
102
+ return (
103
+ obj['__brand'] === 'CortexModel' &&
104
+ typeof obj['provider'] === 'string' &&
105
+ typeof obj['modelId'] === 'string' &&
106
+ typeof obj['contextWindow'] === 'number' &&
107
+ INNER_MODEL in obj
108
+ );
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Internal helpers
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Attempt to extract the context window size from a pi-ai Model object.
117
+ * Pi-ai models expose contextWindow as a property.
118
+ */
119
+ function extractContextWindow(model: unknown): number | undefined {
120
+ if (model && typeof model === 'object') {
121
+ const obj = model as Record<string, unknown>;
122
+ const cw = obj['contextWindow'];
123
+ if (typeof cw === 'number') {
124
+ return cw;
125
+ }
126
+ }
127
+ return undefined;
128
+ }