@oh-my-pi/pi-coding-agent 1.337.1 → 1.340.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.340.0] - 2026-01-03
6
+
7
+ ### Changed
8
+
9
+ - Replaced vendored highlight.js and marked.js with CDN-hosted versions for smaller exports
10
+ - Added runtime minification for HTML, CSS, and JS in session exports
11
+ - Session share URL now uses gistpreview.github.io instead of shittycodingagent.ai
12
+
13
+ ## [1.339.0] - 2026-01-03
14
+
15
+ ### Added
16
+
17
+ - MCP project config setting to disable loading `.mcp.json`/`mcp.json` from project root
18
+ - Support for both `mcp.json` and `.mcp.json` filenames (prefers `mcp.json` if both exist)
19
+ - Automatic Exa MCP server filtering with API key extraction for native integration
20
+
21
+ ## [1.338.0] - 2026-01-03
22
+
23
+ ### Added
24
+
25
+ - Bash interceptor setting to block shell commands that have dedicated tools (disabled by default, enable via `/settings`)
26
+
27
+ ### Changed
28
+
29
+ - Refactored settings UI to declarative definitions for easier maintenance
30
+ - Shell detection now respects `$SHELL` environment variable before falling back to bash/sh
31
+ - Tool binary detection now uses `Bun.which()` instead of spawning processes
32
+
33
+ ### Fixed
34
+
35
+ - CLI help text now accurately lists all default tools
36
+
5
37
  ## [1.337.1] - 2026-01-02
6
38
 
7
39
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "1.337.1",
3
+ "version": "1.340.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -33,8 +33,8 @@
33
33
  "clean": "rm -rf dist",
34
34
  "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
35
35
  "build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
36
- "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html/vendor && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
37
- "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && mkdir -p dist/export-html/vendor && cp src/core/export-html/template.html dist/export-html/ && cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && cp -r docs dist/ && cp -r examples dist/",
36
+ "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/",
37
+ "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && mkdir -p dist/export-html && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/export-html/ && cp -r docs dist/ && cp -r examples dist/",
38
38
  "test": "vitest --run",
39
39
  "prepublishOnly": "npm run clean && npm run build"
40
40
  },
package/src/cli/args.ts CHANGED
@@ -234,13 +234,19 @@ ${chalk.bold("Environment Variables:")}
234
234
  ${chalk.dim("# Configuration")}
235
235
  ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)
236
236
 
