pi-mcp-adapter 2.0.0 → 2.1.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
@@ -5,7 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [2.1.0] - 2026-02-02
9
+
10
+ ### Added
11
+ - **Direct tool registration** - Promote specific MCP tools to first-class Pi tools via `directTools` config (per-server or global). Direct tools appear in the agent's tool list alongside builtins, so the LLM uses them without needing to search through the proxy first. Registers from cached metadata at startup — no server connections needed.
12
+ - **`/mcp` interactive panel** - New TUI overlay replacing the text-based status dump. Shows server connection status, tool lists with direct/proxy toggles, token cost estimates, inline reconnect, and auth notices. Changes written to config on save.
13
+ - **Auto-enriched proxy description** - The `mcp` proxy tool description now includes server names and tool counts from the metadata cache, so the LLM knows what's available without a search call (~30 extra tokens).
14
+ - **`MCP_DIRECT_TOOLS` env var** - Subagent processes receive their direct tool configuration via environment variable, keeping subagents lean by default.
15
+ - **First-run bootstrap** - Servers with `directTools` configured but no cache entry are connected during `session_start` to populate the cache. Direct tools become available after restart.
16
+ - Config provenance tracking for correct write-back to user/project/import sources
17
+ - Builtin name collision guard (skips direct tools that would shadow `read`, `write`, etc.)
18
+ - Cross-server name deduplication for `prefix: "none"` and `prefix: "short"` modes
19
+
20
+ ## [2.0.1] - 2026-02-01
21
+
22
+ ### Fixed
23
+ - Adapt execute signature to pi v0.51.0: add signal, onUpdate, ctx parameters
9
24
 
10
25
  ## [2.0.0] - 2026-01-29
11
26
 
package/README.md CHANGED
@@ -89,6 +89,7 @@ Two calls instead of 26 tools cluttering the context.
89
89
  | `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` |
90
90
  | `idleTimeout` | Minutes before idle disconnect (overrides global) |
91
91
  | `exposeResources` | Expose MCP resources as tools (default: true) |
