pi-mcp-adapter 1.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/ARCHITECTURE.md +572 -0
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/config.ts +132 -0
- package/index.ts +907 -0
- package/lifecycle.ts +59 -0
- package/oauth-handler.ts +57 -0
- package/package.json +51 -0
- package/resource-tools.ts +45 -0
- package/server-manager.ts +242 -0
- package/tool-registrar.ts +77 -0
- package/types.ts +112 -0
package/index.ts
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
// index.ts - Full extension entry point with commands
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { loadMcpConfig } from "./config.js";
|
|
5
|
+
import { formatToolName, type McpConfig, type McpContent } from "./types.js";
|
|
6
|
+
import { McpServerManager } from "./server-manager.js";
|
|
7
|
+
import { McpLifecycleManager } from "./lifecycle.js";
|
|
8
|
+
import { collectToolNames, transformMcpContent } from "./tool-registrar.js";
|
|
9
|
+
import { collectResourceToolNames, resourceNameToToolName } from "./resource-tools.js";
|
|
10
|
+
|
|
11
|
+
interface ToolMetadata {
|
|
12
|
+
name: string; // Prefixed tool name (e.g., "xcodebuild_list_sims")
|
|
13
|
+
originalName: string; // Original MCP tool name (e.g., "list_sims")
|
|
14
|
+
description: string;
|
|
15
|
+
resourceUri?: string; // For resource tools: the URI to read
|
|
16
|
+
inputSchema?: unknown; // JSON Schema for parameters (stored for describe/errors)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface McpExtensionState {
|
|
20
|
+
manager: McpServerManager;
|
|
21
|
+
lifecycle: McpLifecycleManager;
|
|
22
|
+
registeredTools: Map<string, string[]>;
|
|
23
|
+
toolMetadata: Map<string, ToolMetadata[]>; // server -> tool metadata for searching
|
|
24
|
+
config: McpConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function mcpAdapter(pi: ExtensionAPI) {
|
|
28
|
+
let state: McpExtensionState | null = null;
|
|
29
|
+
let initPromise: Promise<McpExtensionState> | null = null;
|
|
30
|
+
|
|
31
|
+
pi.registerFlag("mcp-config", {
|
|
32
|
+
description: "Path to MCP config file",
|
|
33
|
+
type: "string",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
37
|
+
initPromise = initializeMcp(pi, ctx);
|
|
38
|
+
state = await initPromise;
|
|
39
|
+
initPromise = null;
|
|
40
|
+
|
|
41
|
+
// Set up callback for auto-reconnect to update metadata
|
|
42
|
+
state.lifecycle.setReconnectCallback((serverName) => {
|
|
43
|
+
if (state) {
|
|
44
|
+
updateServerMetadata(state, serverName);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (ctx.hasUI) {
|
|
49
|
+
const totalTools = [...state.registeredTools.values()].flat().length;
|
|
50
|
+
if (totalTools > 0) {
|
|
51
|
+
ctx.ui.setStatus("mcp", ctx.ui.theme.fg("accent", `MCP: ${totalTools} tools`));
|
|
52
|
+
} else {
|
|
53
|
+
ctx.ui.setStatus("mcp", "");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
pi.on("session_shutdown", async () => {
|
|
59
|
+
if (initPromise) {
|
|
60
|
+
try {
|
|
61
|
+
state = await initPromise;
|
|
62
|
+
} catch {
|
|
63
|
+
// Initialization failed, nothing to clean up
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (state) {
|
|
68
|
+
await state.lifecycle.gracefulShutdown();
|
|
69
|
+
state = null;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// /mcp command
|
|
74
|
+
pi.registerCommand("mcp", {
|
|
75
|
+
description: "Show MCP server status",
|
|
76
|
+
handler: async (args, ctx) => {
|
|
77
|
+
if (!state) {
|
|
78
|
+
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const subcommand = args?.trim()?.split(/\s+/)?.[0] ?? "";
|
|
83
|
+
|
|
84
|
+
switch (subcommand) {
|
|
85
|
+
case "reconnect":
|
|
86
|
+
await reconnectServers(state, ctx);
|
|
87
|
+
break;
|
|
88
|
+
case "tools":
|
|
89
|
+
await showTools(state, ctx);
|
|
90
|
+
break;
|
|
91
|
+
case "status":
|
|
92
|
+
case "":
|
|
93
|
+
default:
|
|
94
|
+
await showStatus(state, ctx);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// /mcp-auth command
|
|
101
|
+
pi.registerCommand("mcp-auth", {
|
|
102
|
+
description: "Authenticate with an MCP server (OAuth)",
|
|
103
|
+
handler: async (args, ctx) => {
|
|
104
|
+
const serverName = args?.trim();
|
|
105
|
+
if (!serverName) {
|
|
106
|
+
if (ctx.hasUI) ctx.ui.notify("Usage: /mcp-auth <server-name>", "error");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!state) {
|
|
111
|
+
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await authenticateServer(serverName, state.config, ctx);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Single unified MCP tool - mode determined by parameters
|
|
120
|
+
pi.registerTool({
|
|
121
|
+
name: "mcp",
|
|
122
|
+
label: "MCP",
|
|
123
|
+
description: `MCP gateway - connect to MCP servers and call their tools.
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
mcp({ }) → Show server status
|
|
127
|
+
mcp({ server: "name" }) → List tools from server
|
|
128
|
+
mcp({ search: "query" }) → Search for tools (includes schemas, space-separated words OR'd)
|
|
129
|
+
mcp({ describe: "tool_name" }) → Show tool details and parameters
|
|
130
|
+
mcp({ tool: "name", args: {...} }) → Call a tool
|
|
131
|
+
|
|
132
|
+
Mode: tool (call) > describe > search > server (list) > nothing (status)`,
|
|
133
|
+
parameters: Type.Object({
|
|
134
|
+
// Call mode
|
|
135
|
+
tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
|
|
136
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Arguments for tool call" })),
|
|
137
|
+
// Describe mode
|
|
138
|
+
describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
|
|
139
|
+
// Search mode
|
|
140
|
+
search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
|
|
141
|
+
regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
|
|
142
|
+
includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
|
|
143
|
+
// Filter (works with search or list)
|
|
144
|
+
server: Type.Optional(Type.String({ description: "Filter to specific server" })),
|
|
145
|
+
}),
|
|
146
|
+
async execute(_toolCallId, params: {
|
|
147
|
+
tool?: string;
|
|
148
|
+
args?: Record<string, unknown>;
|
|
149
|
+
describe?: string;
|
|
150
|
+
search?: string;
|
|
151
|
+
regex?: boolean;
|
|
152
|
+
includeSchemas?: boolean;
|
|
153
|
+
server?: string;
|
|
154
|
+
}) {
|
|
155
|
+
if (!state) {
|
|
156
|
+
return {
|
|
157
|
+
content: [{ type: "text", text: "MCP not initialized" }],
|
|
158
|
+
details: { error: "not_initialized" },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Mode resolution: tool > describe > search > server > status
|
|
163
|
+
if (params.tool) {
|
|
164
|
+
return executeCall(state, params.tool, params.args);
|
|
165
|
+
}
|
|
166
|
+
if (params.describe) {
|
|
167
|
+
return executeDescribe(state, params.describe);
|
|
168
|
+
}
|
|
169
|
+
if (params.search) {
|
|
170
|
+
return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas);
|
|
171
|
+
}
|
|
172
|
+
if (params.server) {
|
|
173
|
+
return executeList(state, params.server);
|
|
174
|
+
}
|
|
175
|
+
return executeStatus(state);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Mode implementations ---
|
|
181
|
+
|
|
182
|
+
function executeStatus(state: McpExtensionState) {
|
|
183
|
+
const servers: Array<{ name: string; status: string; toolCount: number }> = [];
|
|
184
|
+
|
|
185
|
+
for (const name of Object.keys(state.config.mcpServers)) {
|
|
186
|
+
const connection = state.manager.getConnection(name);
|
|
187
|
+
const toolNames = state.registeredTools.get(name) ?? [];
|
|
188
|
+
servers.push({
|
|
189
|
+
name,
|
|
190
|
+
status: connection?.status ?? "not connected",
|
|
191
|
+
toolCount: toolNames.length,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
|
|
196
|
+
const connectedCount = servers.filter(s => s.status === "connected").length;
|
|
197
|
+
|
|
198
|
+
let text = `MCP: ${connectedCount}/${servers.length} servers, ${totalTools} tools\n\n`;
|
|
199
|
+
for (const server of servers) {
|
|
200
|
+
const icon = server.status === "connected" ? "✓" : "○";
|
|
201
|
+
text += `${icon} ${server.name} (${server.toolCount} tools)\n`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (servers.length > 0) {
|
|
205
|
+
text += `\nmcp({ server: "name" }) to list tools, mcp({ search: "..." }) to search`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text" as const, text: text.trim() }],
|
|
210
|
+
details: { mode: "status", servers, totalTools, connectedCount },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function executeDescribe(state: McpExtensionState, toolName: string) {
|
|
215
|
+
// Find the tool in metadata
|
|
216
|
+
let serverName: string | undefined;
|
|
217
|
+
let toolMeta: ToolMetadata | undefined;
|
|
218
|
+
|
|
219
|
+
for (const [server, metadata] of state.toolMetadata.entries()) {
|
|
220
|
+
const found = metadata.find(m => m.name === toolName);
|
|
221
|
+
if (found) {
|
|
222
|
+
serverName = server;
|
|
223
|
+
toolMeta = found;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!serverName || !toolMeta) {
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }],
|
|
231
|
+
details: { mode: "describe", error: "tool_not_found", requestedTool: toolName },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let text = `${toolMeta.name}\n`;
|
|
236
|
+
text += `Server: ${serverName}\n`;
|
|
237
|
+
if (toolMeta.resourceUri) {
|
|
238
|
+
text += `Type: Resource (reads from ${toolMeta.resourceUri})\n`;
|
|
239
|
+
}
|
|
240
|
+
text += `\n${toolMeta.description || "(no description)"}\n`;
|
|
241
|
+
|
|
242
|
+
// Format parameters from schema
|
|
243
|
+
if (toolMeta.inputSchema && !toolMeta.resourceUri) {
|
|
244
|
+
text += `\nParameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
|
245
|
+
} else if (toolMeta.resourceUri) {
|
|
246
|
+
text += `\nNo parameters required (resource tool).`;
|
|
247
|
+
} else {
|
|
248
|
+
text += `\nNo parameters defined.`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text" as const, text: text.trim() }],
|
|
253
|
+
details: { mode: "describe", tool: toolMeta, server: serverName },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Format JSON Schema to human-readable parameter documentation.
|
|
259
|
+
*/
|
|
260
|
+
function formatSchema(schema: unknown, indent = " "): string {
|
|
261
|
+
if (!schema || typeof schema !== "object") {
|
|
262
|
+
return `${indent}(no schema)`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const s = schema as Record<string, unknown>;
|
|
266
|
+
|
|
267
|
+
// Handle object type with properties
|
|
268
|
+
if (s.type === "object" && s.properties && typeof s.properties === "object") {
|
|
269
|
+
const props = s.properties as Record<string, unknown>;
|
|
270
|
+
const required = Array.isArray(s.required) ? s.required as string[] : [];
|
|
271
|
+
|
|
272
|
+
if (Object.keys(props).length === 0) {
|
|
273
|
+
return `${indent}(no parameters)`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const lines: string[] = [];
|
|
277
|
+
for (const [name, propSchema] of Object.entries(props)) {
|
|
278
|
+
const isRequired = required.includes(name);
|
|
279
|
+
const propLine = formatProperty(name, propSchema, isRequired, indent);
|
|
280
|
+
lines.push(propLine);
|
|
281
|
+
}
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Fallback: just show the schema type
|
|
286
|
+
if (s.type) {
|
|
287
|
+
return `${indent}(${s.type})`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return `${indent}(complex schema)`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Format a single property from JSON Schema.
|
|
295
|
+
*/
|
|
296
|
+
function formatProperty(name: string, schema: unknown, required: boolean, indent: string): string {
|
|
297
|
+
if (!schema || typeof schema !== "object") {
|
|
298
|
+
return `${indent}${name}${required ? " *required*" : ""}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const s = schema as Record<string, unknown>;
|
|
302
|
+
const parts: string[] = [];
|
|
303
|
+
|
|
304
|
+
// Type info
|
|
305
|
+
let typeStr = "";
|
|
306
|
+
if (s.type) {
|
|
307
|
+
if (Array.isArray(s.type)) {
|
|
308
|
+
typeStr = s.type.join(" | ");
|
|
309
|
+
} else {
|
|
310
|
+
typeStr = String(s.type);
|
|
311
|
+
}
|
|
312
|
+
} else if (s.enum) {
|
|
313
|
+
typeStr = "enum";
|
|
314
|
+
} else if (s.anyOf || s.oneOf) {
|
|
315
|
+
typeStr = "union";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Enum values
|
|
319
|
+
if (Array.isArray(s.enum)) {
|
|
320
|
+
const enumVals = s.enum.map(v => JSON.stringify(v)).join(", ");
|
|
321
|
+
typeStr = `enum: ${enumVals}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Build the line
|
|
325
|
+
parts.push(`${indent}${name}`);
|
|
326
|
+
if (typeStr) parts.push(`(${typeStr})`);
|
|
327
|
+
if (required) parts.push("*required*");
|
|
328
|
+
|
|
329
|
+
// Description
|
|
330
|
+
if (s.description && typeof s.description === "string") {
|
|
331
|
+
parts.push(`- ${s.description}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Default value
|
|
335
|
+
if (s.default !== undefined) {
|
|
336
|
+
parts.push(`[default: ${JSON.stringify(s.default)}]`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return parts.join(" ");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function executeSearch(
|
|
343
|
+
state: McpExtensionState,
|
|
344
|
+
query: string,
|
|
345
|
+
regex?: boolean,
|
|
346
|
+
server?: string,
|
|
347
|
+
includeSchemas?: boolean
|
|
348
|
+
) {
|
|
349
|
+
// Default to including schemas
|
|
350
|
+
const showSchemas = includeSchemas !== false;
|
|
351
|
+
|
|
352
|
+
const matches: Array<{ server: string; tool: ToolMetadata }> = [];
|
|
353
|
+
|
|
354
|
+
let pattern: RegExp;
|
|
355
|
+
try {
|
|
356
|
+
if (regex) {
|
|
357
|
+
pattern = new RegExp(query, "i");
|
|
358
|
+
} else {
|
|
359
|
+
// Split on whitespace and OR the terms (like most search engines)
|
|
360
|
+
const terms = query.trim().split(/\s+/).filter(t => t.length > 0);
|
|
361
|
+
if (terms.length === 0) {
|
|
362
|
+
return {
|
|
363
|
+
content: [{ type: "text" as const, text: "Search query cannot be empty" }],
|
|
364
|
+
details: { mode: "search", error: "empty_query" },
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
368
|
+
pattern = new RegExp(escaped.join("|"), "i");
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: "text" as const, text: `Invalid regex: ${query}` }],
|
|
373
|
+
details: { mode: "search", error: "invalid_pattern", query },
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const [serverName, metadata] of state.toolMetadata.entries()) {
|
|
378
|
+
if (server && serverName !== server) continue;
|
|
379
|
+
for (const tool of metadata) {
|
|
380
|
+
if (pattern.test(tool.name) || pattern.test(tool.description)) {
|
|
381
|
+
matches.push({
|
|
382
|
+
server: serverName,
|
|
383
|
+
tool,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (matches.length === 0) {
|
|
390
|
+
const msg = server
|
|
391
|
+
? `No tools matching "${query}" in "${server}"`
|
|
392
|
+
: `No tools matching "${query}"`;
|
|
393
|
+
return {
|
|
394
|
+
content: [{ type: "text" as const, text: msg }],
|
|
395
|
+
details: { mode: "search", matches: [], count: 0, query },
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let text = `Found ${matches.length} tool${matches.length === 1 ? "" : "s"} matching "${query}":\n\n`;
|
|
400
|
+
|
|
401
|
+
for (const match of matches) {
|
|
402
|
+
if (showSchemas) {
|
|
403
|
+
// Full format with schema
|
|
404
|
+
text += `${match.tool.name}\n`;
|
|
405
|
+
text += ` ${match.tool.description || "(no description)"}\n`;
|
|
406
|
+
if (match.tool.inputSchema && !match.tool.resourceUri) {
|
|
407
|
+
text += `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n`;
|
|
408
|
+
} else if (match.tool.resourceUri) {
|
|
409
|
+
text += ` No parameters (resource tool).\n`;
|
|
410
|
+
}
|
|
411
|
+
text += "\n";
|
|
412
|
+
} else {
|
|
413
|
+
// Compact format without schema
|
|
414
|
+
text += `- ${match.tool.name}`;
|
|
415
|
+
if (match.tool.description) {
|
|
416
|
+
text += ` - ${truncateAtWord(match.tool.description, 50)}`;
|
|
417
|
+
}
|
|
418
|
+
text += "\n";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
content: [{ type: "text" as const, text: text.trim() }],
|
|
424
|
+
details: { mode: "search", matches: matches.map(m => ({ server: m.server, tool: m.tool.name })), count: matches.length, query },
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function executeList(state: McpExtensionState, server: string) {
|
|
429
|
+
const toolNames = state.registeredTools.get(server);
|
|
430
|
+
const metadata = state.toolMetadata.get(server);
|
|
431
|
+
|
|
432
|
+
if (!toolNames || toolNames.length === 0) {
|
|
433
|
+
// Server exists in registeredTools (even if empty) means it connected
|
|
434
|
+
if (state.registeredTools.has(server)) {
|
|
435
|
+
return {
|
|
436
|
+
content: [{ type: "text" as const, text: `Server "${server}" has no tools.` }],
|
|
437
|
+
details: { mode: "list", server, tools: [], count: 0 },
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
// Server in config but not in registeredTools means connection failed
|
|
441
|
+
if (state.config.mcpServers[server]) {
|
|
442
|
+
return {
|
|
443
|
+
content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use /mcp reconnect to retry.` }],
|
|
444
|
+
details: { mode: "list", server, tools: [], count: 0, error: "not_connected" },
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
// Server not in config at all
|
|
448
|
+
return {
|
|
449
|
+
content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
|
|
450
|
+
details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let text = `${server} (${toolNames.length} tools):\n\n`;
|
|
455
|
+
|
|
456
|
+
// Build a map of tool name -> description for quick lookup
|
|
457
|
+
const descMap = new Map<string, string>();
|
|
458
|
+
if (metadata) {
|
|
459
|
+
for (const m of metadata) {
|
|
460
|
+
descMap.set(m.name, m.description);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const tool of toolNames) {
|
|
465
|
+
const desc = descMap.get(tool) ?? "";
|
|
466
|
+
const truncated = truncateAtWord(desc, 50);
|
|
467
|
+
text += `- ${tool}`;
|
|
468
|
+
if (truncated) text += ` - ${truncated}`;
|
|
469
|
+
text += "\n";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
content: [{ type: "text" as const, text: text.trim() }],
|
|
474
|
+
details: { mode: "list", server, tools: toolNames, count: toolNames.length },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function executeCall(
|
|
479
|
+
state: McpExtensionState,
|
|
480
|
+
toolName: string,
|
|
481
|
+
args?: Record<string, unknown>
|
|
482
|
+
) {
|
|
483
|
+
// Find the tool in metadata
|
|
484
|
+
let serverName: string | undefined;
|
|
485
|
+
let toolMeta: ToolMetadata | undefined;
|
|
486
|
+
|
|
487
|
+
for (const [server, metadata] of state.toolMetadata.entries()) {
|
|
488
|
+
const found = metadata.find(m => m.name === toolName);
|
|
489
|
+
if (found) {
|
|
490
|
+
serverName = server;
|
|
491
|
+
toolMeta = found;
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!serverName || !toolMeta) {
|
|
497
|
+
return {
|
|
498
|
+
content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }],
|
|
499
|
+
details: { mode: "call", error: "tool_not_found", requestedTool: toolName },
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const connection = state.manager.getConnection(serverName);
|
|
504
|
+
if (!connection || connection.status !== "connected") {
|
|
505
|
+
return {
|
|
506
|
+
content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
|
|
507
|
+
details: { mode: "call", error: "server_not_connected", server: serverName },
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
// Resource tools use readResource, regular tools use callTool
|
|
513
|
+
if (toolMeta.resourceUri) {
|
|
514
|
+
const result = await connection.client.readResource({ uri: toolMeta.resourceUri });
|
|
515
|
+
const content = (result.contents ?? []).map(c => ({
|
|
516
|
+
type: "text" as const,
|
|
517
|
+
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
|
518
|
+
}));
|
|
519
|
+
return {
|
|
520
|
+
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
|
521
|
+
details: { mode: "call", resourceUri: toolMeta.resourceUri, server: serverName },
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Regular tool call
|
|
526
|
+
const result = await connection.client.callTool({
|
|
527
|
+
name: toolMeta.originalName,
|
|
528
|
+
arguments: args ?? {},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const mcpContent = (result.content ?? []) as McpContent[];
|
|
532
|
+
const content = transformMcpContent(mcpContent);
|
|
533
|
+
|
|
534
|
+
if (result.isError) {
|
|
535
|
+
const errorText = content
|
|
536
|
+
.filter((c) => c.type === "text")
|
|
537
|
+
.map((c) => (c as { text: string }).text)
|
|
538
|
+
.join("\n") || "Tool execution failed";
|
|
539
|
+
|
|
540
|
+
// Include schema in error to help LLM self-correct
|
|
541
|
+
let errorWithSchema = `Error: ${errorText}`;
|
|
542
|
+
if (toolMeta.inputSchema) {
|
|
543
|
+
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
content: [{ type: "text" as const, text: errorWithSchema }],
|
|
548
|
+
details: { mode: "call", error: "tool_error", mcpResult: result },
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
|
554
|
+
details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName },
|
|
555
|
+
};
|
|
556
|
+
} catch (error) {
|
|
557
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
558
|
+
|
|
559
|
+
// Include schema in error to help LLM self-correct
|
|
560
|
+
let errorWithSchema = `Failed to call tool: ${message}`;
|
|
561
|
+
if (toolMeta.inputSchema) {
|
|
562
|
+
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
content: [{ type: "text" as const, text: errorWithSchema }],
|
|
567
|
+
details: { mode: "call", error: "call_failed", message },
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function initializeMcp(
|
|
573
|
+
pi: ExtensionAPI,
|
|
574
|
+
ctx: ExtensionContext
|
|
575
|
+
): Promise<McpExtensionState> {
|
|
576
|
+
const configPath = pi.getFlag("mcp-config") as string | undefined;
|
|
577
|
+
const config = loadMcpConfig(configPath);
|
|
578
|
+
|
|
579
|
+
const manager = new McpServerManager();
|
|
580
|
+
const lifecycle = new McpLifecycleManager(manager);
|
|
581
|
+
const registeredTools = new Map<string, string[]>();
|
|
582
|
+
const toolMetadata = new Map<string, ToolMetadata[]>();
|
|
583
|
+
|
|
584
|
+
const serverEntries = Object.entries(config.mcpServers);
|
|
585
|
+
if (serverEntries.length === 0) {
|
|
586
|
+
return { manager, lifecycle, registeredTools, toolMetadata, config };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
for (const [name, definition] of serverEntries) {
|
|
590
|
+
try {
|
|
591
|
+
if (ctx.hasUI) {
|
|
592
|
+
ctx.ui.setStatus("mcp", `Connecting to ${name}...`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const connection = await manager.connect(name, definition);
|
|
596
|
+
const prefix = config.settings?.toolPrefix ?? "server";
|
|
597
|
+
|
|
598
|
+
// Collect tool names (NOT registered with Pi - only mcp proxy is registered)
|
|
599
|
+
const { collected: toolNames, failed: failedTools } = collectToolNames(
|
|
600
|
+
connection.tools,
|
|
601
|
+
{ serverName: name, prefix }
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
// Collect resource tool names (if enabled)
|
|
605
|
+
if (definition.exposeResources !== false && connection.resources.length > 0) {
|
|
606
|
+
const resourceToolNames = collectResourceToolNames(
|
|
607
|
+
connection.resources,
|
|
608
|
+
{ serverName: name, prefix }
|
|
609
|
+
);
|
|
610
|
+
toolNames.push(...resourceToolNames);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
registeredTools.set(name, toolNames);
|
|
614
|
+
|
|
615
|
+
// Build tool metadata for searching (include inputSchema for describe/errors)
|
|
616
|
+
const metadata: ToolMetadata[] = connection.tools.map(tool => ({
|
|
617
|
+
name: formatToolName(tool.name, name, prefix),
|
|
618
|
+
originalName: tool.name,
|
|
619
|
+
description: tool.description ?? "",
|
|
620
|
+
inputSchema: tool.inputSchema,
|
|
621
|
+
}));
|
|
622
|
+
// Add resource tools to metadata
|
|
623
|
+
for (const resource of connection.resources) {
|
|
624
|
+
if (definition.exposeResources !== false) {
|
|
625
|
+
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
626
|
+
metadata.push({
|
|
627
|
+
name: formatToolName(baseName, name, prefix),
|
|
628
|
+
originalName: baseName,
|
|
629
|
+
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
630
|
+
resourceUri: resource.uri,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
toolMetadata.set(name, metadata);
|
|
635
|
+
|
|
636
|
+
// Mark keep-alive servers
|
|
637
|
+
if (definition.lifecycle === "keep-alive") {
|
|
638
|
+
lifecycle.markKeepAlive(name, definition);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (failedTools.length > 0 && ctx.hasUI) {
|
|
642
|
+
ctx.ui.notify(
|
|
643
|
+
`MCP: ${name} - ${failedTools.length} tools skipped`,
|
|
644
|
+
"warning"
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (ctx.hasUI) {
|
|
649
|
+
ctx.ui.notify(
|
|
650
|
+
`MCP: ${name} connected (${connection.tools.length} tools, ${connection.resources.length} resources)`,
|
|
651
|
+
"info"
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
} catch (error) {
|
|
655
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
656
|
+
if (ctx.hasUI) {
|
|
657
|
+
ctx.ui.notify(`MCP: Failed to connect to ${name}: ${message}`, "error");
|
|
658
|
+
}
|
|
659
|
+
console.error(`MCP: Failed to connect to ${name}: ${message}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Start health checks for keep-alive servers
|
|
664
|
+
lifecycle.startHealthChecks();
|
|
665
|
+
|
|
666
|
+
return { manager, lifecycle, registeredTools, toolMetadata, config };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Update tool metadata for a single server after reconnection.
|
|
671
|
+
* Called by lifecycle manager when a keep-alive server reconnects.
|
|
672
|
+
*/
|
|
673
|
+
function updateServerMetadata(state: McpExtensionState, serverName: string): void {
|
|
674
|
+
const connection = state.manager.getConnection(serverName);
|
|
675
|
+
if (!connection || connection.status !== "connected") return;
|
|
676
|
+
|
|
677
|
+
const definition = state.config.mcpServers[serverName];
|
|
678
|
+
if (!definition) return;
|
|
679
|
+
|
|
680
|
+
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
681
|
+
|
|
682
|
+
// Collect tool names
|
|
683
|
+
const { collected: toolNames } = collectToolNames(
|
|
684
|
+
connection.tools,
|
|
685
|
+
{ serverName, prefix }
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
// Collect resource tool names if enabled
|
|
689
|
+
if (definition.exposeResources !== false && connection.resources.length > 0) {
|
|
690
|
+
const resourceToolNames = collectResourceToolNames(
|
|
691
|
+
connection.resources,
|
|
692
|
+
{ serverName, prefix }
|
|
693
|
+
);
|
|
694
|
+
toolNames.push(...resourceToolNames);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
state.registeredTools.set(serverName, toolNames);
|
|
698
|
+
|
|
699
|
+
// Update tool metadata (include inputSchema for describe/errors)
|
|
700
|
+
const metadata: ToolMetadata[] = connection.tools.map(tool => ({
|
|
701
|
+
name: formatToolName(tool.name, serverName, prefix),
|
|
702
|
+
originalName: tool.name,
|
|
703
|
+
description: tool.description ?? "",
|
|
704
|
+
inputSchema: tool.inputSchema,
|
|
705
|
+
}));
|
|
706
|
+
for (const resource of connection.resources) {
|
|
707
|
+
if (definition.exposeResources !== false) {
|
|
708
|
+
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
709
|
+
metadata.push({
|
|
710
|
+
name: formatToolName(baseName, serverName, prefix),
|
|
711
|
+
originalName: baseName,
|
|
712
|
+
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
713
|
+
resourceUri: resource.uri,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
state.toolMetadata.set(serverName, metadata);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
721
|
+
if (!ctx.hasUI) return;
|
|
722
|
+
|
|
723
|
+
const lines: string[] = ["MCP Server Status:", ""];
|
|
724
|
+
|
|
725
|
+
// Show all configured servers, not just connected ones
|
|
726
|
+
for (const name of Object.keys(state.config.mcpServers)) {
|
|
727
|
+
const connection = state.manager.getConnection(name);
|
|
728
|
+
const toolNames = state.registeredTools.get(name) ?? [];
|
|
729
|
+
const status = connection?.status ?? "not connected";
|
|
730
|
+
const statusIcon = status === "connected" ? "✓" : "○";
|
|
731
|
+
|
|
732
|
+
lines.push(`${statusIcon} ${name}: ${status} (${toolNames.length} tools)`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (Object.keys(state.config.mcpServers).length === 0) {
|
|
736
|
+
lines.push("No MCP servers configured");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
743
|
+
if (!ctx.hasUI) return;
|
|
744
|
+
|
|
745
|
+
const allTools = [...state.registeredTools.values()].flat();
|
|
746
|
+
|
|
747
|
+
if (allTools.length === 0) {
|
|
748
|
+
ctx.ui.notify("No MCP tools available", "info");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const lines = [
|
|
753
|
+
"MCP Tools:",
|
|
754
|
+
"",
|
|
755
|
+
...allTools.map(t => ` ${t}`),
|
|
756
|
+
"",
|
|
757
|
+
`Total: ${allTools.length} tools`,
|
|
758
|
+
];
|
|
759
|
+
|
|
760
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function reconnectServers(
|
|
764
|
+
state: McpExtensionState,
|
|
765
|
+
ctx: ExtensionContext
|
|
766
|
+
): Promise<void> {
|
|
767
|
+
for (const [name, definition] of Object.entries(state.config.mcpServers)) {
|
|
768
|
+
try {
|
|
769
|
+
await state.manager.close(name);
|
|
770
|
+
|
|
771
|
+
// Clear old entries before reconnecting (in case reconnection fails)
|
|
772
|
+
state.registeredTools.delete(name);
|
|
773
|
+
state.toolMetadata.delete(name);
|
|
774
|
+
|
|
775
|
+
const connection = await state.manager.connect(name, definition);
|
|
776
|
+
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
777
|
+
|
|
778
|
+
// Collect tool names (NOT registered with Pi)
|
|
779
|
+
const { collected: toolNames, failed: failedTools } = collectToolNames(
|
|
780
|
+
connection.tools,
|
|
781
|
+
{ serverName: name, prefix }
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
// Collect resource tool names if enabled
|
|
785
|
+
if (definition.exposeResources !== false && connection.resources.length > 0) {
|
|
786
|
+
const resourceToolNames = collectResourceToolNames(
|
|
787
|
+
connection.resources,
|
|
788
|
+
{ serverName: name, prefix }
|
|
789
|
+
);
|
|
790
|
+
toolNames.push(...resourceToolNames);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
state.registeredTools.set(name, toolNames);
|
|
794
|
+
|
|
795
|
+
// Update tool metadata for searching
|
|
796
|
+
const metadata: ToolMetadata[] = connection.tools.map(tool => ({
|
|
797
|
+
name: formatToolName(tool.name, name, prefix),
|
|
798
|
+
originalName: tool.name,
|
|
799
|
+
description: tool.description ?? "",
|
|
800
|
+
}));
|
|
801
|
+
for (const resource of connection.resources) {
|
|
802
|
+
if (definition.exposeResources !== false) {
|
|
803
|
+
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
804
|
+
metadata.push({
|
|
805
|
+
name: formatToolName(baseName, name, prefix),
|
|
806
|
+
originalName: baseName,
|
|
807
|
+
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
808
|
+
resourceUri: resource.uri,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
state.toolMetadata.set(name, metadata);
|
|
813
|
+
|
|
814
|
+
if (ctx.hasUI) {
|
|
815
|
+
ctx.ui.notify(
|
|
816
|
+
`MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
|
|
817
|
+
"info"
|
|
818
|
+
);
|
|
819
|
+
if (failedTools.length > 0) {
|
|
820
|
+
ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
} catch (error) {
|
|
824
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
825
|
+
if (ctx.hasUI) {
|
|
826
|
+
ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Update status bar with new tool count
|
|
832
|
+
if (ctx.hasUI) {
|
|
833
|
+
const totalTools = [...state.registeredTools.values()].flat().length;
|
|
834
|
+
if (totalTools > 0) {
|
|
835
|
+
ctx.ui.setStatus("mcp", ctx.ui.theme.fg("accent", `MCP: ${totalTools} tools`));
|
|
836
|
+
} else {
|
|
837
|
+
ctx.ui.setStatus("mcp", "");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function authenticateServer(
|
|
843
|
+
serverName: string,
|
|
844
|
+
config: McpConfig,
|
|
845
|
+
ctx: ExtensionContext
|
|
846
|
+
): Promise<void> {
|
|
847
|
+
if (!ctx.hasUI) return;
|
|
848
|
+
|
|
849
|
+
const definition = config.mcpServers[serverName];
|
|
850
|
+
if (!definition) {
|
|
851
|
+
ctx.ui.notify(`Server "${serverName}" not found in config`, "error");
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (definition.auth !== "oauth") {
|
|
856
|
+
ctx.ui.notify(
|
|
857
|
+
`Server "${serverName}" does not use OAuth authentication.\n` +
|
|
858
|
+
`Current auth mode: ${definition.auth ?? "none"}`,
|
|
859
|
+
"error"
|
|
860
|
+
);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (!definition.url) {
|
|
865
|
+
ctx.ui.notify(
|
|
866
|
+
`Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`,
|
|
867
|
+
"error"
|
|
868
|
+
);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Show instructions for obtaining OAuth tokens
|
|
873
|
+
const tokenPath = `~/.pi/agent/mcp-oauth/${serverName}/tokens.json`;
|
|
874
|
+
|
|
875
|
+
ctx.ui.notify(
|
|
876
|
+
`OAuth setup for "${serverName}":\n\n` +
|
|
877
|
+
`1. Obtain an access token from your OAuth provider\n` +
|
|
878
|
+
`2. Create the token file:\n` +
|
|
879
|
+
` ${tokenPath}\n\n` +
|
|
880
|
+
`3. Add your token:\n` +
|
|
881
|
+
` {\n` +
|
|
882
|
+
` "access_token": "your-token-here",\n` +
|
|
883
|
+
` "token_type": "bearer"\n` +
|
|
884
|
+
` }\n\n` +
|
|
885
|
+
`4. Run /mcp reconnect to connect with the token`,
|
|
886
|
+
"info"
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Truncate text at word boundary, aiming for target length.
|
|
892
|
+
*/
|
|
893
|
+
function truncateAtWord(text: string, target: number): string {
|
|
894
|
+
if (!text || text.length <= target) return text;
|
|
895
|
+
|
|
896
|
+
// Find last space before or at target
|
|
897
|
+
const truncated = text.slice(0, target);
|
|
898
|
+
const lastSpace = truncated.lastIndexOf(" ");
|
|
899
|
+
|
|
900
|
+
if (lastSpace > target * 0.6) {
|
|
901
|
+
// Found a reasonable break point
|
|
902
|
+
return truncated.slice(0, lastSpace) + "...";
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// No good break point, just cut at target
|
|
906
|
+
return truncated + "...";
|
|
907
|
+
}
|