237
- ${chalk.bold("Available Tools (default: read, bash, edit, write):")}
238
- read - Read file contents
239
- bash - Execute bash commands
240
- edit - Edit files with find/replace
241
- write - Write files (creates/overwrites)
242
- grep - Search file contents (read-only, off by default)
243
- find - Find files by glob pattern (read-only, off by default)
244
- ls - List directory contents (read-only, off by default)
237
+ ${chalk.bold("Available Tools (all enabled by default):")}
238
+ read - Read file contents
239
+ bash - Execute bash commands
240
+ edit - Edit files with find/replace
241
+ write - Write files (creates/overwrites)
242
+ grep - Search file contents
243
+ find - Find files by glob pattern
244
+ ls - List directory contents
245
+ lsp - Language server protocol (code intelligence)
246
+ notebook - Edit Jupyter notebooks
247
+ task - Launch sub-agents for parallel tasks
248
+ web_fetch - Fetch and process web pages
249
+ web_search - Search the web
250
+ ask - Ask user questions (interactive mode only)
245
251
  `);
246
252
  }
@@ -5,6 +5,34 @@ import { APP_NAME, getExportTemplateDir } from "../../config.js";
5
5
  import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
6
6
  import { SessionManager } from "../session-manager.js";
7
7
 
8
+ // Cached minified assets (populated on first use)
9
+ let cachedTemplate: string | null = null;
10
+ let cachedJs: string | null = null;
11
+
12
+ /** Minify CSS by removing comments, unnecessary whitespace, and newlines. */
13
+ function minifyCss(css: string): string {
14
+ return css
15
+ .replace(/\/\*[\s\S]*?\*\//g, "") // Remove comments
16
+ .replace(/\s+/g, " ") // Collapse whitespace
17
+ .replace(/\s*([{}:;,>+~])\s*/g, "$1") // Remove space around punctuation
18
+ .replace(/;}/g, "}") // Remove trailing semicolons
19
+ .trim();
20
+ }
21
+
22
+ /** Minify JS using Bun's transpiler. */
23
+ function minifyJs(js: string): string {
24
+ const transpiler = new Bun.Transpiler({ loader: "js", minifyWhitespace: true });
25
+ return transpiler.transformSync(js);
26
+ }
27
+
28
+ /** Minify HTML by collapsing whitespace outside of tags. */
29
+ function minifyHtml(html: string): string {
30
+ return html
31
+ .replace(/>\s+</g, "><") // Remove whitespace between tags
32
+ .replace(/\s{2,}/g, " ") // Collapse multiple spaces
33
+ .trim();
34
+ }
35
+
8
36
  export interface ExportOptions {
9
37
  outputPath?: string;
10
38
  themeName?: string;
@@ -111,11 +139,16 @@ interface SessionData {
111
139
  */
112
140
  function generateHtml(sessionData: SessionData, themeName?: string): string {
113
141
  const templateDir = getExportTemplateDir();
114
- const template = readFileSync(join(templateDir, "template.html"), "utf-8");
142
+
143
+ // Load and minify assets on first use
144
+ if (!cachedTemplate) {
145
+ cachedTemplate = minifyHtml(readFileSync(join(templateDir, "template.html"), "utf-8"));
146
+ }
147
+ if (!cachedJs) {
148
+ cachedJs = minifyJs(readFileSync(join(templateDir, "template.js"), "utf-8"));
149
+ }
150
+
115
151
  const templateCss = readFileSync(join(templateDir, "template.css"), "utf-8");
116
- const templateJs = readFileSync(join(templateDir, "template.js"), "utf-8");
117
- const markedJs = readFileSync(join(templateDir, "vendor", "marked.min.js"), "utf-8");
118
- const hljsJs = readFileSync(join(templateDir, "vendor", "highlight.min.js"), "utf-8");
119
152
 
120
153
  const themeVars = generateThemeVars(themeName);
121
154
  const colors = getResolvedThemeColors(themeName);
@@ -127,19 +160,19 @@ function generateHtml(sessionData: SessionData, themeName?: string): string {
127
160
  // Base64 encode session data to avoid escaping issues
128
161
  const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64");
129
162
 
130
- // Build the CSS with theme variables injected
131
- const css = templateCss
132
- .replace("{{THEME_VARS}}", themeVars)
133
- .replace("{{BODY_BG}}", bodyBg)
134
- .replace("{{CONTAINER_BG}}", containerBg)
135
- .replace("{{INFO_BG}}", infoBg);
163
+ // Build and minify the CSS with theme variables injected
164
+ const css = minifyCss(
165
+ templateCss
166
+ .replace("{{THEME_VARS}}", themeVars)
167
+ .replace("{{BODY_BG}}", bodyBg)
168
+ .replace("{{CONTAINER_BG}}", containerBg)
169
+ .replace("{{INFO_BG}}", infoBg),
170
+ );
136
171
 
137
- return template
172
+ return cachedTemplate
138
173
  .replace("{{CSS}}", css)
139
- .replace("{{JS}}", templateJs)
140
- .replace("{{SESSION_DATA}}", sessionDataBase64)
141
- .replace("{{MARKED_JS}}", markedJs)
142
- .replace("{{HIGHLIGHT_JS}}", hljsJs);
174
+ .replace("{{JS}}", cachedJs)
175
+ .replace("{{SESSION_DATA}}", sessionDataBase64);
143
176
  }
144
177
 
145
178
  /**
@@ -39,16 +39,8 @@
39
39
  </div>
40
40
 
41
41
  <script id="session-data" type="application/json">{{SESSION_DATA}}</script>
42
-
43
- <!-- Vendored libraries -->
44
- <script>{{MARKED_JS}}</script>
45
-
46
- <!-- highlight.js -->
47
- <script>{{HIGHLIGHT_JS}}</script>
48
-
49
- <!-- Main application code -->
50
- <script>
51
- {{JS}}
52
- </script>
42
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.4/marked.min.js" integrity="sha512-VmLxPVdDGeR+F0DzUHVqzHwaR4ZSSh1g/7aYXwKT1PAGVxunOEcysta+4H5Utvmpr2xExEPybZ8q+iM9F1tGdw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
43
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
44
+ <script>{{JS}}</script>
53
45
  </body>
54
46
  </html>
@@ -25,12 +25,32 @@ import type {
25
25
  /** MCP protocol version we support */
26
26
  const PROTOCOL_VERSION = "2025-03-26";
27
27
 
28
+ /** Default connection timeout in ms */
29
+ const CONNECTION_TIMEOUT_MS = 30_000;
30
+
28
31
  /** Client info sent during initialization */
29
32
  const CLIENT_INFO = {
30
33
  name: "pi-coding-agent",
31
34
  version: "1.0.0",
32
35
  };
33
36
 
37
+ /** Wrap a promise with a timeout */
38
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
39
+ return new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => reject(new Error(message)), ms);
41
+ promise.then(
42
+ (value) => {
43
+ clearTimeout(timer);
44
+ resolve(value);
45
+ },
46
+ (error) => {
47
+ clearTimeout(timer);
48
+ reject(error);
49
+ },
50
+ );
51
+ });
52
+ }
53
+
34
54
  /**
35
55
  * Create a transport for the given server config.
36
56
  */
@@ -73,24 +93,31 @@ async function initializeConnection(transport: MCPTransport): Promise<MCPInitial
73
93
 
74
94
  /**
75
95
  * Connect to an MCP server.
96
+ * Has a 30 second timeout to prevent blocking startup.
76
97
  */
77
98
  export async function connectToServer(name: string, config: MCPServerConfig): Promise<MCPServerConnection> {
78
- const transport = await createTransport(config);
79
-
80
- try {
81
- const initResult = await initializeConnection(transport);
82
-
83
- return {
84
- name,
85
- config,
86
- transport,
87
- serverInfo: initResult.serverInfo,
88
- capabilities: initResult.capabilities,
89
- };
90
- } catch (error) {
91
- await transport.close();
92
- throw error;
93
- }
99
+ const timeoutMs = config.timeout ?? CONNECTION_TIMEOUT_MS;
100
+
101
+ const connect = async (): Promise<MCPServerConnection> => {
102
+ const transport = await createTransport(config);
103
+
104
+ try {
105
+ const initResult = await initializeConnection(transport);
106
+
107
+ return {
108
+ name,
109
+ config,
110
+ transport,
111
+ serverInfo: initResult.serverInfo,
112
+ capabilities: initResult.capabilities,
113
+ };
114
+ } catch (error) {
115
+ await transport.close();
116
+ throw error;
117
+ }
118
+ };
119
+
120
+ return withTimeout(connect(), timeoutMs, `Connection to MCP server "${name}" timed out after ${timeoutMs}ms`);
94
121
  }
95
122
 
96
123
  /**
@@ -91,11 +91,17 @@ export interface MCPConfigLocations {
91
91
  */
92
92
  export function getMCPConfigPaths(cwd: string): MCPConfigLocations {
93
93
  const home = homedir();
94
+
95
+ // Project-level: check both mcp.json and .mcp.json (prefer mcp.json if both exist)
96
+ const mcpJson = join(cwd, "mcp.json");
97
+ const dotMcpJson = join(cwd, ".mcp.json");
98
+ const projectPath = existsSync(mcpJson) ? mcpJson : dotMcpJson;
99
+
94
100
  return {
95
101
  // User-level: ~/.pi/mcp.json (our standard)
96
102
  user: join(home, ".pi", "mcp.json"),
97
- // Project-level: .mcp.json at project root
98
- project: join(cwd, ".mcp.json"),
103
+ // Project-level: mcp.json or .mcp.json at project root
104
+ project: projectPath,
99
105
  };
100
106
  }
101
107
 
@@ -115,17 +121,157 @@ export function mergeMCPConfigs(...configs: (MCPConfigFile | null)[]): Record<st
115
121
  return result;
116
122
  }
117
123
 
124
+ /** Options for loading MCP configs */
125
+ export interface LoadMCPConfigsOptions {
126
+ /** Additional environment variables for expansion */
127
+ extraEnv?: Record<string, string>;
128
+ /** Whether to load project-level config (default: true) */
129
+ enableProjectConfig?: boolean;
130
+ /** Whether to filter out Exa MCP servers (default: true) */
131
+ filterExa?: boolean;
132
+ }
133
+
134
+ /** Result of loading MCP configs */
135
+ export interface LoadMCPConfigsResult {
136
+ /** Loaded server configs */
137
+ configs: Record<string, MCPServerConfig>;
138
+ /** Extracted Exa API keys (if any were filtered) */
139
+ exaApiKeys: string[];
140
+ }
141
+
118
142
  /**
119
143
  * Load all MCP server configs from standard locations.
120
144
  * Returns merged config with project overriding user.
145
+ *
146
+ * @param cwd Working directory (project root)
147
+ * @param options Load options or extraEnv for backwards compatibility
121
148
  */
122
- export function loadAllMCPConfigs(cwd: string, extraEnv?: Record<string, string>): Record<string, MCPServerConfig> {
149
+ export function loadAllMCPConfigs(
150
+ cwd: string,
151
+ options?: LoadMCPConfigsOptions | Record<string, string>,
152
+ ): LoadMCPConfigsResult {
153
+ // Support old signature: loadAllMCPConfigs(cwd, extraEnv)
154
+ const opts: LoadMCPConfigsOptions =
155
+ options && ("extraEnv" in options || "enableProjectConfig" in options || "filterExa" in options)
156
+ ? (options as LoadMCPConfigsOptions)
157
+ : { extraEnv: options as Record<string, string> | undefined };
158
+
159
+ const enableProjectConfig = opts.enableProjectConfig ?? true;
160
+ const filterExa = opts.filterExa ?? true;
161
+
123
162
  const paths = getMCPConfigPaths(cwd);
124
163
 
125
- const userConfig = paths.user ? loadMCPConfigFile(paths.user, extraEnv) : null;
126
- const projectConfig = paths.project ? loadMCPConfigFile(paths.project, extraEnv) : null;
164
+ const userConfig = paths.user ? loadMCPConfigFile(paths.user, opts.extraEnv) : null;
165
+ const projectConfig = enableProjectConfig && paths.project ? loadMCPConfigFile(paths.project, opts.extraEnv) : null;
166
+
167
+ let configs = mergeMCPConfigs(userConfig, projectConfig);
168
+ let exaApiKeys: string[] = [];
169
+
170
+ if (filterExa) {
171
+ const result = filterExaMCPServers(configs);
172
+ configs = result.configs;
173
+ exaApiKeys = result.exaApiKeys;
174
+ }
175
+
176
+ return { configs, exaApiKeys };
177
+ }
178
+
179
+ /** Pattern to match Exa MCP servers */
180
+ const EXA_MCP_URL_PATTERN = /mcp\.exa\.ai/i;
181
+ const EXA_API_KEY_PATTERN = /exaApiKey=([^&\s]+)/i;
182
+
183
+ /**
184
+ * Check if a server config is an Exa MCP server.
185
+ */
186
+ export function isExaMCPServer(name: string, config: MCPServerConfig): boolean {
187
+ // Check by server name
188
+ if (name.toLowerCase() === "exa") {
189
+ return true;
190
+ }
191
+
192
+ // Check by URL for HTTP/SSE servers
193
+ if (config.type === "http" || config.type === "sse") {
194
+ const httpConfig = config as { url?: string };
195
+ if (httpConfig.url && EXA_MCP_URL_PATTERN.test(httpConfig.url)) {
196
+ return true;
197
+ }
198
+ }
199
+
200
+ // Check by args for stdio servers (e.g., mcp-remote to exa)
201
+ if (!config.type || config.type === "stdio") {
202
+ const stdioConfig = config as { args?: string[] };
203
+ if (stdioConfig.args?.some((arg) => EXA_MCP_URL_PATTERN.test(arg))) {
204
+ return true;
205
+ }
206
+ }
207
+
208
+ return false;
209
+ }
210
+
211
+ /**
212
+ * Extract Exa API key from an MCP server config.
213
+ */
214
+ export function extractExaApiKey(config: MCPServerConfig): string | undefined {
215
+ // Check URL for HTTP/SSE servers
216
+ if (config.type === "http" || config.type === "sse") {
217
+ const httpConfig = config as { url?: string };
218
+ if (httpConfig.url) {
219
+ const match = EXA_API_KEY_PATTERN.exec(httpConfig.url);
220
+ if (match) return match[1];
221
+ }
222
+ }
223
+
224
+ // Check args for stdio servers
225
+ if (!config.type || config.type === "stdio") {
226
+ const stdioConfig = config as { args?: string[] };
227
+ if (stdioConfig.args) {
228
+ for (const arg of stdioConfig.args) {
229
+ const match = EXA_API_KEY_PATTERN.exec(arg);
230
+ if (match) return match[1];
231
+ }
232
+ }
233
+ }
234
+
235
+ // Check env vars
236
+ if ("env" in config && config.env) {
237
+ const envConfig = config as { env: Record<string, string> };
238
+ if (envConfig.env.EXA_API_KEY) {
239
+ return envConfig.env.EXA_API_KEY;
240
+ }
241
+ }
242
+
243
+ return undefined;
244
+ }
245
+
246
+ /** Result of filtering Exa MCP servers */
247
+ export interface ExaFilterResult {
248
+ /** Configs with Exa servers removed */
249
+ configs: Record<string, MCPServerConfig>;
250
+ /** Extracted Exa API keys (if any) */
251
+ exaApiKeys: string[];
252
+ }
253
+
254
+ /**
255
+ * Filter out Exa MCP servers and extract their API keys.
256
+ * Since we have native Exa integration, we don't need the MCP server.
257
+ */
258
+ export function filterExaMCPServers(configs: Record<string, MCPServerConfig>): ExaFilterResult {
259
+ const filtered: Record<string, MCPServerConfig> = {};
260
+ const exaApiKeys: string[] = [];
261
+
262
+ for (const [name, config] of Object.entries(configs)) {
263
+ if (isExaMCPServer(name, config)) {
264
+ // Extract API key before filtering
265
+ const apiKey = extractExaApiKey(config);
266
+ if (apiKey) {
267
+ exaApiKeys.push(apiKey);
268
+ }
269
+ } else {
270
+ filtered[name] = config;
271
+ }
272
+ }
127
273
 
128
- return mergeMCPConfigs(userConfig, projectConfig);
274
+ return { configs: filtered, exaApiKeys };
129
275
  }
130
276
 
131
277
  /**
@@ -9,19 +9,23 @@
9
9
  export { callTool, connectToServer, disconnectServer, listTools, serverSupportsTools } from "./client.js";
10
10
 
11
11
  // Config
12
+ export type { ExaFilterResult, LoadMCPConfigsOptions, LoadMCPConfigsResult } from "./config.js";
12
13
  export {
13
14
  expandEnvVars,
15
+ extractExaApiKey,
16
+ filterExaMCPServers,
14
17
  getMCPConfigPaths,
18
+ isExaMCPServer,
15
19
  loadAllMCPConfigs,
16
20
  loadMCPConfigFile,
17
21
  mergeMCPConfigs,
18
22
  validateServerConfig,
19
23
  } from "./config.js";
20
24
  // Loader (for SDK integration)
21
- export type { MCPToolsLoadResult } from "./loader.js";
25
+ export type { MCPToolsLoadOptions, MCPToolsLoadResult } from "./loader.js";
22
26
  export { discoverAndLoadMCPTools } from "./loader.js";
23
27
  // Manager
24
- export type { MCPLoadResult } from "./manager.js";
28
+ export type { MCPDiscoverOptions, MCPLoadResult } from "./manager.js";
25
29
  export { createMCPManager, MCPManager } from "./manager.js";
26
30
  // Tool bridge
27
31
  export type { MCPToolDetails } from "./tool-bridge.js";
@@ -17,24 +17,49 @@ export interface MCPToolsLoadResult {
17
17
  errors: Array<{ path: string; error: string }>;
18
18
  /** Connected server names */
19
19
  connectedServers: string[];
20
+ /** Extracted Exa API keys from filtered MCP servers */
21
+ exaApiKeys: string[];
22
+ }
23
+
24
+ /** Options for loading MCP tools */
25
+ export interface MCPToolsLoadOptions {
26
+ /** Additional environment variables for expansion */
27
+ extraEnv?: Record<string, string>;
28
+ /** Called when starting to connect to servers */
29
+ onConnecting?: (serverNames: string[]) => void;
30
+ /** Whether to load project-level config (default: true) */
31
+ enableProjectConfig?: boolean;
32
+ /** Whether to filter out Exa MCP servers (default: true) */
33
+ filterExa?: boolean;
20
34
  }
21
35
 
22
36
  /**
23
37
  * Discover and load MCP tools from .mcp.json files.
24
38
  *
25
39
  * @param cwd Working directory (project root)
26
- * @param extraEnv Additional environment variables for expansion
40
+ * @param options Load options including extraEnv and progress callbacks
27
41
  * @returns MCP tools in LoadedCustomTool format for integration
28
42
  */
29
43
  export async function discoverAndLoadMCPTools(
30
44
  cwd: string,
31
- extraEnv?: Record<string, string>,
45
+ options?: MCPToolsLoadOptions | Record<string, string>,
32
46
  ): Promise<MCPToolsLoadResult> {
47
+ // Support old signature: discoverAndLoadMCPTools(cwd, extraEnv)
48
+ const opts: MCPToolsLoadOptions =
49
+ options && ("extraEnv" in options || "onConnecting" in options || "enableProjectConfig" in options)
50
+ ? (options as MCPToolsLoadOptions)
51
+ : { extraEnv: options as Record<string, string> | undefined };
52
+
33
53
  const manager = new MCPManager(cwd);
34
54
 
35
55
  let result: MCPLoadResult;
36
56
  try {
37
- result = await manager.discoverAndConnect(extraEnv);
57
+ result = await manager.discoverAndConnect({
58
+ extraEnv: opts.extraEnv,
59
+ onConnecting: opts.onConnecting,
60
+ enableProjectConfig: opts.enableProjectConfig,
61
+ filterExa: opts.filterExa,
62
+ });
38
63
  } catch (error) {
39
64
  // If discovery fails entirely, return empty result
40
65
  const message = error instanceof Error ? error.message : String(error);
@@ -43,6 +68,7 @@ export async function discoverAndLoadMCPTools(
43
68
  tools: [],
44
69
  errors: [{ path: ".mcp.json", error: message }],
45
70
  connectedServers: [],
71
+ exaApiKeys: [],
46
72
  };
47
73
  }
48
74
 
@@ -64,5 +90,6 @@ export async function discoverAndLoadMCPTools(
64
90
  tools: loadedTools,
65
91
  errors,
66
92
  connectedServers: result.connectedServers,
93
+ exaApiKeys: result.exaApiKeys,
67
94
  };
68
95
  }
@@ -8,7 +8,7 @@
8
8
  import type { TSchema } from "@sinclair/typebox";
9
9
  import type { CustomTool } from "../custom-tools/types.js";
10
10
  import { connectToServer, disconnectServer, listTools } from "./client.js";
11
- import { loadAllMCPConfigs, validateServerConfig } from "./config.js";
11
+ import { type LoadMCPConfigsOptions, loadAllMCPConfigs, validateServerConfig } from "./config.js";
12
12
  import type { MCPToolDetails } from "./tool-bridge.js";
13
13
  import { createMCPTools } from "./tool-bridge.js";
14
14
  import type { MCPServerConfig, MCPServerConnection } from "./types.js";
@@ -21,6 +21,14 @@ export interface MCPLoadResult {
21
21
  errors: Map<string, string>;
22
22
  /** Connected server names */
23
23
  connectedServers: string[];
24
+ /** Extracted Exa API keys from filtered MCP servers */
25
+ exaApiKeys: string[];
26
+ }
27
+
28
+ /** Options for discovering and connecting to MCP servers */
29
+ export interface MCPDiscoverOptions extends LoadMCPConfigsOptions {
30
+ /** Called when starting to connect to servers */
31
+ onConnecting?: (serverNames: string[]) => void;
24
32
  }
25
33
 
26
34
  /**
@@ -38,19 +46,49 @@ export class MCPManager {
38
46
  * Discover and connect to all MCP servers from .mcp.json files.
39
47
  * Returns tools and any connection errors.
40
48
  */
41
- async discoverAndConnect(extraEnv?: Record<string, string>): Promise<MCPLoadResult> {
42
- const configs = loadAllMCPConfigs(this.cwd, extraEnv);
43
- return this.connectServers(configs);
49
+ async discoverAndConnect(
50
+ extraEnvOrOptions?: Record<string, string> | MCPDiscoverOptions,
51
+ onConnecting?: (serverNames: string[]) => void,
52
+ ): Promise<MCPLoadResult> {
53
+ // Support old signature: discoverAndConnect(extraEnv, onConnecting)
54
+ const opts: MCPDiscoverOptions =
55
+ extraEnvOrOptions &&
56
+ ("extraEnv" in extraEnvOrOptions ||
57
+ "enableProjectConfig" in extraEnvOrOptions ||
58
+ "filterExa" in extraEnvOrOptions ||
59
+ "onConnecting" in extraEnvOrOptions)
60
+ ? (extraEnvOrOptions as MCPDiscoverOptions)
61
+ : { extraEnv: extraEnvOrOptions as Record<string, string> | undefined, onConnecting };
62
+
63
+ const { configs, exaApiKeys } = loadAllMCPConfigs(this.cwd, {
64
+ extraEnv: opts.extraEnv,
65
+ enableProjectConfig: opts.enableProjectConfig,
66
+ filterExa: opts.filterExa,
67
+ });
68
+ const result = await this.connectServers(configs, opts.onConnecting);
69
+ result.exaApiKeys = exaApiKeys;
70
+ return result;
44
71
  }
45
72
 
46
73
  /**
47
74
  * Connect to specific MCP servers.
75
+ * Connections are made in parallel for faster startup.
48
76
  */
49
- async connectServers(configs: Record<string, MCPServerConfig>): Promise<MCPLoadResult> {
77
+ async connectServers(
78
+ configs: Record<string, MCPServerConfig>,
79
+ onConnecting?: (serverNames: string[]) => void,
80
+ ): Promise<MCPLoadResult> {
50
81
  const errors = new Map<string, string>();
51
82
  const connectedServers: string[] = [];
52
83
  const allTools: CustomTool<TSchema, MCPToolDetails>[] = [];
53
84
 
85
+ // Prepare connection tasks
86
+ const connectionTasks: Array<{
87
+ name: string;
88
+ config: MCPServerConfig;
89
+ validationErrors: string[];
90
+ }> = [];
91
+
54
92
  for (const [name, config] of Object.entries(configs)) {
55
93
  // Skip if already connected
56
94
  if (this.connections.has(name)) {
@@ -65,17 +103,37 @@ export class MCPManager {
65
103
  continue;
66
104
  }
67
105
 
68
- try {
106
+ connectionTasks.push({ name, config, validationErrors });
107
+ }
108
+
109
+ // Notify about servers we're connecting to
110
+ if (connectionTasks.length > 0 && onConnecting) {
111
+ onConnecting(connectionTasks.map((t) => t.name));
112
+ }
113
+
114
+ // Connect to all servers in parallel
115
+ const results = await Promise.allSettled(
116
+ connectionTasks.map(async ({ name, config }) => {
69
117
  const connection = await connectToServer(name, config);
118
+ const serverTools = await listTools(connection);
119
+ return { name, connection, serverTools };
120
+ }),
121
+ );
122
+
123
+ // Process results
124
+ for (let i = 0; i < results.length; i++) {
125
+ const result = results[i];
126
+ const { name } = connectionTasks[i];
127
+
128
+ if (result.status === "fulfilled") {
129
+ const { connection, serverTools } = result.value;
70
130
  this.connections.set(name, connection);
71
131
  connectedServers.push(name);
72
132
 
73
- // Load tools from this server
74
- const serverTools = await listTools(connection);
75
133
  const customTools = createMCPTools(connection, serverTools);
76
134
  allTools.push(...customTools);
77
- } catch (error) {
78
- const message = error instanceof Error ? error.message : String(error);
135
+ } else {
136
+ const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
79
137
  errors.set(name, message);
80
138
  }
81
139
  }
@@ -87,6 +145,7 @@ export class MCPManager {
87
145
  tools: allTools,
88
146
  errors,
89
147
  connectedServers,
148
+ exaApiKeys: [], // Will be populated by discoverAndConnect
90
149
  };
91
150
  }
92
151