pi-mcp-adapter 3.3.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.
package/metadata-cache.ts CHANGED
@@ -58,27 +58,32 @@ export function loadMetadataCache(): MetadataCache | null {
58
58
  }
59
59
 
60
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 {
61
66
  const cachePath = getMetadataCachePath();
62
67
  const dir = dirname(cachePath);
63
68
  mkdirSync(dir, { recursive: true });
64
69
 
65
- let merged: MetadataCache = { version: CACHE_VERSION, servers: {} };
70
+ let servers: MetadataCache["servers"] = {};
66
71
  try {
67
72
  if (existsSync(cachePath)) {
68
73
  const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as MetadataCache;
69
74
  if (existing && existing.version === CACHE_VERSION && existing.servers) {
70
- merged.servers = { ...existing.servers };
75
+ servers = { ...existing.servers };
71
76
  }
72
77
  }
73
78
  } catch {
74
79
  // Ignore parse errors and proceed with empty cache
75
80
  }
76
81
 
77
- merged.version = CACHE_VERSION;
78
- merged.servers = { ...merged.servers, ...cache.servers };
82
+ const next = update(servers);
83
+ if (!next) return;
79
84
 
80
85
  const tmpPath = `${cachePath}.${process.pid}.tmp`;
81
- writeFileSync(tmpPath, JSON.stringify(merged), "utf-8");
86
+ writeFileSync(tmpPath, JSON.stringify({ version: CACHE_VERSION, servers: next }), "utf-8");
82
87
  renameSync(tmpPath, cachePath);
83
88
  }
84
89
 
@@ -117,6 +122,49 @@ export function computeServerHash(definition: ServerEntry, environment: NodeJS.P
117
122
  return createHash("sha256").update(normalized).digest("hex");
118
123
  }
