@pi-unipi/mcp 2.4.1 → 2.5.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/README.md CHANGED
@@ -12,6 +12,7 @@ The add command opens a split-pane overlay: server browser on the left, JSON con
12
12
  | `/unipi:mcp-settings` | Interactive settings with enable/disable/edit |
13
13
  | `/unipi:mcp-sync` | Force sync server catalog from GitHub |
14
14
  | `/unipi:mcp-status` | Text summary of all configured servers |
15
+ | `/unipi:mcp-reload` | Remind you to restart Pi so tool schemas reload as a clean cache epoch |
15
16
 
16
17
  ### Setup Flow
17
18
 
@@ -30,6 +31,14 @@ MCP registers with the info-screen dashboard, showing server count, active serve
30
31
 
31
32
  MCP tools are registered dynamically based on configured servers. Once a server is added and Pi restarts, its tools become available to the agent.
32
33
 
34
+ ### Deterministic Definitions and Cache Behavior
35
+
36
+ At session startup, enabled servers connect and discover tools in parallel. Registration waits for all discoveries to settle, then registers the successful combined tool set in canonical `{serverName}__{toolName}` order. Duplicate final names are rejected explicitly instead of allowing one definition to overwrite another.
37
+
38
+ MCP input properties are cloned and recursively canonicalized before registration: schema object keys use locale-independent UTF-16 code-unit order, valid schema `required` string arrays are sorted and deduplicated, a missing top-level `required` becomes `[]`, and literal-value arrays keep their source order. Each tool also receives a stable label matching its final Pi name. These stable definitions and registration order prevent equivalent MCP configurations from changing the serialized tool list between runs, improving provider prompt-cache reuse. A server that fails discovery is excluded from the combined set; a registration error fails startup for that prepared set and is not reported as successful.
39
+
40
+ Pi 0.80 cannot remove dynamically registered tools. Enabling, disabling, deleting, or changing MCP servers is therefore applied on the next Pi restart rather than mutating the tool list mid-session. This prevents stale schemas and makes the restart an explicit cache-epoch boundary.
41
+
33
42
  Example tool calls:
34
43
  ```
35
44
  github__search_code({ query: "authentication middleware" })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,8 +26,11 @@
26
26
  "skills/**/*",
27
27
  "README.md"
28
28
  ],
29
+ "scripts": {
30
+ "test": "npx tsx --test tests/**/*.test.ts"
31
+ },
29
32
  "dependencies": {
30
- "@pi-unipi/core": "2.4.1"
33
+ "@pi-unipi/core": "2.5.0"
31
34
  },
32
35
  "peerDependencies": {
33
36
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -2,19 +2,21 @@
2
2
  * @pi-unipi/mcp — Server registry
3
3
  *
4
4
  * Manages MCP server lifecycle: start, stop, restart, status tracking.
5
- * Coordinates McpClient instances and tool registration with pi.
5
+ * Coordinates McpClient instances and deterministic tool registration with pi.
6
6
  */
7
7
 
8
8
  import { UNIPI_EVENTS, MCP_DEFAULTS } from "@pi-unipi/core";
9
9
  import type {
10
10
  ResolvedServer,
11
11
  ServerState,
12
- ServerStatus,
13
- McpTool,
14
12
  McpRegistryEntry,
15
13
  } from "../types.js";
16
14
  import { McpClient } from "./client.js";
17
- import { translateMcpTool, type PiExternalTool } from "./translator.js";
15
+ import {
16
+ compareCodeUnits,
17
+ translateMcpTool,
18
+ type PiExternalTool,
19
+ } from "./translator.js";
18
20
 
19
21
  /** Callback for emitting events */
20
22
  export type EventEmitFn = (
@@ -28,6 +30,12 @@ export type RegisterToolFn = (tool: PiExternalTool) => void;
28
30
  /** Callback for unregistering a tool with pi */
29
31
  export type UnregisterToolFn = (toolName: string) => void;
30
32
 
33
+ /** Minimal client surface used by the registry. */
34
+ export type RegistryClient = Pick<
35
+ McpClient,
36
+ "connect" | "disconnect" | "listTools" | "callTool" | "pid"
37
+ >;
38
+
31
39
  /** Options for ServerRegistry */
32
40
  export interface ServerRegistryOptions {
33
41
  /** Function to emit events via pi.events */
@@ -36,156 +44,273 @@ export interface ServerRegistryOptions {
36
44
  registerTool: RegisterToolFn;
37
45
  /** Function to unregister a tool from pi */
38
46
  unregisterTool: UnregisterToolFn;
47
+ /** Whether the host can actually remove dynamically registered tools. */
48
+ canUnregisterTools?: boolean;
39
49
  /** Per-server startup timeout in ms */
40
50
  timeoutMs?: number;
51
+ /** Client factory, primarily for tests. */
52
+ createClient?: () => RegistryClient;
41
53
  }
42
54
 
43
- /**
44
- * Server registry — tracks all MCP server connections and their tools.
45
- */
55
+ interface PreparedServer {
56
+ entry: McpRegistryEntry;
57
+ client: RegistryClient;
58
+ tools: PiExternalTool[];
59
+ }
60
+
61
+ /** Server registry — tracks all MCP server connections and their tools. */
46
62
  export class ServerRegistry {
47
63
  private entries = new Map<string, McpRegistryEntry>();
48
64
  private readonly emitEvent: EventEmitFn;
49
65
  private readonly registerTool: RegisterToolFn;
50
66
  private readonly unregisterTool: UnregisterToolFn;
67
+ private readonly canUnregisterTools: boolean;
51
68
  private readonly timeoutMs: number;
69
+ private readonly createClient: () => RegistryClient;
52
70
 
53
71
  constructor(options: ServerRegistryOptions) {
54
72
  this.emitEvent = options.emitEvent;
55
73
  this.registerTool = options.registerTool;
56
74
  this.unregisterTool = options.unregisterTool;
75
+ this.canUnregisterTools = options.canUnregisterTools ?? true;
57
76
  this.timeoutMs = options.timeoutMs ?? MCP_DEFAULTS.STARTUP_TIMEOUT_MS;
77
+ this.createClient = options.createClient ?? (() => new McpClient({ timeoutMs: this.timeoutMs }));
58
78
  }
59
79
 
60
80
  /**
61
- * Start an MCP server: spawn process, initialize, discover tools, register.
81
+ * Start one MCP server. Discovery completes before its tools are registered,
82
+ * and registration always follows final Pi tool-name order.
62
83
  */
63
84
  async startServer(resolved: ResolvedServer): Promise<void> {
64
- const { name, def } = resolved;
85
+ await this.startServers([resolved]);
86
+ const state = this.getServerState(resolved.name);
87
+ if (state?.status === "error") {
88
+ throw new Error(state.error ?? `MCP server "${resolved.name}" failed to start`);
89
+ }
90
+ }
65
91
 
66
- // Check max servers limit
67
- if (this.entries.size >= MCP_DEFAULTS.MAX_SERVERS) {
92
+ /**
93
+ * Start MCP servers behind a discovery barrier.
94
+ *
95
+ * Connections and tool discovery run in parallel. Once every server has
96
+ * either prepared or failed, tools from all successful servers are checked
97
+ * for duplicate final names, sorted, and only then registered with Pi.
98
+ * Individual connection failures remain represented by error registry
99
+ * entries and do not prevent tools from other servers being registered.
100
+ */
101
+ async startServers(resolvedServers: ResolvedServer[]): Promise<void> {
102
+ if (resolvedServers.length === 0) return;
103
+
104
+ const names = new Set<string>();
105
+ for (const { name } of resolvedServers) {
106
+ if (names.has(name)) {
107
+ throw new Error(`Duplicate MCP server name in startup batch: "${name}"`);
108
+ }
109
+ names.add(name);
110
+ }
111
+
112
+ const replacementCount = [...names].filter((name) => this.entries.has(name)).length;
113
+ if (this.entries.size - replacementCount + resolvedServers.length > MCP_DEFAULTS.MAX_SERVERS) {
68
114
  throw new Error(
69
115
  `Maximum number of MCP servers (${MCP_DEFAULTS.MAX_SERVERS}) reached. ` +
70
116
  `Stop a server before starting a new one.`,
71
117
  );
72
118
  }
73
119
 
74
- // Stop existing server with same name if running
75
- if (this.entries.has(name)) {
76
- await this.stopServer(name);
77
- }
120
+ // Existing instances must release their names before replacements prepare.
121
+ await Promise.all(
122
+ [...names]
123
+ .filter((name) => this.entries.has(name))
124
+ .map((name) => this.stopServer(name)),
125
+ );
78
126
 
79
- const state: ServerState = {
80
- name,
81
- status: "starting",
82
- toolCount: 0,
83
- startedAt: new Date().toISOString(),
84
- };
127
+ const settled = await Promise.allSettled(
128
+ resolvedServers.map((resolved) => this.prepareServer(resolved)),
129
+ );
130
+ const prepared = settled
131
+ .filter((result): result is PromiseFulfilledResult<PreparedServer> => result.status === "fulfilled")
132
+ .map((result) => result.value);
85
133
 
86
- const entry: McpRegistryEntry = {
87
- name,
88
- resolved,
89
- state,
90
- client: null,
91
- toolNames: [],
92
- };
134
+ if (prepared.length === 0) return;
93
135
 
94
- this.entries.set(name, entry);
136
+ const registrations = prepared
137
+ .flatMap(({ entry, tools }) => tools.map((tool) => ({ entry, tool })))
138
+ .sort((left, right) => compareCodeUnits(left.tool.name, right.tool.name));
95
139
 
96
140
  try {
97
- // Defensive: ensure server definition has correct types before passing
98
- // to the client. Catches rare serialization bugs where env/args become
99
- // strings instead of objects/arrays.
100
- const safeCommand = typeof def.command === "string" ? def.command : String(def.command);
101
- const safeArgs = Array.isArray(def.args) ? def.args : [];
102
- let safeEnv: Record<string, string> | undefined;
103
- if (def.env !== undefined && def.env !== null) {
104
- if (typeof def.env === "object" && !Array.isArray(def.env)) {
105
- safeEnv = {};
106
- for (const [k, v] of Object.entries(def.env)) {
107
- safeEnv[k] = typeof v === "string" ? v : String(v);
141
+ this.assertUniqueFinalToolNames(registrations.map(({ tool }) => tool.name));
142
+ } catch (error) {
143
+ await this.failPreparedServers(prepared, error);
144
+ throw error;
145
+ }
146
+
147
+ const registeredNames: string[] = [];
148
+ try {
149
+ for (const { tool } of registrations) {
150
+ this.registerTool(tool);
151
+ registeredNames.push(tool.name);
152
+ }
153
+ } catch (error) {
154
+ if (this.canUnregisterTools) {
155
+ for (const toolName of registeredNames.reverse()) {
156
+ try {
157
+ this.unregisterTool(toolName);
158
+ } catch {
159
+ // Preserve the original registration error.
108
160
  }
109
- } else {
110
- // Env config invalid — silently skip env vars.
161
+ }
162
+ await this.failPreparedServers(prepared, error);
163
+ } else {
164
+ // Pi 0.80 cannot roll back dynamic registration. Keep the successfully
165
+ // registered subset and its clients alive, and report the partial set
166
+ // truthfully instead of pretending it was removed.
167
+ const message = `${error instanceof Error ? error.message : String(error)}; ` +
168
+ "some MCP tools remain registered until Pi restarts";
169
+ for (const { entry, client, tools } of prepared) {
170
+ const toolNames = tools
171
+ .map((tool) => tool.name)
172
+ .filter((name) => registeredNames.includes(name))
173
+ .sort(compareCodeUnits);
174
+ entry.toolNames = toolNames;
175
+ entry.state = {
176
+ ...entry.state,
177
+ status: "error",
178
+ pid: client.pid,
179
+ toolCount: toolNames.length,
180
+ error: message,
181
+ };
182
+ this.emitEvent(UNIPI_EVENTS.MCP_SERVER_ERROR, {
183
+ name: entry.name,
184
+ error: message,
185
+ });
111
186
  }
112
187
  }
188
+ throw error;
189
+ }
113
190
 
114
- // Create and connect client
115
- const client = new McpClient({ timeoutMs: this.timeoutMs });
116
- await client.connect(safeCommand, safeArgs, safeEnv);
117
-
118
- entry.client = client;
119
-
120
- // Discover tools
121
- const mcpTools = await client.listTools();
122
-
123
- // Translate and register tools
124
- const toolNames: string[] = [];
125
- for (const mcpTool of mcpTools) {
126
- const piTool = translateMcpTool(mcpTool, name, client);
127
- this.registerTool(piTool);
128
- toolNames.push(piTool.name);
129
- }
130
-
131
- // Update state
191
+ // State and success events are published only after every registration
192
+ // succeeds. Server event order is deterministic as well.
193
+ for (const { entry, client, tools } of [...prepared].sort((left, right) =>
194
+ compareCodeUnits(left.entry.name, right.entry.name))) {
195
+ const toolNames = tools.map((tool) => tool.name).sort(compareCodeUnits);
196
+ entry.toolNames = toolNames;
132
197
  entry.state = {
133
- ...state,
198
+ ...entry.state,
134
199
  status: "running",
135
200
  pid: client.pid,
136
201
  toolCount: toolNames.length,
137
202
  };
138
- entry.toolNames = toolNames;
139
203
 
140
- // Emit events
141
204
  this.emitEvent(UNIPI_EVENTS.MCP_SERVER_STARTED, {
142
- name,
205
+ name: entry.name,
143
206
  toolCount: toolNames.length,
144
207
  });
145
-
146
208
  if (toolNames.length > 0) {
147
209
  this.emitEvent(UNIPI_EVENTS.MCP_TOOLS_REGISTERED, {
148
- serverName: name,
210
+ serverName: entry.name,
149
211
  toolNames,
150
212
  });
151
213
  }
152
- } catch (err) {
153
- const error =
154
- err instanceof Error ? err.message : String(err);
214
+ }
215
+ }
155
216
 
156
- entry.state = {
157
- ...state,
158
- status: "error",
159
- error,
160
- };
217
+ private async prepareServer(resolved: ResolvedServer): Promise<PreparedServer> {
218
+ const { name, def } = resolved;
219
+ const state: ServerState = {
220
+ name,
221
+ status: "starting",
222
+ toolCount: 0,
223
+ startedAt: new Date().toISOString(),
224
+ };
225
+ const entry: McpRegistryEntry = {
226
+ name,
227
+ resolved,
228
+ state,
229
+ client: null,
230
+ toolNames: [],
231
+ };
232
+ this.entries.set(name, entry);
233
+
234
+ let client: RegistryClient | null = null;
235
+ try {
236
+ const safeCommand = typeof def.command === "string" ? def.command : String(def.command);
237
+ const safeArgs = Array.isArray(def.args) ? def.args : [];
238
+ let safeEnv: Record<string, string> | undefined;
239
+ if (def.env !== undefined && def.env !== null && typeof def.env === "object" && !Array.isArray(def.env)) {
240
+ safeEnv = {};
241
+ for (const [key, value] of Object.entries(def.env)) {
242
+ safeEnv[key] = typeof value === "string" ? value : String(value);
243
+ }
244
+ }
161
245
 
162
- // Clean up client if partially connected
163
- if (entry.client) {
246
+ client = this.createClient();
247
+ const connectedClient = client;
248
+ await connectedClient.connect(safeCommand, safeArgs, safeEnv);
249
+ entry.client = connectedClient;
250
+
251
+ const mcpTools = await connectedClient.listTools();
252
+ const tools = mcpTools
253
+ .map((tool) => translateMcpTool(tool, name, connectedClient))
254
+ .sort((left, right) => compareCodeUnits(left.name, right.name));
255
+ return { entry, client: connectedClient, tools };
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ entry.state = { ...state, status: "error", error: message };
259
+ if (client) {
164
260
  try {
165
- await (entry.client as McpClient).disconnect();
261
+ await client.disconnect();
166
262
  } catch {
167
- // Ignore cleanup errors
263
+ // Ignore cleanup errors.
168
264
  }
169
- entry.client = null;
170
265
  }
266
+ entry.client = null;
267
+ this.emitEvent(UNIPI_EVENTS.MCP_SERVER_ERROR, { name, error: message });
268
+ throw error;
269
+ }
270
+ }
171
271
 
172
- this.emitEvent(UNIPI_EVENTS.MCP_SERVER_ERROR, {
173
- name,
174
- error,
175
- });
176
-
177
- throw err;
272
+ private assertUniqueFinalToolNames(newNames: string[]): void {
273
+ const existingNames = this.getActive().flatMap((state) =>
274
+ this.entries.get(state.name)?.toolNames ?? []);
275
+ const seen = new Set<string>();
276
+ const duplicates = new Set<string>();
277
+ for (const name of [...existingNames, ...newNames]) {
278
+ if (seen.has(name)) duplicates.add(name);
279
+ seen.add(name);
280
+ }
281
+ if (duplicates.size > 0) {
282
+ throw new Error(
283
+ `Duplicate final MCP tool name(s): ${[...duplicates].sort(compareCodeUnits).join(", ")}`,
284
+ );
178
285
  }
179
286
  }
180
287
 
181
- /**
182
- * Stop an MCP server: unregister tools, disconnect client.
183
- */
288
+ private async failPreparedServers(prepared: PreparedServer[], error: unknown): Promise<void> {
289
+ const message = error instanceof Error ? error.message : String(error);
290
+ await Promise.all(prepared.map(async ({ entry, client }) => {
291
+ entry.state = { ...entry.state, status: "error", toolCount: 0, error: message };
292
+ entry.toolNames = [];
293
+ try {
294
+ await client.disconnect();
295
+ } catch {
296
+ // Ignore cleanup errors.
297
+ }
298
+ entry.client = null;
299
+ this.emitEvent(UNIPI_EVENTS.MCP_SERVER_ERROR, { name: entry.name, error: message });
300
+ }));
301
+ }
302
+
303
+ /** Stop an MCP server: unregister tools, disconnect client. */
184
304
  async stopServer(name: string): Promise<void> {
185
305
  const entry = this.entries.get(name);
186
306
  if (!entry) return;
187
307
 
188
- // Unregister tools
308
+ if (entry.toolNames.length > 0 && !this.canUnregisterTools) {
309
+ throw new Error(
310
+ "This Pi version cannot remove MCP tools at runtime; restart Pi to change the MCP tool set.",
311
+ );
312
+ }
313
+
189
314
  for (const toolName of entry.toolNames) {
190
315
  this.unregisterTool(toolName);
191
316
  }
@@ -197,101 +322,83 @@ export class ServerRegistry {
197
322
  });
198
323
  }
199
324
 
200
- // Disconnect client
201
325
  if (entry.client) {
202
326
  try {
203
- await (entry.client as McpClient).disconnect();
327
+ await (entry.client as RegistryClient).disconnect();
204
328
  } catch {
205
- // Ignore disconnect errors
329
+ // Ignore disconnect errors.
206
330
  }
207
331
  entry.client = null;
208
332
  }
209
333
 
210
- // Update state
211
- entry.state = {
212
- ...entry.state,
213
- status: "stopped",
214
- toolCount: 0,
215
- };
334
+ entry.state = { ...entry.state, status: "stopped", toolCount: 0 };
216
335
  entry.toolNames = [];
217
-
218
336
  this.emitEvent(UNIPI_EVENTS.MCP_SERVER_STOPPED, { name });
219
337
  }
220
338
 
221
- /**
222
- * Restart an MCP server: stop then start.
223
- */
339
+ /** Restart an MCP server: stop then start. */
224
340
  async restartServer(name: string): Promise<void> {
225
341
  const entry = this.entries.get(name);
226
- if (!entry) {
227
- throw new Error(`Server '${name}' not found in registry`);
228
- }
229
-
342
+ if (!entry) throw new Error(`Server '${name}' not found in registry`);
230
343
  const resolved = entry.resolved;
231
344
  await this.stopServer(name);
232
345
  await this.startServer(resolved);
233
346
  }
234
347
 
235
- /**
236
- * Stop all running servers.
237
- */
348
+ /** Stop all running servers when runtime unregistration is supported. */
238
349
  async stopAll(): Promise<void> {
239
350
  const names = Array.from(this.entries.keys());
240
351
  await Promise.allSettled(names.map((name) => this.stopServer(name)));
241
352
  }
242
353
 
243
- /**
244
- * Get all registered server states.
245
- */
354
+ /** Disconnect clients during extension shutdown without claiming tools were removed. */
355
+ async disconnectAll(): Promise<void> {
356
+ await Promise.allSettled([...this.entries.values()].map(async (entry) => {
357
+ if (!entry.client) return;
358
+ try {
359
+ await (entry.client as RegistryClient).disconnect();
360
+ } finally {
361
+ entry.client = null;
362
+ }
363
+ }));
364
+ }
365
+
366
+ /** Get all registered server states. */
246
367
  getAll(): ServerState[] {
247
- return Array.from(this.entries.values()).map((e) => e.state);
368
+ return Array.from(this.entries.values()).map((entry) => entry.state);
248
369
  }
249
370
 
250
- /**
251
- * Get states of running servers.
252
- */
371
+ /** Get states of running servers. */
253
372
  getActive(): ServerState[] {
254
- return this.getAll().filter((s) => s.status === "running");
373
+ return this.getAll().filter((state) => state.status === "running");
255
374
  }
256
375
 
257
- /**
258
- * Get states of servers in error state.
259
- */
376
+ /** Get states of servers in error state. */
260
377
  getFailed(): ServerState[] {
261
- return this.getAll().filter((s) => s.status === "error");
378
+ return this.getAll().filter((state) => state.status === "error");
262
379
  }
263
380
 
264
- /**
265
- * Get total number of tools across all active servers.
266
- */
381
+ /** Get total number of tools across all active servers. */
267
382
  getTotalToolCount(): number {
268
- return this.getActive().reduce((sum, s) => sum + s.toolCount, 0);
383
+ return this.getActive().reduce((sum, state) => sum + state.toolCount, 0);
269
384
  }
270
385
 
271
- /**
272
- * Get the state of a specific server.
273
- */
386
+ /** Get the state of a specific server. */
274
387
  getServerState(name: string): ServerState | null {
275
388
  return this.entries.get(name)?.state ?? null;
276
389
  }
277
390
 
278
- /**
279
- * Get the full registry entry for a server.
280
- */
391
+ /** Get the full registry entry for a server. */
281
392
  getEntry(name: string): McpRegistryEntry | null {
282
393
  return this.entries.get(name) ?? null;
283
394
  }
284
395
 
285
- /**
286
- * Check if a server exists in the registry.
287
- */
396
+ /** Check if a server exists in the registry. */
288
397
  hasServer(name: string): boolean {
289
398
  return this.entries.has(name);
290
399
  }
291
400
 
292
- /**
293
- * Get the number of registered servers.
294
- */
401
+ /** Get the number of registered servers. */
295
402
  get size(): number {
296
403
  return this.entries.size;
297
404
  }
@@ -9,11 +9,15 @@ import { MCP_DEFAULTS } from "@pi-unipi/core";
9
9
  import type { McpTool, McpToolResult } from "../types.js";
10
10
  import type { McpClient } from "./client.js";
11
11
 
12
- /** Pi-compatible tool parameter schema */
12
+ /** Client operation needed by translated tools. */
13
+ export type ToolCallClient = Pick<McpClient, "callTool">;
14
+
15
+ /** JSON object used as a Pi-compatible tool parameter schema. */
13
16
  interface ToolParameters {
17
+ [key: string]: unknown;
14
18
  type: "object";
15
19
  properties: Record<string, unknown>;
16
- required?: string[];
20
+ required: unknown;
17
21
  }
18
22
 
19
23
  /** Content block returned by a pi tool */
@@ -31,6 +35,7 @@ interface PiToolResult {
31
35
  /** Pi-compatible external tool */
32
36
  export interface PiExternalTool {
33
37
  name: string;
38
+ label: string;
34
39
  description: string;
35
40
  parameters: ToolParameters;
36
41
  execute: (
@@ -41,6 +46,66 @@ export interface PiExternalTool {
41
46
  ) => Promise<PiToolResult>;
42
47
  }
43
48
 
49
+ /**
50
+ * Compare strings by JavaScript/Unicode UTF-16 code units.
51
+ *
52
+ * Unlike localeCompare(), this ordering does not depend on the host locale.
53
+ */
54
+ export function compareCodeUnits(left: string, right: string): number {
55
+ return left < right ? -1 : left > right ? 1 : 0;
56
+ }
57
+
58
+ function isObject(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ const LITERAL_VALUE_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
63
+
64
+ function canonicalizeValue(
65
+ value: unknown,
66
+ key?: string,
67
+ normalizeSchemaKeywords = true,
68
+ ): unknown {
69
+ if (Array.isArray(value)) {
70
+ if (
71
+ normalizeSchemaKeywords &&
72
+ key === "required" &&
73
+ value.every((item) => typeof item === "string")
74
+ ) {
75
+ return [...new Set(value as string[])].sort(compareCodeUnits);
76
+ }
77
+ return value.map((item) => canonicalizeValue(item, undefined, normalizeSchemaKeywords));
78
+ }
79
+
80
+ if (!isObject(value)) return value;
81
+
82
+ const canonical: Record<string, unknown> = {};
83
+ for (const objectKey of Object.keys(value).sort(compareCodeUnits)) {
84
+ // Values under these JSON Schema keywords are literal application data,
85
+ // not nested schemas. A property named `required` inside that data must
86
+ // retain array order (for example under `const`).
87
+ const childNormalizesSchemaKeywords =
88
+ normalizeSchemaKeywords && !LITERAL_VALUE_KEYWORDS.has(objectKey);
89
+ canonical[objectKey] = canonicalizeValue(
90
+ value[objectKey],
91
+ objectKey,
92
+ childNormalizesSchemaKeywords,
93
+ );
94
+ }
95
+ return canonical;
96
+ }
97
+
98
+ /**
99
+ * Recursively clone and canonicalize a JSON Schema value.
100
+ *
101
+ * Object keys use locale-independent code-unit order. Arrays retain their
102
+ * original order, except valid `required` arrays (arrays containing only
103
+ * strings), which are sorted and deduplicated.
104
+ */
105
+ export function canonicalizeJsonSchema(schema: unknown): unknown {
106
+ return canonicalizeValue(schema);
107
+ }
108
+
44
109
  /**
45
110
  * Translate an MCP tool definition to a pi-compatible external tool.
46
111
  *
@@ -52,19 +117,21 @@ export interface PiExternalTool {
52
117
  export function translateMcpTool(
53
118
  mcpTool: McpTool,
54
119
  serverName: string,
55
- client: McpClient,
120
+ client: ToolCallClient,
56
121
  ): PiExternalTool {
57
122
  const separator = MCP_DEFAULTS.TOOL_NAME_SEPARATOR;
58
123
  const toolName = `${serverName}${separator}${mcpTool.name}`;
59
124
 
60
- // Ensure inputSchema is a valid JSON Schema object
61
- const inputSchema = mcpTool.inputSchema ?? {};
62
- const parameters: ToolParameters = {
125
+ // Preserve the existing Pi-facing top-level shape while cloning and
126
+ // canonicalizing all nested property schemas. Forwarding additional MCP
127
+ // top-level keywords is a separate provider-compatibility decision.
128
+ const inputSchema = isObject(mcpTool.inputSchema) ? mcpTool.inputSchema : {};
129
+ const normalizedSchema: Record<string, unknown> = {
63
130
  type: "object",
64
- properties:
65
- (inputSchema.properties as Record<string, unknown>) ?? {},
66
- required: inputSchema.required as string[] | undefined,
131
+ properties: isObject(inputSchema.properties) ? inputSchema.properties : {},
132
+ required: Array.isArray(inputSchema.required) ? inputSchema.required : [],
67
133
  };
134
+ const parameters = canonicalizeJsonSchema(normalizedSchema) as ToolParameters;
68
135
 
69
136
  const description = [
70
137
  mcpTool.description || `MCP tool: ${mcpTool.name}`,
@@ -137,6 +204,7 @@ export function translateMcpTool(
137
204
 
138
205
  return {
139
206
  name: toolName,
207
+ label: toolName,
140
208
  description,
141
209
  parameters,
142
210
  execute,
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import type { ResolvedServer } from "./types.js";
18
18
  import { loadAndResolve, getGlobalConfigDir } from "./config/manager.js";
19
19
  import { syncCatalog, loadCatalog } from "./config/sync.js";
20
20
  import { ServerRegistry } from "./bridge/registry.js";
21
+ import { compareCodeUnits } from "./bridge/translator.js";
21
22
  import { renderMcpAddOverlay } from "./tui/add-overlay.js";
22
23
  import { renderMcpSettingsOverlay } from "./tui/settings-overlay.js";
23
24
 
@@ -42,24 +43,32 @@ export default function (pi: ExtensionAPI) {
42
43
  // Session start — load configs, start servers
43
44
  pi.on("session_start", async (_event, ctx) => {
44
45
  // Create registry with pi integration callbacks
46
+ const toolApi = pi as ExtensionAPI & {
47
+ registerExternalTool?: (tool: unknown) => void;
48
+ unregisterTool?: (toolName: string) => void;
49
+ unregisterExternalTool?: (toolName: string) => void;
50
+ };
51
+ const registerTool = typeof toolApi.registerTool === "function"
52
+ ? (tool: unknown) => toolApi.registerTool(tool as Parameters<typeof toolApi.registerTool>[0])
53
+ : typeof toolApi.registerExternalTool === "function"
54
+ ? (tool: unknown) => toolApi.registerExternalTool!(tool)
55
+ : () => {
56
+ throw new Error("Pi does not expose a supported MCP tool registration API");
57
+ };
58
+ const canUnregisterTools =
59
+ typeof toolApi.unregisterTool === "function" ||
60
+ typeof toolApi.unregisterExternalTool === "function";
61
+ const unregisterTool = typeof toolApi.unregisterTool === "function"
62
+ ? (toolName: string) => toolApi.unregisterTool!(toolName)
63
+ : typeof toolApi.unregisterExternalTool === "function"
64
+ ? (toolName: string) => toolApi.unregisterExternalTool!(toolName)
65
+ : () => {};
66
+
45
67
  registry = new ServerRegistry({
46
68
  emitEvent: (event, payload) => emitEvent(pi, event, payload),
47
- registerTool: (tool) => {
48
- try {
49
- (pi as any).registerTool?.(tool) ??
50
- (pi as any).registerExternalTool?.(tool);
51
- } catch {
52
- // Tool registration may not be available in all contexts
53
- }
54
- },
55
- unregisterTool: (toolName) => {
56
- try {
57
- (pi as any).unregisterTool?.(toolName) ??
58
- (pi as any).unregisterExternalTool?.(toolName);
59
- } catch {
60
- // Ignore
61
- }
62
- },
69
+ registerTool,
70
+ unregisterTool,
71
+ canUnregisterTools,
63
72
  });
64
73
 
65
74
  // Load and resolve server configs
@@ -73,21 +82,13 @@ export default function (pi: ExtensionAPI) {
73
82
  // Config load failure — servers will be empty, visible via /unipi:mcp-status.
74
83
  }
75
84
 
76
- // Start enabled servers (parallel, non-blocking errors)
77
- const startPromises = servers
78
- .filter((s) => s.enabled)
79
- .map(async (server) => {
80
- try {
81
- await registry!.startServer(server);
82
- // Removed console.log — startup logs cause layout shift in TUI.
83
- // Server status visible via /unipi:mcp-status or info screen.
84
- } catch (err) {
85
- // Removed console.error — errors surfaced via info-screen MCP group.
86
- // Server failure tracked in registry state.
87
- }
88
- });
89
-
90
- await Promise.allSettled(startPromises);
85
+ // Connect/discover in parallel, then register the successful combined set
86
+ // after a barrier so tool order is stable across runs.
87
+ try {
88
+ await registry.startServers(servers.filter((server) => server.enabled));
89
+ } catch (_err) {
90
+ // Errors are tracked in registry state and surfaced by the info screen.
91
+ }
91
92
 
92
93
  // Register info-screen group
93
94
  const infoRegistry = getInfoRegistry();
@@ -153,16 +154,17 @@ export default function (pi: ExtensionAPI) {
153
154
  `unipi:${MCP_COMMANDS.STATUS}`,
154
155
  `unipi:${MCP_COMMANDS.RELOAD}`,
155
156
  ],
156
- tools: activeServers.flatMap((s) =>
157
- registry?.getEntry(s.name)?.toolNames ?? [],
158
- ),
157
+ tools: activeServers
158
+ .flatMap((server) => registry?.getEntry(server.name)?.toolNames ?? [])
159
+ .sort(compareCodeUnits),
159
160
  });
160
161
  });
161
162
 
162
- // Session shutdown stop all servers
163
+ // Session shutdown tears down clients. Pi tears down this extension's tool
164
+ // registry itself, so do not claim per-tool unregistration here.
163
165
  pi.on("session_shutdown", async (_event, _ctx) => {
164
166
  if (registry) {
165
- await registry.stopAll();
167
+ await registry.disconnectAll();
166
168
  registry = null;
167
169
  }
168
170
  });
@@ -305,38 +307,12 @@ export default function (pi: ExtensionAPI) {
305
307
 
306
308
  // /unipi:mcp-reload — restart all MCP servers
307
309
  pi.registerCommand(`unipi:${MCP_COMMANDS.RELOAD}`, {
308
- description: "Reload all MCP servers (restart with current config)",
310
+ description: "Explain how to reload MCP servers safely",
309
311
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
310
- const reg = getRegistry();
311
- if (!reg) {
312
- ctx.ui.notify("MCP extension not initialized", "warning");
313
- return;
314
- }
315
-
316
- const all = reg.getAll();
317
- if (all.length === 0) {
318
- ctx.ui.notify("No MCP servers configured. Use /unipi:mcp-add to add one.", "info");
319
- return;
320
- }
321
-
322
- ctx.ui.notify(`Reloading ${all.length} MCP server(s)...`, "info");
323
-
324
- let restarted = 0;
325
- let failed = 0;
326
- for (const state of all) {
327
- try {
328
- await reg.restartServer(state.name);
329
- restarted++;
330
- } catch (_err) {
331
- failed++;
332
- // Silently ignore — restart failure tracked in failed count.
333
- }
334
- }
335
-
336
- const msg = failed > 0
337
- ? `Reloaded: ${restarted} ok, ${failed} failed`
338
- : `Reloaded ${restarted} MCP server(s) successfully`;
339
- ctx.ui.notify(msg, failed > 0 ? "warning" : "info");
312
+ // Pi 0.80 does not expose dynamic tool removal. Restarting in place can
313
+ // leave stale schemas in the provider-visible tool list, so require a
314
+ // process/extension restart to establish a clean cache epoch.
315
+ ctx.ui.notify("Restart Pi to reload MCP servers and tool schemas safely.", "info");
340
316
  },
341
317
  });
342
318
  }
@@ -141,15 +141,9 @@ export function renderMcpSettingsOverlay(params?: {
141
141
  };
142
142
  saveMetadata(configDir, meta);
143
143
 
144
- // Try to stop if disabling
145
- if (!newEnabled && registry) {
146
- try {
147
- await registry.stopServer(server.name);
148
- } catch {
149
- // Ignore stop errors
150
- }
151
- }
152
-
144
+ // Pi 0.80 cannot remove a registered tool definition at runtime.
145
+ // Persist the setting now; the next Pi restart applies the new set as
146
+ // one deterministic cache epoch.
153
147
  refreshServers();
154
148
  refresh();
155
149
  } catch (err) {