@yofriadi/pi-mcp 0.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.
@@ -0,0 +1,325 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@mariozechner/pi-coding-agent";
2
+ import type { McpManager, McpManagerState } from "../runtime/mcp-manager";
3
+
4
+ const BRIDGED_TOOL_NAME_MAX_LENGTH = 64;
5
+ const FALLBACK_PARAMETERS_SCHEMA = {
6
+ type: "object",
7
+ additionalProperties: true,
8
+ };
9
+
10
+ interface McpDiscoveredTool {
11
+ name: string;
12
+ description?: string;
13
+ inputSchema?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface McpBridgedToolRegistration {
17
+ key: string;
18
+ server: string;
19
+ mcpToolName: string;
20
+ registeredName: string;
21
+ description?: string;
22
+ }
23
+
24
+ export interface McpToolBridgeSyncResult {
25
+ added: number;
26
+ total: number;
27
+ addedTools: string[];
28
+ failed: Array<{ key: string; reason: string }>;
29
+ }
30
+
31
+ export interface McpToolBridge {
32
+ sync(): McpToolBridgeSyncResult;
33
+ getRegistrations(): McpBridgedToolRegistration[];
34
+ }
35
+
36
+ export function createMcpToolBridge(pi: ExtensionAPI, manager: McpManager): McpToolBridge {
37
+ const registrationsByKey = new Map<string, McpBridgedToolRegistration>();
38
+ const usedToolNames = new Set<string>(["mcp_call", "mcp_list_tools"]);
39
+
40
+ return {
41
+ sync(): McpToolBridgeSyncResult {
42
+ const state = manager.getState();
43
+ const addedTools: string[] = [];
44
+ const failed: Array<{ key: string; reason: string }> = [];
45
+
46
+ for (const [serverName, toolListState] of Object.entries(state.toolLists)) {
47
+ if (toolListState.state !== "ready") {
48
+ continue;
49
+ }
50
+
51
+ const discoveredTools = extractDiscoveredTools(toolListState.tools);
52
+ for (const discovered of discoveredTools) {
53
+ const key = createRegistrationKey(serverName, discovered.name);
54
+ if (registrationsByKey.has(key)) {
55
+ continue;
56
+ }
57
+
58
+ try {
59
+ const registeredName = createStableBridgedToolName(serverName, discovered.name, usedToolNames);
60
+ const toolDefinition = createBridgedToolDefinition({
61
+ manager,
62
+ serverName,
63
+ discovered,
64
+ registeredName,
65
+ });
66
+ pi.registerTool(toolDefinition);
67
+
68
+ const registration: McpBridgedToolRegistration = {
69
+ key,
70
+ server: serverName,
71
+ mcpToolName: discovered.name,
72
+ registeredName,
73
+ description: discovered.description,
74
+ };
75
+ registrationsByKey.set(key, registration);
76
+ usedToolNames.add(registeredName);
77
+ addedTools.push(registeredName);
78
+ } catch (error) {
79
+ failed.push({
80
+ key,
81
+ reason: formatError(error),
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ return {
88
+ added: addedTools.length,
89
+ total: registrationsByKey.size,
90
+ addedTools,
91
+ failed,
92
+ };
93
+ },
94
+
95
+ getRegistrations(): McpBridgedToolRegistration[] {
96
+ return [...registrationsByKey.values()];
97
+ },
98
+ };
99
+ }
100
+
101
+ export function createStableBridgedToolName(
102
+ serverName: string,
103
+ mcpToolName: string,
104
+ usedToolNames: Set<string>,
105
+ ): string {
106
+ const base = `mcp_${sanitizeToolNameSegment(serverName)}_${sanitizeToolNameSegment(mcpToolName)}`;
107
+ const hashInput = `${serverName}::${mcpToolName}`;
108
+ let candidate = trimToolName(base);
109
+
110
+ if (!usedToolNames.has(candidate)) {
111
+ return candidate;
112
+ }
113
+
114
+ const hash = shortHash(hashInput);
115
+ candidate = trimToolName(`${base}_${hash}`);
116
+ if (!usedToolNames.has(candidate)) {
117
+ return candidate;
118
+ }
119
+
120
+ let counter = 2;
121
+ while (usedToolNames.has(candidate)) {
122
+ candidate = trimToolName(`${base}_${hash}_${counter}`);
123
+ counter += 1;
124
+ }
125
+ return candidate;
126
+ }
127
+
128
+ export function normalizeMcpInputSchema(inputSchema: unknown): Record<string, unknown> {
129
+ if (!isObject(inputSchema)) {
130
+ return { ...FALLBACK_PARAMETERS_SCHEMA };
131
+ }
132
+
133
+ const normalized = cloneJsonObject(inputSchema);
134
+ if (!normalized) {
135
+ return { ...FALLBACK_PARAMETERS_SCHEMA };
136
+ }
137
+
138
+ const hasObjectSignals =
139
+ normalized.type === "object" ||
140
+ isObject(normalized.properties) ||
141
+ Array.isArray(normalized.required) ||
142
+ isObject(normalized.patternProperties);
143
+
144
+ if (!hasObjectSignals) {
145
+ return { ...FALLBACK_PARAMETERS_SCHEMA };
146
+ }
147
+
148
+ if (normalized.type === undefined) {
149
+ normalized.type = "object";
150
+ }
151
+
152
+ if (normalized.additionalProperties === undefined) {
153
+ normalized.additionalProperties = true;
154
+ }
155
+
156
+ return normalized;
157
+ }
158
+
159
+ function createBridgedToolDefinition(input: {
160
+ manager: McpManager;
161
+ serverName: string;
162
+ discovered: McpDiscoveredTool;
163
+ registeredName: string;
164
+ }): ToolDefinition {
165
+ const { manager, serverName, discovered, registeredName } = input;
166
+ const schema = normalizeMcpInputSchema(discovered.inputSchema);
167
+
168
+ return {
169
+ name: registeredName,
170
+ label: `MCP ${discovered.name}`,
171
+ description:
172
+ discovered.description?.trim() || `Bridged MCP tool "${discovered.name}" from server "${serverName}".`,
173
+ parameters: schema as any,
174
+ async execute(_toolCallId, params, signal) {
175
+ const argumentsPayload = normalizeToolArguments(params);
176
+ try {
177
+ const result = await manager.callTool(serverName, discovered.name, argumentsPayload, {
178
+ timeoutMs: 30_000,
179
+ signal,
180
+ });
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: formatJsonResult(`MCP ${serverName}.${discovered.name}`, result),
186
+ },
187
+ ],
188
+ details: {
189
+ server: serverName,
190
+ mcpToolName: discovered.name,
191
+ registeredToolName: registeredName,
192
+ result,
193
+ },
194
+ };
195
+ } catch (error) {
196
+ return {
197
+ isError: true,
198
+ content: [
199
+ {
200
+ type: "text",
201
+ text: `MCP tool call failed (${serverName}.${discovered.name}): ${formatError(error)}`,
202
+ },
203
+ ],
204
+ details: {
205
+ server: serverName,
206
+ mcpToolName: discovered.name,
207
+ registeredToolName: registeredName,
208
+ error: formatError(error),
209
+ },
210
+ };
211
+ }
212
+ },
213
+ };
214
+ }
215
+
216
+ function extractDiscoveredTools(tools: unknown[]): McpDiscoveredTool[] {
217
+ const discovered: McpDiscoveredTool[] = [];
218
+ for (const entry of tools) {
219
+ if (!isObject(entry)) {
220
+ continue;
221
+ }
222
+
223
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
224
+ if (!name) {
225
+ continue;
226
+ }
227
+
228
+ const description = typeof entry.description === "string" ? entry.description.trim() : undefined;
229
+ const inputSchema = normalizeInputSchemaField(entry);
230
+ discovered.push({
231
+ name,
232
+ description,
233
+ inputSchema,
234
+ });
235
+ }
236
+ return discovered;
237
+ }
238
+
239
+ function normalizeInputSchemaField(entry: Record<string, unknown>): Record<string, unknown> | undefined {
240
+ const candidates = [entry.inputSchema, entry.input_schema, entry.parameters];
241
+ for (const candidate of candidates) {
242
+ if (isObject(candidate)) {
243
+ return candidate;
244
+ }
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ function normalizeToolArguments(params: unknown): Record<string, unknown> {
250
+ if (isObject(params)) {
251
+ return params;
252
+ }
253
+ if (params === undefined || params === null) {
254
+ return {};
255
+ }
256
+ return { value: params };
257
+ }
258
+
259
+ function createRegistrationKey(serverName: string, mcpToolName: string): string {
260
+ return `${serverName}::${mcpToolName}`;
261
+ }
262
+
263
+ function sanitizeToolNameSegment(value: string): string {
264
+ const sanitized = value
265
+ .toLowerCase()
266
+ .replace(/[^a-z0-9]+/g, "_")
267
+ .replace(/^_+|_+$/g, "");
268
+ if (!sanitized) {
269
+ return "tool";
270
+ }
271
+ return sanitized;
272
+ }
273
+
274
+ function trimToolName(value: string): string {
275
+ if (value.length <= BRIDGED_TOOL_NAME_MAX_LENGTH) {
276
+ return value;
277
+ }
278
+ return value.slice(0, BRIDGED_TOOL_NAME_MAX_LENGTH);
279
+ }
280
+
281
+ function shortHash(value: string): string {
282
+ let hash = 2166136261;
283
+ for (let index = 0; index < value.length; index += 1) {
284
+ hash ^= value.charCodeAt(index);
285
+ hash = Math.imul(hash, 16777619);
286
+ }
287
+ return (hash >>> 0).toString(36);
288
+ }
289
+
290
+ function cloneJsonObject(value: Record<string, unknown>): Record<string, unknown> | undefined {
291
+ try {
292
+ const cloned = JSON.parse(JSON.stringify(value));
293
+ return isObject(cloned) ? cloned : undefined;
294
+ } catch {
295
+ return undefined;
296
+ }
297
+ }
298
+
299
+ function isObject(value: unknown): value is Record<string, unknown> {
300
+ return !!value && typeof value === "object" && !Array.isArray(value);
301
+ }
302
+
303
+ function formatJsonResult(prefix: string, payload: unknown): string {
304
+ return `${prefix}:\n${JSON.stringify(payload, null, 2)}`;
305
+ }
306
+
307
+ function formatError(error: unknown): string {
308
+ if (error instanceof Error) {
309
+ return error.message;
310
+ }
311
+ return String(error);
312
+ }
313
+
314
+ export function getMcpBridgeToolSummary(state: McpManagerState): { readyServers: number; discoveredTools: number } {
315
+ let readyServers = 0;
316
+ let discoveredTools = 0;
317
+ for (const toolListState of Object.values(state.toolLists)) {
318
+ if (toolListState.state !== "ready") {
319
+ continue;
320
+ }
321
+ readyServers += 1;
322
+ discoveredTools += extractDiscoveredTools(toolListState.tools).length;
323
+ }
324
+ return { readyServers, discoveredTools };
325
+ }
@@ -0,0 +1,257 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { McpManager } from "../runtime/mcp-manager";
4
+ import { createMcpToolBridge, getMcpBridgeToolSummary, type McpToolBridge } from "./mcp-tool-bridge";
5
+
6
+ const MCP_CALL_PARAMS = Type.Object({
7
+ server: Type.String({ description: "Configured MCP server name" }),
8
+ method: Type.String({ description: "JSON-RPC method name" }),
9
+ params: Type.Optional(Type.Unknown({ description: "JSON-RPC params payload" })),
10
+ timeoutMs: Type.Optional(Type.Number({ minimum: 1_000, maximum: 120_000 })),
11
+ });
12
+
13
+ const MCP_LIST_TOOLS_PARAMS = Type.Object({
14
+ server: Type.String({ description: "Configured MCP server name" }),
15
+ timeoutMs: Type.Optional(Type.Number({ minimum: 1_000, maximum: 120_000 })),
16
+ });
17
+
18
+ export function registerMcpTools(pi: ExtensionAPI, manager: McpManager): McpToolBridge {
19
+ const bridge = createMcpToolBridge(pi, manager);
20
+
21
+ pi.registerCommand("mcp-status", {
22
+ description: "Show status information for configured MCP servers",
23
+ handler: async (_args, ctx) => {
24
+ const state = manager.getState();
25
+ const config = state.config;
26
+ const status = state.runtime;
27
+ const bridgeSummary = getMcpBridgeToolSummary(state);
28
+ const lines = [
29
+ `MCP manager: ${state.lifecycle} (${state.reason})`,
30
+ `MCP state: ${status.state}`,
31
+ `Reason: ${status.reason}`,
32
+ `Configured servers: ${status.configuredServers}`,
33
+ `Active servers: ${status.activeServers}`,
34
+ `Discovered MCP tools: ${bridgeSummary.discoveredTools} across ${bridgeSummary.readyServers} ready server(s)`,
35
+ `Bridged MCP tools: ${bridge.getRegistrations().length}`,
36
+ ];
37
+
38
+ if (state.session) {
39
+ const sessionLabel = state.session.sessionId ?? "<none>";
40
+ const activeLabel = state.session.isActive ? "active" : "inactive";
41
+ lines.push(`Session: ${sessionLabel} (${activeLabel}, reloads: ${state.session.reloadCount})`);
42
+ }
43
+
44
+ if (status.servers.length > 0) {
45
+ lines.push("Servers:");
46
+ for (const server of status.servers) {
47
+ const location =
48
+ server.transport === "http"
49
+ ? (server.url ?? "<missing url>")
50
+ : (server.command?.join(" ") ?? "<missing command>");
51
+ lines.push(`- ${server.name}: ${server.state} (${server.transport}) ${location} -> ${server.reason}`);
52
+ }
53
+ }
54
+
55
+ const toolLists = Object.values(state.toolLists);
56
+ if (toolLists.length > 0) {
57
+ lines.push("Tool list cache:");
58
+ for (const entry of toolLists) {
59
+ lines.push(`- ${entry.server}: ${entry.state} (${entry.tools.length} tool(s)) -> ${entry.reason}`);
60
+ }
61
+ }
62
+
63
+ if (config.diagnostics.length > 0) {
64
+ lines.push("Diagnostics:");
65
+ for (const diagnostic of config.diagnostics) {
66
+ lines.push(`- ${diagnostic.level} ${diagnostic.code}: ${diagnostic.message}`);
67
+ }
68
+ }
69
+
70
+ ctx.ui.notify(lines.join("\n"), status.state === "error" ? "warning" : "info");
71
+ },
72
+ });
73
+
74
+ pi.registerCommand("mcp-tools", {
75
+ description: "List tools exposed by a configured MCP server (/mcp-tools <server>)",
76
+ handler: async (args, ctx) => {
77
+ const server = args.trim();
78
+ if (!server) {
79
+ ctx.ui.notify("Usage: /mcp-tools <server>", "warning");
80
+ return;
81
+ }
82
+
83
+ try {
84
+ const result = await manager.listTools(server, { timeoutMs: 20_000 });
85
+ ctx.ui.notify(formatJsonResult(`MCP tools for ${server}`, result), "info");
86
+ } catch (error) {
87
+ ctx.ui.notify(`Failed to list MCP tools for ${server}: ${formatError(error)}`, "warning");
88
+ }
89
+ },
90
+ });
91
+
92
+ pi.registerCommand("mcp-call", {
93
+ description: "Call an MCP method (/mcp-call <server> <method> [jsonParams])",
94
+ handler: async (args, ctx) => {
95
+ const parsed = parseCommandArgs(args);
96
+ if (!parsed) {
97
+ ctx.ui.notify("Usage: /mcp-call <server> <method> [jsonParams]", "warning");
98
+ return;
99
+ }
100
+
101
+ try {
102
+ const result = await manager.request(parsed.server, parsed.method, parsed.params ?? {}, {
103
+ timeoutMs: 25_000,
104
+ });
105
+ ctx.ui.notify(formatJsonResult(`MCP result ${parsed.server}.${parsed.method}`, result), "info");
106
+ } catch (error) {
107
+ ctx.ui.notify(`MCP request failed: ${formatError(error)}`, "warning");
108
+ }
109
+ },
110
+ });
111
+
112
+ pi.registerTool({
113
+ name: "mcp_call",
114
+ label: "MCP call",
115
+ description: "Call a JSON-RPC method on a configured MCP server",
116
+ parameters: MCP_CALL_PARAMS,
117
+ async execute(_toolCallId, params, signal) {
118
+ try {
119
+ const result = await manager.request(params.server, params.method, params.params ?? {}, {
120
+ timeoutMs: params.timeoutMs,
121
+ signal,
122
+ });
123
+ return {
124
+ content: [
125
+ {
126
+ type: "text",
127
+ text: formatJsonResult(`MCP ${params.server}.${params.method}`, result),
128
+ },
129
+ ],
130
+ details: {
131
+ server: params.server,
132
+ method: params.method,
133
+ result,
134
+ },
135
+ };
136
+ } catch (error) {
137
+ return {
138
+ isError: true,
139
+ content: [
140
+ {
141
+ type: "text",
142
+ text: `MCP request failed: ${formatError(error)}`,
143
+ },
144
+ ],
145
+ details: {
146
+ server: params.server,
147
+ method: params.method,
148
+ error: formatError(error),
149
+ },
150
+ };
151
+ }
152
+ },
153
+ });
154
+
155
+ pi.registerTool({
156
+ name: "mcp_list_tools",
157
+ label: "MCP list tools",
158
+ description: "List tools exposed by a configured MCP server",
159
+ parameters: MCP_LIST_TOOLS_PARAMS,
160
+ async execute(_toolCallId, params, signal) {
161
+ try {
162
+ const result = await manager.listTools(params.server, {
163
+ timeoutMs: params.timeoutMs,
164
+ signal,
165
+ });
166
+ return {
167
+ content: [
168
+ {
169
+ type: "text",
170
+ text: formatJsonResult(`MCP tools ${params.server}`, result),
171
+ },
172
+ ],
173
+ details: {
174
+ server: params.server,
175
+ result,
176
+ },
177
+ };
178
+ } catch (error) {
179
+ return {
180
+ isError: true,
181
+ content: [
182
+ {
183
+ type: "text",
184
+ text: `Failed to list MCP tools for ${params.server}: ${formatError(error)}`,
185
+ },
186
+ ],
187
+ details: {
188
+ server: params.server,
189
+ error: formatError(error),
190
+ },
191
+ };
192
+ }
193
+ },
194
+ });
195
+
196
+ return bridge;
197
+ }
198
+
199
+ function parseCommandArgs(input: string): { server: string; method: string; params?: unknown } | undefined {
200
+ const trimmed = input.trim();
201
+ if (!trimmed) {
202
+ return undefined;
203
+ }
204
+
205
+ const firstSpace = trimmed.indexOf(" ");
206
+ if (firstSpace === -1) {
207
+ return undefined;
208
+ }
209
+ const server = trimmed.slice(0, firstSpace).trim();
210
+ const rest = trimmed.slice(firstSpace + 1).trim();
211
+ if (!server || !rest) {
212
+ return undefined;
213
+ }
214
+
215
+ const secondSpace = rest.indexOf(" ");
216
+ if (secondSpace === -1) {
217
+ return {
218
+ server,
219
+ method: rest,
220
+ };
221
+ }
222
+
223
+ const method = rest.slice(0, secondSpace).trim();
224
+ const paramsRaw = rest.slice(secondSpace + 1).trim();
225
+ if (!method) {
226
+ return undefined;
227
+ }
228
+
229
+ if (!paramsRaw) {
230
+ return { server, method };
231
+ }
232
+
233
+ try {
234
+ return {
235
+ server,
236
+ method,
237
+ params: JSON.parse(paramsRaw),
238
+ };
239
+ } catch {
240
+ return {
241
+ server,
242
+ method,
243
+ params: paramsRaw,
244
+ };
245
+ }
246
+ }
247
+
248
+ function formatJsonResult(prefix: string, payload: unknown): string {
249
+ return `${prefix}:\n${JSON.stringify(payload, null, 2)}`;
250
+ }
251
+
252
+ function formatError(error: unknown): string {
253
+ if (error instanceof Error) {
254
+ return error.message;
255
+ }
256
+ return String(error);
257
+ }