119
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
+
120
168
  export function isServerCacheValid(
121
169
  entry: ServerCacheEntry,
122
170
  definition: ServerEntry,
@@ -131,6 +179,8 @@ export function isServerCacheValid(
131
179
  }
132
180
  if (!entry || entry.configHash !== configHash) return false;
133
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;
134
184
  const declaredTtlMs = entry.ttlMs;
135
185
  if (typeof declaredTtlMs === "number" && Number.isSafeInteger(declaredTtlMs) && declaredTtlMs >= 0) {
136
186
  if (declaredTtlMs === 0) return false;
package/output-shape.ts CHANGED
@@ -1,11 +1,14 @@
1
+ import { logger } from "./logger.ts";
2
+ import { outputShapeKey, saveObservedOutput } from "./metadata-cache.ts";
1
3
  import type { McpExtensionState } from "./state.ts";
2
4
  import { formatPropertyName } from "./ts-shape.ts";
3
- import type { ToolMetadata } from "./types.ts";
5
+ import type { ServerCacheEntry, ServerDefinition, ToolMetadata } from "./types.ts";
4
6
 
5
7
  /**
6
- * Session-only output shapes for tools that declare no outputSchema.
8
+ * Output shapes for tools that declare no outputSchema.
7
9
  * Field names and broad JSON types only, never values; every dimension is
8
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.
9
12
  */
10
13
  export interface OutputShape {
11
14
  type?: "null" | "boolean" | "number" | "string" | "object" | "array";
@@ -19,7 +22,13 @@ export interface OutputShape {
19
22
  export interface ObservedOutput {
20
23
  source: "structuredContent" | "jsonText";
21
24
  shape: OutputShape;
22
- calls: number;
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;
23
32
  }
24
33
 
25
34
  const MAX_DEPTH = 6;
@@ -33,61 +42,191 @@ const MAX_NODES_PER_CALL = 1000;
33
42
  // Sizes count UTF-16 code units (string length), not bytes.
34
43
  const MAX_SHAPE_CHARS = 4 * 1024;
35
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"]);
36
48
 
37
- export function recordObservedOutput(
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(
38
61
  state: McpExtensionState,
39
62
  serverName: string,
40
- toolName: string,
63
+ tool: ToolMetadata | undefined,
64
+ observedPrivately: boolean,
41
65
  result: Record<string, unknown>,
42
66
  ): void {
43
- if (findTool(state, serverName, toolName)?.outputSchema !== undefined) return;
67
+ if (!tool || tool.outputSchema !== undefined) return;
68
+ const toolName = tool.originalName;
44
69
  const observed = readResultValue(result);
45
70
  if (!observed) return;
46
71
  const definition = state.config.mcpServers[serverName];
47
72
  if (!definition) return;
48
73
  const shape = inferShape(observed.value, 0, { nodes: MAX_NODES_PER_CALL });
49
- const observedOutputs = state.observedOutputs ??= new WeakMap();
50
- let byTool = observedOutputs.get(definition);
51
- if (!byTool) observedOutputs.set(definition, byTool = new Map());
74
+ const byTool = observedOutputsFor(state, definition);
75
+ const toolKey = outputShapeKey(tool);
52
76
  const previous = byTool.get(toolName);
53
- byTool.set(toolName, previous?.source === observed.source
54
- ? { source: observed.source, shape: fitShape(mergeShapes(previous.shape, shape)), calls: previous.calls + 1 }
55
- : { source: observed.source, shape: fitShape(shape), calls: 1 });
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
+ }
56
112
  }
57
113
 
58
114
  export function getObservedOutput(
59
115
  state: McpExtensionState,
60
116
  serverName: string,
61
- tool: Pick<ToolMetadata, "originalName" | "outputSchema">,
117
+ tool: Pick<ToolMetadata, "originalName" | "outputSchema" | "description" | "inputSchema">,
62
118
  ): ObservedOutput | undefined {
63
119
  if (tool.outputSchema !== undefined) return undefined;
64
120
  const definition = state.config.mcpServers[serverName];
65
121
  const observed = definition && state.observedOutputs?.get(definition)?.get(tool.originalName);
66
- return observed && !isUnknown(observed.shape) ? observed : undefined;
122
+ return observed && observed.toolKey === outputShapeKey(tool) && !isUnknown(observed.shape) ? observed : undefined;
67
123
  }
68
124
 
69
125
  export function renderOutputShape(shape: OutputShape): string {
70
- if (shape.anyOf) return shape.anyOf.map(renderOutputShape).join(" | ");
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(" | ");
71
165
  switch (shape.type) {
72
166
  case undefined:
73
167
  return "unknown";
74
168
  case "object": {
75
- if (shape.additionalProperties) return `Record<string, ${renderOutputShape(shape.additionalProperties)}>`;
169
+ if (shape.additionalProperties) return `Record<string, ${renderShape(shape.additionalProperties, field, onObject)}>`;
76
170
  const required = new Set(shape.required);
77
171
  const properties = Object.entries(shape.properties ?? {})
78
- .map(([name, property]) => `${formatPropertyName(name)}${required.has(name) ? "" : "?"}: ${renderOutputShape(property)};`);
79
- return properties.length === 0 ? "{}" : `{ ${properties.join(" ")} }`;
172
+ .map(([name, property]) => `${formatPropertyName(name)}${required.has(name) ? "" : "?"}: ${renderShape(property, name, onObject)};`);
173
+ return onObject(shape, field, properties.length === 0 ? "{}" : `{ ${properties.join(" ")} }`);
80
174
  }
81
175
  case "array": {
82
176
  if (!shape.items) return "unknown[]";
83
- const item = renderOutputShape(shape.items);
84
- return item.includes(" | ") ? `(${item})[]` : `${item}[]`;
177
+ const item = renderShape(shape.items, field, onObject);
178
+ return shape.items.anyOf ? `(${item})[]` : `${item}[]`;
85
179
  }
86
180
  default:
87
181
  return shape.type;
88
182
  }
89
183
  }
90
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
+
91
230
  function findTool(state: McpExtensionState, serverName: string, toolName: string): ToolMetadata | undefined {
92
231
  return state.toolMetadata.get(serverName)?.find(tool => tool.originalName === toolName && !tool.resourceUri);
93
232
  }
@@ -181,11 +320,11 @@ function mergeSameType(left: OutputShape, right: OutputShape): OutputShape {
181
320
  return { type: "object", properties, required: (left.required ?? []).filter(key => rightRequired.has(key)) };
182
321
  }
183
322
 
184
- /** Drops nesting from the deepest level up until the stored shape fits the size bound. */
323
+ /** Drops nesting from the deepest level up until the rendered shape, which is what describe shows, fits the size bound. */
185
324
  function fitShape(shape: OutputShape): OutputShape {
186
325
  for (let depth = MAX_DEPTH; depth > 0; depth--) {
187
326
  const pruned = pruneShape(shape, depth);
188
- if (JSON.stringify(pruned).length <= MAX_SHAPE_CHARS) return pruned;
327
+ if (renderOutputShape(pruned).length <= MAX_SHAPE_CHARS) return pruned;
189
328
  }
190
329
  return {};
191
330
  }
@@ -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.3.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",
@@ -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",