pi-mcp-adapter 3.2.0 → 4.0.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/OAUTH.md +4 -2
  3. package/README.md +72 -10
  4. package/cli.js +1 -0
  5. package/commands.ts +20 -2
  6. package/config.ts +19 -1
  7. package/direct-tool-surface.ts +3 -2
  8. package/direct-tools.ts +12 -2
  9. package/dist/config.d.ts +6 -0
  10. package/dist/config.js +15 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/elicitation-handler.d.ts +9 -1
  13. package/dist/elicitation-handler.js +65 -2
  14. package/dist/elicitation-handler.js.map +1 -1
  15. package/dist/jev-client.js +7 -5
  16. package/dist/jev-client.js.map +1 -1
  17. package/dist/jev-key-store.d.ts +1 -0
  18. package/dist/jev-key-store.js +11 -0
  19. package/dist/jev-key-store.js.map +1 -1
  20. package/dist/mcp-auth-flow.d.ts +2 -0
  21. package/dist/mcp-auth-flow.js +12 -3
  22. package/dist/mcp-auth-flow.js.map +1 -1
  23. package/dist/metadata-cache.d.ts +12 -0
  24. package/dist/metadata-cache.js +50 -6
  25. package/dist/metadata-cache.js.map +1 -1
  26. package/dist/output-shape.d.ts +36 -0
  27. package/dist/output-shape.js +338 -0
  28. package/dist/output-shape.js.map +1 -0
  29. package/dist/package-mcp-loader.js +9 -5
  30. package/dist/package-mcp-loader.js.map +1 -1
  31. package/dist/server-manager.js +27 -16
  32. package/dist/server-manager.js.map +1 -1
  33. package/dist/state.d.ts +6 -0
  34. package/dist/ts-shape.d.ts +3 -0
  35. package/dist/ts-shape.js +153 -0
  36. package/dist/ts-shape.js.map +1 -0
  37. package/dist/types.d.ts +24 -3
  38. package/dist/types.js.map +1 -1
  39. package/dist/unix-socket-transport.js +1 -1
  40. package/dist/unix-socket-transport.js.map +1 -1
  41. package/dist/utils.d.ts +6 -1
  42. package/dist/utils.js +17 -0
  43. package/dist/utils.js.map +1 -1
  44. package/elicitation-handler.ts +78 -1
  45. package/index.ts +72 -12
  46. package/init.ts +20 -5
  47. package/jev-client.ts +7 -4
  48. package/jev-key-store.ts +7 -0
  49. package/mcp-auth-flow.ts +13 -2
  50. package/mcp-code.ts +126 -68
  51. package/mcp-output-guard.ts +24 -5
  52. package/mcp-script-worker.mjs +6 -2
  53. package/mcp-setup-panel.ts +583 -302
  54. package/metadata-cache.ts +60 -5
  55. package/output-shape.ts +348 -0
  56. package/package-mcp-loader.ts +8 -5
  57. package/package.json +6 -6
  58. package/prompts.ts +68 -0
  59. package/proxy-modes.ts +149 -106
  60. package/server-manager.ts +28 -15
  61. package/skills/mcp-scripting/SKILL.md +24 -28
  62. package/skills/mcp-scripting/references/jev.md +3 -0
  63. package/state.ts +6 -0
  64. package/tool-approval.ts +17 -4
  65. package/tool-metadata.ts +3 -1
  66. package/ts-shape.ts +1 -1
  67. package/types.ts +22 -3
  68. package/ui-server.ts +5 -2
  69. package/unix-socket-transport.ts +1 -1
  70. package/utils.ts +16 -1
package/metadata-cache.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  import { createToolSelectorCandidateIndex, formatPromptCommandName, formatToolName, getToolNameCandidates, isServerDisabled, isToolAllowed, resolveToolPrefix, resolveUniqueNameOwnership, type ToolPrefix, type ToolSelectorCandidateIndex } from "./types.ts";
24
24
  import { resourceNameToToolName } from "./resource-tools.ts";