92
+ | `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
92
93
  | `debug` | Show server stderr (default: false) |
93
94
 
94
95
  ### Lifecycle Modes
@@ -113,9 +114,68 @@ Two calls instead of 26 tools cluttering the context.
113
114
  |---------|-------------|
114
115
  | `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` |
115
116
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
117
+ | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
116
118
 
117
119
  Per-server `idleTimeout` overrides the global setting.
118
120
 
121
+ ### Direct Tools
122
+
123
+ By default, all MCP tools are accessed through the single `mcp` proxy tool. This keeps context small but means the LLM has to discover tools via search. If you want specific tools to show up directly in the agent's tool list — alongside `read`, `bash`, `edit`, etc. — add `directTools` to your config.
124
+
125
+ Per-server:
126
+
127
+ ```json
128
+ {
129
+ "mcpServers": {
130
+ "chrome-devtools": {
131
+ "command": "npx",
132
+ "args": ["-y", "chrome-devtools-mcp@latest"],
133
+ "directTools": true
134
+ },
135
+ "github": {
136
+ "command": "npx",
137
+ "args": ["-y", "@modelcontextprotocol/server-github"],
138
+ "directTools": ["search_repositories", "get_file_contents"]
139
+ },
140
+ "huge-server": {
141
+ "command": "npx",
142
+ "args": ["-y", "mega-mcp@latest"]
143
+ }
144
+ }
145
+ }
146
+ ```
147
+
148
+ | Value | Behavior |
149
+ |-------|----------|
150
+ | `true` | Register all tools from this server as individual Pi tools |
151
+ | `["tool_a", "tool_b"]` | Register only these tools (use original MCP names) |
152
+ | Omitted or `false` | Proxy only (default) |
153
+
154
+ To set a global default for all servers:
155
+
156
+ ```json
157
+ {
158
+ "settings": {
159
+ "directTools": true
160
+ },
161
+ "mcpServers": {
162
+ "huge-server": {
163
+ "directTools": false
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ Per-server `directTools` overrides the global setting. The example above registers direct tools for every server except `huge-server`.
170
+
171
+ Each direct tool costs ~150-300 tokens in the system prompt (name + description + schema). Good for targeted sets of 5-20 tools. For servers with 75+ tools, stick with the proxy or pick specific tools with a `string[]`.
172
+
173
+ Direct tools register from the metadata cache (`~/.pi/agent/mcp-cache.json`), so no server connections are needed at startup. On the first session after adding `directTools` to a new server, the cache won't exist yet — tools fall back to proxy-only and the cache populates in the background. Restart Pi and they'll be available. To force it: `/mcp reconnect <server>` then restart.
174
+
175
+ **Interactive configuration:** Run `/mcp` to open an interactive panel showing all servers with connection status, tools, and direct/proxy toggles. You can reconnect servers, initiate OAuth, and toggle tools between direct and proxy — all from one overlay. Changes are written to your config file; restart Pi to apply.
176
+
177
+ **Subagent integration:** If you use the subagent extension, agents can request direct MCP tools in their frontmatter with `mcp:server-name` syntax. See the subagent README for details.
178
+
119
179
  ### Import Existing Configs
120
180
 
121
181
  Already have MCP set up elsewhere? Import it:
@@ -152,7 +212,7 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li
152
212
 
153
213
  | Command | What it does |
154
214
  |---------|--------------|
155
- | `/mcp` | Server status |
215
+ | `/mcp` | Interactive panel (server status, tool toggles, reconnect) |
156
216
  | `/mcp tools` | List all tools |
157
217
  | `/mcp reconnect` | Reconnect all servers |
158
218
  | `/mcp reconnect <server>` | Connect or reconnect a single server |
@@ -169,6 +229,7 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full picture. Short version:
169
229
  - npx-based servers resolve to direct binary paths, skipping the ~143 MB npm parent process
170
230
  - MCP server validates arguments, not the adapter
171
231
  - Keep-alive servers get health checks and auto-reconnect
232
+ - Specific tools can be promoted from the proxy to first-class Pi tools via `directTools` config, so the LLM sees them directly instead of having to search
172
233
 
173
234
  ## Limitations
174
235
 
package/config.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // config.ts - Config loading with import support
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join, resolve } from "node:path";
5
- import type { McpConfig, ServerEntry, McpSettings, ImportKind } from "./types.js";
4
+ import { join, resolve, dirname } from "node:path";
5
+ import type { McpConfig, ServerEntry, McpSettings, ImportKind, ServerProvenance } from "./types.js";
6
6
 
7
7
  const DEFAULT_CONFIG_PATH = join(homedir(), ".pi", "agent", "mcp.json");
8
8
  const PROJECT_CONFIG_NAME = ".pi/mcp.json";
@@ -127,3 +127,100 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
127
127
 
128
128
  return servers as Record<string, ServerEntry>;
129
129
  }
130
+
131
+ export function getServerProvenance(overridePath?: string): Map<string, ServerProvenance> {
132
+ const provenance = new Map<string, ServerProvenance>();
133
+ const userPath = overridePath ? resolve(overridePath) : DEFAULT_CONFIG_PATH;
134
+
135
+ let userConfig: McpConfig = { mcpServers: {} };
136
+ if (existsSync(userPath)) {
137
+ try {
138
+ userConfig = validateConfig(JSON.parse(readFileSync(userPath, "utf-8")));
139
+ } catch {}
140
+ }
141
+ for (const name of Object.keys(userConfig.mcpServers)) {
142
+ provenance.set(name, { path: userPath, kind: "user" });
143
+ }
144
+
145
+ if (userConfig.imports?.length) {
146
+ for (const importKind of userConfig.imports) {
147
+ const importPath = IMPORT_PATHS[importKind];
148
+ if (!importPath) continue;
149
+ const fullPath = importPath.startsWith(".")
150
+ ? resolve(process.cwd(), importPath)
151
+ : importPath;
152
+ if (!existsSync(fullPath)) continue;
153
+ try {
154
+ const imported = JSON.parse(readFileSync(fullPath, "utf-8"));
155
+ const servers = extractServers(imported, importKind);
156
+ for (const name of Object.keys(servers)) {
157
+ if (!provenance.has(name)) {
158
+ provenance.set(name, { path: userPath, kind: "import", importKind });
159
+ }
160
+ }
161
+ } catch {}
162
+ }
163
+ }
164
+
165
+ const projectPath = resolve(process.cwd(), PROJECT_CONFIG_NAME);
166
+ if (existsSync(projectPath) && projectPath !== userPath) {
167
+ try {
168
+ const projectConfig = validateConfig(JSON.parse(readFileSync(projectPath, "utf-8")));
169
+ for (const name of Object.keys(projectConfig.mcpServers)) {
170
+ provenance.set(name, { path: projectPath, kind: "project" });
171
+ }
172
+ } catch {}
173
+ }
174
+
175
+ return provenance;
176
+ }
177
+
178
+ export function writeDirectToolsConfig(
179
+ changes: Map<string, true | string[] | false>,
180
+ provenance: Map<string, ServerProvenance>,
181
+ fullConfig: McpConfig,
182
+ ): void {
183
+ const byPath = new Map<string, { name: string; value: true | string[] | false; prov: ServerProvenance }[]>();
184
+
185
+ for (const [serverName, value] of changes) {
186
+ const prov = provenance.get(serverName);
187
+ if (!prov) continue;
188
+
189
+ const targetPath = prov.path;
190
+
191
+ if (!byPath.has(targetPath)) byPath.set(targetPath, []);
192
+ byPath.get(targetPath)!.push({ name: serverName, value, prov });
193
+ }
194
+
195
+ for (const [filePath, entries] of byPath) {
196
+ let raw: Record<string, unknown> = {};
197
+ if (existsSync(filePath)) {
198
+ try {
199
+ raw = JSON.parse(readFileSync(filePath, "utf-8"));
200
+ } catch {}
201
+ }
202
+ if (!raw || typeof raw !== "object") raw = {};
203
+
204
+ const servers = (raw.mcpServers ?? raw["mcp-servers"] ?? {}) as Record<string, ServerEntry>;
205
+ if (typeof servers !== "object" || Array.isArray(servers)) continue;
206
+
207
+ for (const { name, value, prov } of entries) {
208
+ if (prov.kind === "import") {
209
+ const fullDef = fullConfig.mcpServers[name];
210
+ if (fullDef) {
211
+ servers[name] = { ...fullDef, directTools: value };
212
+ }
213
+ } else if (servers[name]) {
214
+ servers[name] = { ...servers[name], directTools: value };
215
+ }
216
+ }
217
+
218
+ const key = raw["mcp-servers"] && !raw.mcpServers ? "mcp-servers" : "mcpServers";
219
+ raw[key] = servers;
220
+
221
+ mkdirSync(dirname(filePath), { recursive: true });
222
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
223
+ writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
224
+ renameSync(tmpPath, filePath);
225
+ }
226
+ }
package/index.ts CHANGED
@@ -2,17 +2,19 @@
2
2
  import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import { existsSync } from "node:fs";
5
- import { loadMcpConfig } from "./config.js";
6
- import { formatToolName, getServerPrefix, type McpConfig, type McpContent, type ToolMetadata, type McpTool, type McpResource, type ServerEntry } from "./types.js";
5
+ import { loadMcpConfig, getServerProvenance, writeDirectToolsConfig } from "./config.js";
6
+ import { formatToolName, getServerPrefix, type McpConfig, type McpContent, type ToolMetadata, type McpTool, type McpResource, type ServerEntry, type DirectToolSpec, type McpPanelCallbacks, type McpPanelResult } from "./types.js";
7
7
  import { McpServerManager } from "./server-manager.js";
8
8
  import { McpLifecycleManager } from "./lifecycle.js";
9
9
  import { transformMcpContent } from "./tool-registrar.js";
10
10
  import { resourceNameToToolName } from "./resource-tools.js";
11
+ import { getStoredTokens } from "./oauth-handler.js";
11
12
  import {
12
13
  computeServerHash,
13
14
  getMetadataCachePath,
14
15
  isServerCacheValid,
15
16
  loadMetadataCache,
17
+ type MetadataCache,
16
18
  reconstructToolMetadata,
17
19
  saveMetadataCache,
18
20
  serializeResources,
@@ -66,10 +68,284 @@ async function parallelLimit<T, R>(
66
68
  return results;
67
69
  }
68
70
 
71
+ const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
72
+
73
+ function getConfigPathFromArgv(): string | undefined {
74
+ const idx = process.argv.indexOf("--mcp-config");
75
+ if (idx >= 0 && idx + 1 < process.argv.length) {
76
+ return process.argv[idx + 1];
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function resolveDirectTools(
82
+ config: McpConfig,
83
+ cache: MetadataCache | null,
84
+ prefix: "server" | "none" | "short",
85
+ envOverride?: string[],
86
+ ): DirectToolSpec[] {
87
+ const specs: DirectToolSpec[] = [];
88
+ if (!cache) return specs;
89
+
90
+ const seenNames = new Set<string>();
91
+
92
+ const envServers = new Set<string>();
93
+ const envTools = new Map<string, Set<string>>();
94
+ if (envOverride) {
95
+ for (let item of envOverride) {
96
+ item = item.replace(/\/+$/, "");
97
+ if (item.includes("/")) {
98
+ const [server, tool] = item.split("/", 2);
99
+ if (server && tool) {
100
+ if (!envTools.has(server)) envTools.set(server, new Set());
101
+ envTools.get(server)!.add(tool);
102
+ } else if (server) {
103
+ envServers.add(server);
104
+ }
105
+ } else if (item) {
106
+ envServers.add(item);
107
+ }
108
+ }
109
+ }
110
+
111
+ const globalDirect = config.settings?.directTools;
112
+
113
+ for (const [serverName, definition] of Object.entries(config.mcpServers)) {
114
+ const serverCache = cache.servers[serverName];
115
+ if (!serverCache || !isServerCacheValid(serverCache, definition)) continue;
116
+
117
+ let toolFilter: true | string[] | false = false;
118
+
119
+ if (envOverride) {
120
+ if (envServers.has(serverName)) {
121
+ toolFilter = true;
122
+ } else if (envTools.has(serverName)) {
123
+ toolFilter = [...envTools.get(serverName)!];
124
+ }
125
+ } else {
126
+ if (definition.directTools !== undefined) {
127
+ toolFilter = definition.directTools;
128
+ } else if (globalDirect) {
129
+ toolFilter = globalDirect;
130
+ }
131
+ }
132
+
133
+ if (!toolFilter) continue;
134
+
135
+ for (const tool of serverCache.tools ?? []) {
136
+ if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
137
+ const prefixedName = formatToolName(tool.name, serverName, prefix);
138
+ if (BUILTIN_NAMES.has(prefixedName)) {
139
+ console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
140
+ continue;
141
+ }
142
+ if (seenNames.has(prefixedName)) {
143
+ console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`);
144
+ continue;
145
+ }
146
+ seenNames.add(prefixedName);
147
+ specs.push({
148
+ serverName,
149
+ originalName: tool.name,
150
+ prefixedName,
151
+ description: tool.description ?? "",
152
+ inputSchema: tool.inputSchema,
153
+ });
154
+ }
155
+
156
+ if (definition.exposeResources !== false) {
157
+ for (const resource of serverCache.resources ?? []) {
158
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
159
+ if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
160
+ const prefixedName = formatToolName(baseName, serverName, prefix);
161
+ if (BUILTIN_NAMES.has(prefixedName)) {
162
+ console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
163
+ continue;
164
+ }
165
+ if (seenNames.has(prefixedName)) {
166
+ console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`);
167
+ continue;
168
+ }
169
+ seenNames.add(prefixedName);
170
+ specs.push({
171
+ serverName,
172
+ originalName: baseName,
173
+ prefixedName,
174
+ description: resource.description ?? `Read resource: ${resource.uri}`,
175
+ resourceUri: resource.uri,
176
+ });
177
+ }
178
+ }
179
+ }
180
+
181
+ return specs;
182
+ }
183
+
184
+ function buildProxyDescription(
185
+ config: McpConfig,
186
+ cache: MetadataCache | null,
187
+ directSpecs: DirectToolSpec[],
188
+ ): string {
189
+ let desc = `MCP gateway - connect to MCP servers and call their tools.\n`;
190
+
191
+ const directByServer = new Map<string, number>();
192
+ for (const spec of directSpecs) {
193
+ directByServer.set(spec.serverName, (directByServer.get(spec.serverName) ?? 0) + 1);
194
+ }
195
+ if (directByServer.size > 0) {
196
+ const parts = [...directByServer.entries()].map(
197
+ ([server, count]) => `${server} (${count})`,
198
+ );
199
+ desc += `\nDirect tools available (call as normal tools): ${parts.join(", ")}\n`;
200
+ }
201
+
202
+ const serverSummaries: string[] = [];
203
+ for (const serverName of Object.keys(config.mcpServers)) {
204
+ const entry = cache?.servers?.[serverName];
205
+ const definition = config.mcpServers[serverName];
206
+ const toolCount = entry?.tools?.length ?? 0;
207
+ const resourceCount = definition?.exposeResources !== false ? (entry?.resources?.length ?? 0) : 0;
208
+ const totalItems = toolCount + resourceCount;
209
+ if (totalItems === 0) continue;
210
+ const directCount = directByServer.get(serverName) ?? 0;
211
+ const proxyCount = totalItems - directCount;
212
+ if (proxyCount > 0) {
213
+ serverSummaries.push(`${serverName} (${proxyCount} tools)`);
214
+ }
215
+ }
216
+
217
+ if (serverSummaries.length > 0) {
218
+ desc += `\nServers: ${serverSummaries.join(", ")}\n`;
219
+ }
220
+
221
+ desc += `\nUsage:\n`;
222
+ desc += ` mcp({ }) → Show server status\n`;
223
+ desc += ` mcp({ server: "name" }) → List tools from server\n`;
224
+ desc += ` mcp({ search: "query" }) → Search for tools (MCP + pi, space-separated words OR'd)\n`;
225
+ desc += ` mcp({ describe: "tool_name" }) → Show tool details and parameters\n`;
226
+ desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
227
+ desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
228
+ desc += `\nMode: tool (call) > connect > describe > search > server (list) > nothing (status)`;
229
+
230
+ return desc;
231
+ }
232
+
69
233
  export default function mcpAdapter(pi: ExtensionAPI) {
70
234
  let state: McpExtensionState | null = null;
71
235
  let initPromise: Promise<McpExtensionState> | null = null;
72
-
236
+
237
+ const earlyConfigPath = getConfigPathFromArgv();
238
+ const earlyConfig = loadMcpConfig(earlyConfigPath);
239
+ const earlyCache = loadMetadataCache();
240
+ const prefix = earlyConfig.settings?.toolPrefix ?? "server";
241
+
242
+ const envRaw = process.env.MCP_DIRECT_TOOLS;
243
+ const directSpecs = envRaw === "__none__"
244
+ ? []
245
+ : resolveDirectTools(
246
+ earlyConfig,
247
+ earlyCache,
248
+ prefix,
249
+ envRaw?.split(",").map(s => s.trim()).filter(Boolean),
250
+ );
251
+
252
+ for (const spec of directSpecs) {
253
+ pi.registerTool({
254
+ name: spec.prefixedName,
255
+ label: `MCP: ${spec.originalName}`,
256
+ description: spec.description || "(no description)",
257
+ parameters: Type.Unsafe<Record<string, unknown>>(spec.inputSchema || { type: "object", properties: {} }),
258
+ async execute(_toolCallId, params) {
259
+ if (!state && initPromise) {
260
+ try { state = await initPromise; } catch {
261
+ return {
262
+ content: [{ type: "text" as const, text: "MCP initialization failed" }],
263
+ details: { error: "init_failed" },
264
+ };
265
+ }
266
+ }
267
+ if (!state) {
268
+ return {
269
+ content: [{ type: "text" as const, text: "MCP not initialized" }],
270
+ details: { error: "not_initialized" },
271
+ };
272
+ }
273
+
274
+ const s = state;
275
+ const connected = await lazyConnect(s, spec.serverName);
276
+ if (!connected) {
277
+ const failedAgo = getFailureAgeSeconds(s, spec.serverName);
278
+ return {
279
+ content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
280
+ details: { error: "server_unavailable", server: spec.serverName },
281
+ };
282
+ }
283
+
284
+ const connection = s.manager.getConnection(spec.serverName);
285
+ if (!connection || connection.status !== "connected") {
286
+ return {
287
+ content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not connected` }],
288
+ details: { error: "not_connected", server: spec.serverName },
289
+ };
290
+ }
291
+
292
+ try {
293
+ s.manager.touch(spec.serverName);
294
+ s.manager.incrementInFlight(spec.serverName);
295
+
296
+ if (spec.resourceUri) {
297
+ const result = await connection.client.readResource({ uri: spec.resourceUri });
298
+ const content = (result.contents ?? []).map(c => ({
299
+ type: "text" as const,
300
+ text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
301
+ }));
302
+ return {
303
+ content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
304
+ details: { server: spec.serverName, resourceUri: spec.resourceUri },
305
+ };
306
+ }
307
+
308
+ const result = await connection.client.callTool({
309
+ name: spec.originalName,
310
+ arguments: params ?? {},
311
+ });
312
+
313
+ const mcpContent = (result.content ?? []) as McpContent[];
314
+ const content = transformMcpContent(mcpContent);
315
+
316
+ if (result.isError) {
317
+ let errorText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "Tool execution failed";
318
+ if (spec.inputSchema) {
319
+ errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
320
+ }
321
+ return {
322
+ content: [{ type: "text" as const, text: `Error: ${errorText}` }],
323
+ details: { error: "tool_error", server: spec.serverName },
324
+ };
325
+ }
326
+
327
+ return {
328
+ content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
329
+ details: { server: spec.serverName, tool: spec.originalName },
330
+ };
331
+ } catch (error) {
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ let errorText = `Failed to call tool: ${message}`;
334
+ if (spec.inputSchema) {
335
+ errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
336
+ }
337
+ return {
338
+ content: [{ type: "text" as const, text: errorText }],
339
+ details: { error: "call_failed", server: spec.serverName },
340
+ };
341
+ } finally {
342
+ s.manager.decrementInFlight(spec.serverName);
343
+ s.manager.touch(spec.serverName);
344
+ }
345
+ },
346
+ });
347
+ }
348
+
73
349
  // Capture pi tool accessor (closure) for unified search
74
350
  const getPiTools = (): ToolInfo[] => pi.getAllTools();
75
351
 
@@ -140,7 +416,11 @@ export default function mcpAdapter(pi: ExtensionAPI) {
140
416
  case "status":
141
417
  case "":
142
418
  default:
143
- await showStatus(state, ctx);
419
+ if (ctx.hasUI) {
420
+ await openMcpPanel(state, pi, ctx, earlyConfigPath);
421
+ } else {
422
+ await showStatus(state, ctx);
423
+ }
144
424
  break;
145
425
  }
146
426
  },
@@ -178,17 +458,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
178
458
  pi.registerTool({
179
459
  name: "mcp",
180
460
  label: "MCP",
181
- description: `MCP gateway - connect to MCP servers and call their tools.
182
-
183
- Usage:
184
- mcp({ }) → Show server status
185
- mcp({ server: "name" }) → List tools from server
186
- mcp({ search: "query" }) → Search for tools (MCP + pi, space-separated words OR'd)
187
- mcp({ describe: "tool_name" }) → Show tool details and parameters
188
- mcp({ connect: "server-name" }) → Connect to a server and refresh metadata
189
- mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)
190
-
191
- Mode: tool (call) > connect > describe > search > server (list) > nothing (status)`,
461
+ description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
192
462
  parameters: Type.Object({
193
463
  // Call mode
194
464
  tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
@@ -212,7 +482,7 @@ Mode: tool (call) > connect > describe > search > server (list) > nothing (statu
212
482
  regex?: boolean;
213
483
  includeSchemas?: boolean;
214
484
  server?: string;
215
- }) {
485
+ }, _signal, _onUpdate, _ctx) {
216
486
  // Parse args from JSON string if provided
217
487
  let parsedArgs: Record<string, unknown> | undefined;
218
488
  if (params.args) {
@@ -988,6 +1258,45 @@ async function initializeMcp(
988
1258
  ctx.ui.notify(msg, "info");
989
1259
  }
990
1260
 
1261
+ const envDirect = process.env.MCP_DIRECT_TOOLS;
1262
+ if (envDirect !== "__none__") {
1263
+ const missingCacheServers: string[] = [];
1264
+ const currentCache = loadMetadataCache();
1265
+ for (const [name, definition] of serverEntries) {
1266
+ const hasDirect = definition.directTools !== undefined
1267
+ ? !!definition.directTools
1268
+ : !!config.settings?.directTools;
1269
+ if (!hasDirect) continue;
1270
+ const entry = currentCache?.servers?.[name];
1271
+ if (!entry || !isServerCacheValid(entry, definition)) {
1272
+ missingCacheServers.push(name);
1273
+ }
1274
+ }
1275
+
1276
+ if (missingCacheServers.length > 0) {
1277
+ const bootstrapResults = await parallelLimit(
1278
+ missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)),
1279
+ 10,
1280
+ async (name) => {
1281
+ const definition = config.mcpServers[name];
1282
+ try {
1283
+ const connection = await manager.connect(name, definition);
1284
+ const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
1285
+ toolMetadata.set(name, metadata);
1286
+ updateMetadataCache(state, name);
1287
+ return { name, ok: true };
1288
+ } catch {
1289
+ return { name, ok: false };
1290
+ }
1291
+ },
1292
+ );
1293
+ const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
1294
+ if (bootstrapped.length > 0 && ctx.hasUI) {
1295
+ ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
1296
+ }
1297
+ }
1298
+ }
1299
+
991
1300
  lifecycle.setReconnectCallback((serverName) => {
992
1301
  updateServerMetadata(state, serverName);
993
1302
  updateMetadataCache(state, serverName);
@@ -1330,6 +1639,55 @@ async function authenticateServer(
1330
1639
  );
1331
1640
  }
1332
1641
 
1642
+ async function openMcpPanel(
1643
+ state: McpExtensionState,
1644
+ pi: ExtensionAPI,
1645
+ ctx: ExtensionContext,
1646
+ configOverridePath?: string,
1647
+ ): Promise<void> {
1648
+ const config = state.config;
1649
+ const cache = loadMetadataCache();
1650
+ const provenanceMap = getServerProvenance(pi.getFlag("mcp-config") as string | undefined ?? configOverridePath);
1651
+
1652
+ const callbacks: McpPanelCallbacks = {
1653
+ reconnect: async (serverName: string) => {
1654
+ return lazyConnect(state, serverName);
1655
+ },
1656
+ getConnectionStatus: (serverName: string) => {
1657
+ const definition = config.mcpServers[serverName];
1658
+ if (definition?.auth === "oauth" && getStoredTokens(serverName) === undefined) {
1659
+ return "needs-auth";
1660
+ }
1661
+ const connection = state.manager.getConnection(serverName);
1662
+ if (connection?.status === "connected") return "connected";
1663
+ if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
1664
+ return "idle";
1665
+ },
1666
+ refreshCacheAfterReconnect: (serverName: string) => {
1667
+ const freshCache = loadMetadataCache();
1668
+ return freshCache?.servers?.[serverName] ?? null;
1669
+ },
1670
+ };
1671
+
1672
+ const { createMcpPanel } = await import("./mcp-panel.js");
1673
+
1674
+ return new Promise<void>((resolve) => {
1675
+ ctx.ui.custom(
1676
+ (tui, _theme, _keybindings, done) => {
1677
+ return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
1678
+ if (!result.cancelled && result.changes.size > 0) {
1679
+ writeDirectToolsConfig(result.changes, provenanceMap, config);
1680
+ ctx.ui.notify("Direct tools updated. Restart pi to apply.", "info");
1681
+ }
1682
+ done();
1683
+ resolve();
1684
+ });
1685
+ },
1686
+ { overlay: true, overlayOptions: { anchor: "center", width: 82 } },
1687
+ );
1688
+ });
1689
+ }
1690
+
1333
1691
  /**
1334
1692
  * Truncate text at word boundary, aiming for target length.
1335
1693
  */