25
25
  import {
26
+ extractToolAnnotations,
26
27
  extractToolUiStreamMode,
27
28
  interpolateEnvRecord,
28
29
  interpolateEnvVars,
@@ -57,27 +58,32 @@ export function loadMetadataCache(): MetadataCache | null {
57
58
  }
58
59
 
59
60
  export function saveMetadataCache(cache: MetadataCache): void {
61
+ updateMetadataCacheFile(servers => ({ ...servers, ...cache.servers }));
62
+ }
63
+
64
+ /** Reads the cache file, applies one update, and writes the result; an update returning undefined skips the write. */
65
+ function updateMetadataCacheFile(update: (servers: MetadataCache["servers"]) => MetadataCache["servers"] | undefined): void {
60
66
  const cachePath = getMetadataCachePath();
61
67
  const dir = dirname(cachePath);
62
68
  mkdirSync(dir, { recursive: true });
63
69
 
64
- let merged: MetadataCache = { version: CACHE_VERSION, servers: {} };
70
+ let servers: MetadataCache["servers"] = {};
65
71
  try {
66
72
  if (existsSync(cachePath)) {
67
73
  const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as MetadataCache;
68
74
  if (existing && existing.version === CACHE_VERSION && existing.servers) {
69
- merged.servers = { ...existing.servers };
75
+ servers = { ...existing.servers };
70
76
  }
71
77
  }
72
78
  } catch {
73
79
  // Ignore parse errors and proceed with empty cache
74
80
  }
75
81
 
76
- merged.version = CACHE_VERSION;
77
- merged.servers = { ...merged.servers, ...cache.servers };
82
+ const next = update(servers);
83
+ if (!next) return;
78
84
 
79
85
  const tmpPath = `${cachePath}.${process.pid}.tmp`;
80
- writeFileSync(tmpPath, JSON.stringify(merged), "utf-8");
86
+ writeFileSync(tmpPath, JSON.stringify({ version: CACHE_VERSION, servers: next }), "utf-8");
81
87
  renameSync(tmpPath, cachePath);
82
88
  }
83
89
 
@@ -116,6 +122,49 @@ export function computeServerHash(definition: ServerEntry, environment: NodeJS.P
116
122
  return createHash("sha256").update(normalized).digest("hex");
117
123
  }
118
124
 
125
+ /**
126
+ * Identifies the tool definition an output shape was learned against. A shape is only used or saved while
127
+ * the tool's description and input schema still match, since a change there can mean a different result.
128
+ */
129
+ export function outputShapeKey(tool: Pick<CachedTool, "description" | "inputSchema">): string {
130
+ return stableStringify({ description: tool.description ?? "", inputSchema: tool.inputSchema ?? null });
131
+ }
132
+
133
+ /**
134
+ * Saves one tool's observed output shape into its server's cache entry, if the entry matches the running config
135
+ * and tool. The entry comes from the same read the write replaces, so newer metadata from other Pi processes is kept.
136
+ */
137
+ export function saveObservedOutput(
138
+ serverName: string,
139
+ definition: ServerEntry,
140
+ toolName: string,
141
+ toolKey: string,
142
+ output: NonNullable<ServerCacheEntry["outputShapes"]>[string],
143
+ ): void {
144
+ const configHash = computeServerHash(definition);
145
+ updateMetadataCacheFile(servers => {
146
+ const entry = servers[serverName];
147
+ const cachedTool = entry?.tools?.find(tool => tool.name === toolName);
148
+ if (!entry || !cachedTool || entry.configHash !== configHash || outputShapeKey(cachedTool) !== toolKey) return undefined;
149
+ return { ...servers, [serverName]: { ...entry, outputShapes: { ...entry.outputShapes, [toolName]: output } } };
150
+ });
151
+ }
152
+
153
+ /** Output shapes to carry into a rewritten cache entry: same config, not private, and only tools whose definition kept its shape key. */
154
+ export function keepOutputShapes(
155
+ previous: ServerCacheEntry | undefined,
156
+ configHash: string,
157
+ tools: CachedTool[],
158
+ ): ServerCacheEntry["outputShapes"] {
159
+ if (!previous?.outputShapes || previous.configHash !== configHash || previous.cacheScope === "private") return undefined;
160
+ const kept = Object.entries(previous.outputShapes).filter(([toolName]) => {
161
+ const before = previous.tools?.find(tool => tool.name === toolName);
162
+ const after = tools.find(tool => tool.name === toolName);
163
+ return before !== undefined && after !== undefined && outputShapeKey(before) === outputShapeKey(after);
164
+ });
165
+ return kept.length > 0 ? Object.fromEntries(kept) : undefined;
166
+ }
167
+
119
168
  export function isServerCacheValid(
120
169
  entry: ServerCacheEntry,
121
170
  definition: ServerEntry,
@@ -130,6 +179,8 @@ export function isServerCacheValid(
130
179
  }
131
180
  if (!entry || entry.configHash !== configHash) return false;
132
181
  if (!entry.cachedAt || typeof entry.cachedAt !== "number") return false;
182
+ // The persistent cache is not partitioned by authorization context.
183
+ if (entry.cacheScope === "private") return false;
133
184
  const declaredTtlMs = entry.ttlMs;
134
185
  if (typeof declaredTtlMs === "number" && Number.isSafeInteger(declaredTtlMs) && declaredTtlMs >= 0) {
135
186
  if (declaredTtlMs === 0) return false;
@@ -225,6 +276,7 @@ export function reconstructToolMetadata(
225
276
  }
226
277
 
227
278
  const name = formatToolName(tool.name, serverName, effectivePrefix);
279
+ const annotations = extractToolAnnotations(tool.annotations);
228
280
  metadata.push({
229
281
  name,
230
282
  originalName: tool.name,
@@ -234,6 +286,7 @@ export function reconstructToolMetadata(
234
286
  ...(tool.uiResourceUri !== undefined ? { uiResourceUri: tool.uiResourceUri } : {}),
235
287
  ...(tool.uiVisibility !== undefined ? { uiVisibility: tool.uiVisibility } : {}),
236
288
  ...(tool.uiStreamMode !== undefined ? { uiStreamMode: tool.uiStreamMode } : {}),
289
+ ...(annotations !== undefined ? { annotations } : {}),
237
290
  });
238
291
  }
239
292
 
@@ -289,6 +342,7 @@ export function serializeTools(tools: McpTool[]): CachedTool[] {
289
342
  const uiResourceUri = tryGetToolUiResourceUri(t);
290
343
  const uiVisibility = extractUiToolVisibility(t._meta);
291
344
  const uiStreamMode = extractToolUiStreamMode(t._meta);
345
+ const annotations = extractToolAnnotations(t.annotations);
292
346
  return {
293
347
  name: t.name,
294
348
  ...(t.description !== undefined ? { description: t.description } : {}),
@@ -297,6 +351,7 @@ export function serializeTools(tools: McpTool[]): CachedTool[] {
297
351
  ...(uiResourceUri !== undefined ? { uiResourceUri } : {}),
298
352
  ...(uiVisibility !== undefined ? { uiVisibility } : {}),
299
353
  ...(uiStreamMode !== undefined ? { uiStreamMode } : {}),
354
+ ...(annotations !== undefined ? { annotations } : {}),
300
355
  };
301
356
  });
302
357
  }
@@ -0,0 +1,348 @@
1
+ import { logger } from "./logger.ts";
2
+ import { outputShapeKey, saveObservedOutput } from "./metadata-cache.ts";
3
+ import type { McpExtensionState } from "./state.ts";
4
+ import { formatPropertyName } from "./ts-shape.ts";
5
+ import type { ServerCacheEntry, ServerDefinition, ToolMetadata } from "./types.ts";
6
+
7
+ /**
8
+ * Output shapes for tools that declare no outputSchema.
9
+ * Field names and broad JSON types only, never values; every dimension is
10
+ * bounded because inference runs on arbitrary upstream payloads after each call.
11
+ * Script sessions also save them in the metadata cache for later sessions.
12
+ */
13
+ export interface OutputShape {
14
+ type?: "null" | "boolean" | "number" | "string" | "object" | "array";
15
+ properties?: Record<string, OutputShape>;
16
+ required?: string[];
17
+ items?: OutputShape;
18
+ additionalProperties?: OutputShape;
19
+ anyOf?: OutputShape[];
20
+ }
21
+
22
+ export interface ObservedOutput {
23
+ source: "structuredContent" | "jsonText";
24
+ shape: OutputShape;
25
+ /** outputShapeKey of the tool definition the shape was learned against; other definitions do not see it. */
26
+ toolKey: string;
27
+ /**
28
+ * True once any observation merged into the shape was made under a private tool listing, which is tied to one
29
+ * authorization context. Such a shape is used in this session only and never saved.
30
+ */
31
+ private: boolean;
32
+ }
33
+
34
+ const MAX_DEPTH = 6;
35
+ const MAX_ARRAY_SAMPLE = 5;
36
+ // Objects wider than this, or with any key that does not look like a field name, are treated as maps
37
+ // keyed by data (ids, emails, dates), so their keys are not kept.
38
+ const MAX_OBJECT_KEYS = 40;
39
+ const FIELD_NAME = /^[A-Za-z_$][A-Za-z0-9_$]{0,39}$/;
40
+ const MAX_UNION = 4;
41
+ const MAX_NODES_PER_CALL = 1000;
42
+ // Sizes count UTF-16 code units (string length), not bytes.
43
+ const MAX_SHAPE_CHARS = 4 * 1024;
44
+ const MAX_JSON_TEXT_CHARS = 256 * 1024;
45
+ // Object shapes at least this long that appear more than once are written once as a named type.
46
+ const MIN_ALIAS_CHARS = 80;
47
+ const SHAPE_TYPES = new Set(["null", "boolean", "number", "string", "object", "array"]);
48
+
49
+ /**
50
+ * Looks the tool up when the call starts, so its result is recorded against the definition it was
51
+ * requested under even if a tools/list_changed refresh replaces that definition mid-call.
52
+ */
53
+ export function observedOutputRecorder(state: McpExtensionState, serverName: string, toolName: string): (result: Record<string, unknown>) => void {
54
+ const tool = findTool(state, serverName, toolName);
55
+ const listingIsPrivate = () => state.manager.getConnection(serverName)?.toolListHints?.cacheScope === "private";
56
+ const privateAtStart = listingIsPrivate();
57
+ return result => recordObservedOutput(state, serverName, tool, privateAtStart || listingIsPrivate(), result);
58
+ }
59
+
60
+ function recordObservedOutput(
61
+ state: McpExtensionState,
62
+ serverName: string,
63
+ tool: ToolMetadata | undefined,
64
+ observedPrivately: boolean,
65
+ result: Record<string, unknown>,
66
+ ): void {
67
+ if (!tool || tool.outputSchema !== undefined) return;
68
+ const toolName = tool.originalName;
69
+ const observed = readResultValue(result);
70
+ if (!observed) return;
71
+ const definition = state.config.mcpServers[serverName];
72
+ if (!definition) return;
73
+ const shape = inferShape(observed.value, 0, { nodes: MAX_NODES_PER_CALL });
74
+ const byTool = observedOutputsFor(state, definition);
75
+ const toolKey = outputShapeKey(tool);
76
+ const previous = byTool.get(toolName);
77
+ const mergeable = previous?.source === observed.source && previous.toolKey === toolKey;
78
+ const next: ObservedOutput = {
79
+ source: observed.source,
80
+ shape: fitShape(mergeable ? mergeShapes(previous.shape, shape) : shape),
81
+ toolKey,
82
+ private: observedPrivately || (mergeable && previous.private),
83
+ };
84
+ byTool.set(toolName, next);
85
+ // Skip unchanged shapes to avoid repeated cache writes; sessions without mcpScript never write.
86
+ if (state.scriptTool === true && !next.private && JSON.stringify(next) !== JSON.stringify(previous)) {
87
+ try {
88
+ saveObservedOutput(serverName, definition, toolName, toolKey, { source: next.source, shape: next.shape });
89
+ } catch (error) {
90
+ // The shape stays in memory for this session; only later sessions miss it.
91
+ logger.debug(`MCP: failed to save output shape for ${serverName}/${toolName}: ${error instanceof Error ? error.message : String(error)}`);
92
+ }
93
+ }
94
+ }
95
+
96
+ /** Adds shapes from a cache entry, tagged with the entry's own tool definitions, unless this session already has one for that definition. */
97
+ export function seedObservedOutputs(state: McpExtensionState, serverName: string, entry: Pick<ServerCacheEntry, "tools" | "outputShapes">): void {
98
+ const definition = state.config.mcpServers[serverName];
99
+ const saved = entry.outputShapes;
100
+ if (!definition || typeof saved !== "object" || saved === null) return;
101
+ const byTool = observedOutputsFor(state, definition);
102
+ for (const [toolName, value] of Object.entries(saved)) {
103
+ const cachedTool = Array.isArray(entry.tools) ? entry.tools.find(tool => tool?.name === toolName) : undefined;
104
+ if (!cachedTool || typeof value !== "object" || value === null) continue;
105
+ const toolKey = outputShapeKey(cachedTool);
106
+ if (byTool.get(toolName)?.toolKey === toolKey) continue;
107
+ const { source, shape } = value as { source?: unknown; shape?: unknown };
108
+ if (source !== "structuredContent" && source !== "jsonText") continue;
109
+ const parsed = readSavedShape(shape, 0);
110
+ if (parsed && !isUnknown(parsed)) byTool.set(toolName, { source, shape: fitShape(parsed), toolKey, private: false });
111
+ }
112
+ }
113
+
114
+ export function getObservedOutput(
115
+ state: McpExtensionState,
116
+ serverName: string,
117
+ tool: Pick<ToolMetadata, "originalName" | "outputSchema" | "description" | "inputSchema">,
118
+ ): ObservedOutput | undefined {
119
+ if (tool.outputSchema !== undefined) return undefined;
120
+ const definition = state.config.mcpServers[serverName];
121
+ const observed = definition && state.observedOutputs?.get(definition)?.get(tool.originalName);
122
+ return observed && observed.toolKey === outputShapeKey(tool) && !isUnknown(observed.shape) ? observed : undefined;
123
+ }
124
+
125
+ export function renderOutputShape(shape: OutputShape): string {
126
+ // First pass: each object's full text, counted, so APIs that repeat a wide object (a user under
127
+ // user, assignee, and assignees) show its fields once instead of three times.
128
+ const plain = new Map<OutputShape, string>();
129
+ const uses = new Map<string, { count: number; field: string }>();
130
+ const once = renderShape(shape, "item", (node, field, text) => {
131
+ plain.set(node, text);
132
+ const use = uses.get(text);
133
+ if (use) use.count++;
134
+ else uses.set(text, { count: 1, field });
135
+ return text;
136
+ });
137
+ const names = new Map<string, string>();
138
+ const taken = new Set(["Record"]);
139
+ for (const [text, use] of uses) {
140
+ if (use.count < 2 || text.length < MIN_ALIAS_CHARS) continue;
141
+ const words = use.field.split(/[^A-Za-z0-9]+/).filter(Boolean).map(part => part[0]!.toUpperCase() + part.slice(1)).join("");
142
+ const base = /^[A-Za-z]/.test(words) ? words : `T${words}`;
143
+ let name = base;
144
+ for (let suffix = 2; taken.has(name); suffix++) name = `${base}${suffix}`;
145
+ taken.add(name);
146
+ names.set(text, name);
147
+ }
148
+ if (names.size === 0) return once;
149
+ const definitions = new Map<string, string>();
150
+ const main = renderShape(shape, "item", (node, _field, text) => {
151
+ const name = names.get(plain.get(node)!);
152
+ if (!name) return text;
153
+ if (!definitions.has(name)) definitions.set(name, text);
154
+ return name;
155
+ });
156
+ return [...[...definitions].map(([name, body]) => `type ${name} = ${body};`), main].join("\n");
157
+ }
158
+
159
+ function renderShape(
160
+ shape: OutputShape,
161
+ field: string,
162
+ onObject: (node: OutputShape, field: string, text: string) => string,
163
+ ): string {
164
+ if (shape.anyOf) return shape.anyOf.map(variant => renderShape(variant, field, onObject)).join(" | ");
165
+ switch (shape.type) {
166
+ case undefined:
167
+ return "unknown";
168
+ case "object": {
169
+ if (shape.additionalProperties) return `Record<string, ${renderShape(shape.additionalProperties, field, onObject)}>`;
170
+ const required = new Set(shape.required);
171
+ const properties = Object.entries(shape.properties ?? {})
172
+ .map(([name, property]) => `${formatPropertyName(name)}${required.has(name) ? "" : "?"}: ${renderShape(property, name, onObject)};`);
173
+ return onObject(shape, field, properties.length === 0 ? "{}" : `{ ${properties.join(" ")} }`);
174
+ }
175
+ case "array": {
176
+ if (!shape.items) return "unknown[]";
177
+ const item = renderShape(shape.items, field, onObject);
178
+ return shape.items.anyOf ? `(${item})[]` : `${item}[]`;
179
+ }
180
+ default:
181
+ return shape.type;
182
+ }
183
+ }
184
+
185
+ function observedOutputsFor(state: McpExtensionState, definition: ServerDefinition): Map<string, ObservedOutput> {
186
+ const observedOutputs = state.observedOutputs ??= new WeakMap();
187
+ let byTool = observedOutputs.get(definition);
188
+ if (!byTool) observedOutputs.set(definition, byTool = new Map());
189
+ return byTool;
190
+ }
191
+
192
+ /** Rebuilds a shape read from the cache file, or returns undefined when any part is not something inferShape writes. */
193
+ function readSavedShape(value: unknown, depth: number): OutputShape | undefined {
194
+ if (typeof value !== "object" || value === null || Array.isArray(value) || depth > MAX_DEPTH) return undefined;
195
+ const raw = value as Record<string, unknown>;
196
+ if (raw.anyOf !== undefined) {
197
+ // Inference writes flat unions only; rejecting nested ones first keeps recursion bounded by MAX_DEPTH.
198
+ if (!Array.isArray(raw.anyOf) || raw.anyOf.length < 2 || raw.anyOf.length > MAX_UNION
199
+ || raw.anyOf.some(variant => typeof variant !== "object" || variant === null || "anyOf" in variant)) return undefined;
200
+ const variants = raw.anyOf.map(variant => readSavedShape(variant, depth));
201
+ return variants.every(variant => variant?.type !== undefined) ? { anyOf: variants as OutputShape[] } : undefined;
202
+ }
203
+ if (raw.type === undefined) return {};
204
+ if (typeof raw.type !== "string" || !SHAPE_TYPES.has(raw.type)) return undefined;
205
+ const shape: OutputShape = { type: raw.type as NonNullable<OutputShape["type"]> };
206
+ for (const key of ["items", "additionalProperties"] as const) {
207
+ if (raw[key] === undefined) continue;
208
+ const child = readSavedShape(raw[key], depth + 1);
209
+ if (!child) return undefined;
210
+ shape[key] = child;
211
+ }
212
+ if (raw.properties !== undefined) {
213
+ if (typeof raw.properties !== "object" || raw.properties === null || Array.isArray(raw.properties)) return undefined;
214
+ const entries = Object.entries(raw.properties);
215
+ if (entries.length > MAX_OBJECT_KEYS) return undefined;
216
+ const properties: Record<string, OutputShape> = {};
217
+ for (const [key, child] of entries) {
218
+ const property = key !== "__proto__" && FIELD_NAME.test(key) ? readSavedShape(child, depth + 1) : undefined;
219
+ if (!property) return undefined;
220
+ properties[key] = property;
221
+ }
222
+ shape.properties = properties;
223
+ shape.required = Array.isArray(raw.required)
224
+ ? raw.required.filter((key): key is string => typeof key === "string" && Object.hasOwn(properties, key))
225
+ : [];
226
+ }
227
+ return shape;
228
+ }
229
+
230
+ function findTool(state: McpExtensionState, serverName: string, toolName: string): ToolMetadata | undefined {
231
+ return state.toolMetadata.get(serverName)?.find(tool => tool.originalName === toolName && !tool.resourceUri);
232
+ }
233
+
234
+ function readResultValue(result: Record<string, unknown>): { source: ObservedOutput["source"]; value: unknown } | undefined {
235
+ const structured = result.structuredContent;
236
+ if (typeof structured === "object" && structured !== null) return { source: "structuredContent", value: structured };
237
+ const content = result.content;
238
+ if (!Array.isArray(content) || content.length !== 1) return undefined;
239
+ const block = content[0] as { type?: unknown; text?: unknown };
240
+ if (block.type !== "text" || typeof block.text !== "string" || block.text.length > MAX_JSON_TEXT_CHARS) return undefined;
241
+ const text = block.text.trim();
242
+ if (!text.startsWith("{") && !text.startsWith("[")) return undefined;
243
+ try {
244
+ return { source: "jsonText", value: JSON.parse(text) };
245
+ } catch {
246
+ return undefined;
247
+ }
248
+ }
249
+
250
+ function inferShape(value: unknown, depth: number, budget: { nodes: number }): OutputShape {
251
+ if (--budget.nodes < 0) return {};
252
+ if (value === null) return { type: "null" };
253
+ if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") return { type: typeof value as "boolean" | "number" | "string" };
254
+ if (typeof value !== "object" || depth >= MAX_DEPTH) return {};
255
+ if (Array.isArray(value)) {
256
+ const items = value.slice(0, MAX_ARRAY_SAMPLE).map(item => inferShape(item, depth + 1, budget));
257
+ return items.length === 0 ? { type: "array" } : { type: "array", items: items.reduce(mergeShapes) };
258
+ }
259
+ const record = value as Record<string, unknown>;
260
+ const keys: string[] = [];
261
+ let isMap = false;
262
+ // Stops at the first key that makes this a map, so a huge object costs O(MAX_OBJECT_KEYS) here.
263
+ for (const key in record) {
264
+ if (!Object.hasOwn(record, key)) continue;
265
+ keys.push(key);
266
+ // An own "__proto__" key would set the prototype of the shape's properties object instead of adding a field.
267
+ if (keys.length > MAX_OBJECT_KEYS || !FIELD_NAME.test(key) || /\d{4}/.test(key) || key === "__proto__") {
268
+ isMap = true;
269
+ break;
270
+ }
271
+ }
272
+ if (isMap) {
273
+ const values = keys.slice(0, MAX_ARRAY_SAMPLE).map(key => inferShape(record[key], depth + 1, budget));
274
+ return { type: "object", additionalProperties: values.reduce(mergeShapes) };
275
+ }
276
+ const properties: Record<string, OutputShape> = {};
277
+ for (const key of keys) properties[key] = inferShape(record[key], depth + 1, budget);
278
+ return { type: "object", properties, required: keys };
279
+ }
280
+
281
+ function isUnknown(shape: OutputShape): boolean {
282
+ return shape.type === undefined && shape.anyOf === undefined;
283
+ }
284
+
285
+ function mergeShapes(left: OutputShape, right: OutputShape): OutputShape {
286
+ if (isUnknown(left) || isUnknown(right)) return {};
287
+ const variants = [...(left.anyOf ?? [left])];
288
+ for (const variant of right.anyOf ?? [right]) {
289
+ const index = variants.findIndex(existing => existing.type === variant.type);
290
+ if (index === -1) variants.push(variant);
291
+ else variants[index] = mergeSameType(variants[index]!, variant);
292
+ }
293
+ if (variants.length > MAX_UNION) return {};
294
+ return variants.length === 1 ? variants[0]! : { anyOf: variants };
295
+ }
296
+
297
+ function mergeSameType(left: OutputShape, right: OutputShape): OutputShape {
298
+ if (left.type === "array") {
299
+ const items = left.items && right.items ? mergeShapes(left.items, right.items) : left.items ?? right.items;
300
+ return items ? { type: "array", items } : { type: "array" };
301
+ }
302
+ if (left.type !== "object") return left;
303
+ const keys = new Set([...Object.keys(left.properties ?? {}), ...Object.keys(right.properties ?? {})]);
304
+ if (left.additionalProperties || right.additionalProperties || keys.size > MAX_OBJECT_KEYS) {
305
+ const values = [
306
+ left.additionalProperties,
307
+ right.additionalProperties,
308
+ ...Object.values(left.properties ?? {}),
309
+ ...Object.values(right.properties ?? {}),
310
+ ].filter((shape): shape is OutputShape => shape !== undefined);
311
+ return { type: "object", additionalProperties: values.reduce(mergeShapes) };
312
+ }
313
+ const properties: Record<string, OutputShape> = {};
314
+ for (const key of keys) {
315
+ const leftProperty = left.properties?.[key];
316
+ const rightProperty = right.properties?.[key];
317
+ properties[key] = leftProperty && rightProperty ? mergeShapes(leftProperty, rightProperty) : (leftProperty ?? rightProperty)!;
318
+ }
319
+ const rightRequired = new Set(right.required);
320
+ return { type: "object", properties, required: (left.required ?? []).filter(key => rightRequired.has(key)) };
321
+ }
322
+
323
+ /** Drops nesting from the deepest level up until the rendered shape, which is what describe shows, fits the size bound. */
324
+ function fitShape(shape: OutputShape): OutputShape {
325
+ for (let depth = MAX_DEPTH; depth > 0; depth--) {
326
+ const pruned = pruneShape(shape, depth);
327
+ if (renderOutputShape(pruned).length <= MAX_SHAPE_CHARS) return pruned;
328
+ }
329
+ return {};
330
+ }
331
+
332
+ function pruneShape(shape: OutputShape, depth: number): OutputShape {
333
+ if (shape.anyOf) {
334
+ const variants = shape.anyOf.map(variant => pruneShape(variant, depth));
335
+ return variants.some(isUnknown) ? {} : { anyOf: variants };
336
+ }
337
+ if (shape.type !== "object" && shape.type !== "array") return shape;
338
+ if (depth === 0) return {};
339
+ const prune = (child: OutputShape) => pruneShape(child, depth - 1);
340
+ return {
341
+ ...shape,
342
+ ...(shape.properties
343
+ ? { properties: Object.fromEntries(Object.entries(shape.properties).map(([key, child]) => [key, prune(child)])) }
344
+ : {}),
345
+ ...(shape.items ? { items: prune(shape.items) } : {}),
346
+ ...(shape.additionalProperties ? { additionalProperties: prune(shape.additionalProperties) } : {}),
347
+ };
348
+ }
@@ -103,11 +103,14 @@ function resolvePackageRoot(source: string, scope: "user" | "project", cwd: stri
103
103
  ? source.slice(4).trim()
104
104
  : /^(?:(?:https?|ssh):\/\/|git@[^:]+:)/.test(source) ? source : null;
105
105
  if (gitSource) {
106
- const value = gitSource
107
- .replace(/^ssh:\/\/git@/, "")
108
- .replace(/^git@([^:]+):/, "$1/")
109
- .replace(/^[a-z]+:\/\//i, "");
110
- const path = value.replace(/@[^/]+$/, "").replace(/\.git$/, "");
106
+ // Pi installs URL sources under the bare hostname, without user or port.
107
+ const isUrl = /^[a-z]+:\/\//i.test(gitSource);
108
+ if (isUrl && !URL.canParse(gitSource)) return null;
109
+ const url = isUrl ? new URL(gitSource) : null;
110
+ const value = url ? url.hostname + url.pathname : gitSource.replace(/^git@([^:]+):/, "$1/");
111
+ // Like Pi, a non-empty ref starts at the first "@" after the host, so refs such as "@feature/x" keep their slash.
112
+ const refStart = value.indexOf("@", value.indexOf("/"));
113
+ const path = (refStart < 0 || refStart === value.length - 1 ? value : value.slice(0, refStart)).replace(/\.git$/, "");
111
114
  return path && !path.startsWith("/") ? resolveContainedPath(join(baseDir, "git"), path) : null;
112
115
  }
113
116
  return isAbsolute(source) ? resolve(source) : resolve(baseDir, source);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "3.2.0",
3
+ "version": "4.0.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -89,6 +89,7 @@
89
89
  "runtime-owner.ts",
90
90
  "tool-metadata.ts",
91
91
  "ts-shape.ts",
92
+ "output-shape.ts",
92
93
  "search-ranking.ts",
93
94
  "tool-approval.ts",
94
95
  "tool-parameters.ts",
@@ -180,7 +181,6 @@
180
181
  "dependencies": {
181
182
  "@modelcontextprotocol/client": "2.0.0",
182
183
  "@modelcontextprotocol/core": "2.0.0",
183
- "@modelcontextprotocol/ext-apps": "^1.2.2",
184
184
  "@modelcontextprotocol/ext-tasks": "^0.1.0",
185
185
  "@napi-rs/keyring": "^2.1.0",
186
186
  "@typesafe-ai/sdk": "0.6.0",
@@ -196,7 +196,7 @@
196
196
  "zod": "^3.25.0 || ^4.0.0"
197
197
  },
198
198
  "peerDependencies": {
199
- "@earendil-works/pi-ai": "^0.84.1 || ^0.85.0 || ^0.86.0 || ^0.87.0",
199
+ "@earendil-works/pi-ai": "^0.84.1 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.0",
200
200
  "@earendil-works/pi-tui": "*",
201
201
  "typebox": "*",
202
202
  "zod": "^3.25.0 || ^4.0.0"
@@ -213,9 +213,9 @@
213
213
  }
214
214
  },
215
215
  "devDependencies": {
216
- "@earendil-works/pi-ai": "0.87.0",
217
- "@earendil-works/pi-coding-agent": "0.87.0",
218
- "@earendil-works/pi-tui": "0.87.0",
216
+ "@earendil-works/pi-ai": "0.99.1",
217
+ "@earendil-works/pi-coding-agent": "0.99.1",
218
+ "@earendil-works/pi-tui": "0.99.1",
219
219
  "@modelcontextprotocol/conformance": "0.1.16",
220
220
  "@types/bun": "^1.0.0",
221
221
  "@types/cross-spawn": "6.0.6",
package/prompts.ts CHANGED
@@ -122,6 +122,64 @@ function stripQuotes(value: string): string {
122
122
  return value;
123
123
  }
124
124
 
125
+ function findCurrentPromptTokenStart(input: string): number {
126
+ let quote: '"' | "'" | null = null;
127
+ let escaped = false;
128
+ let start = 0;
129
+
130
+ for (let i = 0; i < input.length; i++) {
131
+ const char = input.charAt(i);
132
+ if (escaped) {
133
+ escaped = false;
134
+ continue;
135
+ }
136
+ if (char === "\\" && quote !== "'") {
137
+ escaped = true;
138
+ continue;
139
+ }
140
+ if (quote) {
141
+ if (char === quote) quote = null;
142
+ continue;
143
+ }
144
+ if (char === '"' || char === "'") {
145
+ quote = char;
146
+ continue;
147
+ }
148
+ if (/\s/.test(char)) start = i + 1;
149
+ }
150
+
151
+ return start;
152
+ }
153
+
154
+ export function completePromptArgs(
155
+ metadata: PromptMetadata,
156
+ input: string,
157
+ ): Array<{ value: string; label: string; description?: string }> | null {
158
+ const tokenStart = findCurrentPromptTokenStart(input);
159
+ const current = input.slice(tokenStart);
160
+ if (current.includes("=") || current.startsWith('"') || current.startsWith("'")) return null;
161
+
162
+ const committed = input.slice(0, tokenStart);
163
+ const parsed = parsePromptArgs(committed);
164
+ const used = new Set(Object.keys(parsed.named));
165
+ let positionalIndex = 0;
166
+
167
+ for (const arg of metadata.arguments) {
168
+ if (Object.hasOwn(parsed.named, arg.name)) continue;
169
+ const positional = parsed.positional[positionalIndex++];
170
+ if (positional !== undefined && positional !== "") used.add(arg.name);
171
+ }
172
+
173
+ const matches = metadata.arguments.filter(arg => !used.has(arg.name) && arg.name.startsWith(current));
174
+ if (matches.length === 0) return null;
175
+
176
+ return matches.map(arg => ({
177
+ value: committed + arg.name + "=",
178
+ label: arg.name + "=",
179
+ ...(arg.description ? { description: arg.description } : {}),
180
+ }));
181
+ }
182
+
125
183
  export interface ResolvedPromptArgs {
126
184
  ok: boolean;
127
185
  /** Present when `ok === true`. */
@@ -249,6 +307,16 @@ export function createPromptCommand(
249
307
 
250
308
  return {
251
309
  description,
310
+ getArgumentCompletions: (prefix: string) => {
311
+ const state = getState();
312
+ if (!state) return completePromptArgs(metadata, prefix);
313
+ const definition = state.config.mcpServers[metadata.serverName];
314
+ if (!definition || isServerDisabled(definition)) return null;
315
+
316
+ const liveMetadata = findLivePromptMetadata(state, metadata.serverName, metadata.originalName);
317
+ if (state.promptMetadataLive?.has(metadata.serverName) && !liveMetadata) return null;
318
+ return completePromptArgs(liveMetadata ?? metadata, prefix);
319
+ },
252
320
  handler: async (args: string, ctx: ExtensionCommandContext) => {
253
321
  let state = getState();
254
322
  if (!state && runtime.ensureState) {