pi-mcp-adapter 5.3.0 → 5.3.2

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.
@@ -80,7 +80,8 @@ export function hashProjectServerDefinition(definition: ServerDefinition): strin
80
80
 
81
81
  export function canonicalProjectRoot(cwd: string): string {
82
82
  try {
83
- return realpathSync(cwd);
83
+ // The native realpath returns the on-disk casing, so `C:\Users\Me` and `c:\users\me` share approvals.
84
+ return realpathSync.native(cwd);
84
85
  } catch {
85
86
  return resolve(cwd);
86
87
  }
@@ -114,9 +115,9 @@ function linkedWorktreeRepoScope(dotGit: string): string | undefined {
114
115
  if (!lstatSync(dotGit).isFile()) return undefined;
115
116
  const pointer = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1]?.trim();
116
117
  if (!pointer) return undefined;
117
- const adminDir = realpathSync(resolve(dirname(dotGit), pointer));
118
+ const adminDir = realpathSync.native(resolve(dirname(dotGit), pointer));
118
119
  const backLink = readFileSync(join(adminDir, "gitdir"), "utf8").trim();
119
- if (realpathSync(resolve(adminDir, backLink)) !== realpathSync(dotGit)) return undefined;
120
+ if (realpathSync.native(resolve(adminDir, backLink)) !== realpathSync.native(dotGit)) return undefined;
120
121
  const commonDir = dirname(dirname(adminDir));
121
122
  const bare = /^\s*bare\s*=\s*(true|yes|on|1)\s*$/im.test(readFileSync(join(commonDir, "config"), "utf8"));
122
123
  return basename(commonDir) === ".git" && !bare ? dirname(commonDir) : `git-dir:${commonDir}`;
@@ -169,6 +170,49 @@ export function approveProjectServer(cwd: string, serverName: string, definition
169
170
  });
170
171
  }
171
172
 
173
+ function isApproved(approvals: ApprovalStore, projectRoot: string, serverName: string, definition: ServerDefinition): boolean {
174
+ const definitionHash = hashProjectServerDefinition(definition);
175
+ return approvals.approvals.some(entry =>
176
+ entry.projectRoot === projectRoot && entry.serverName === serverName && entry.definitionHash === definitionHash);
177
+ }
178
+
179
+ function isProjectTrusted(ctx: Pick<ExtensionContext, "isProjectTrusted">): boolean {
180
+ try {
181
+ return ctx.isProjectTrusted();
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * After the adapter edits config on the user's behalf (a panel save), re-approve each named project
189
+ * server whose pre-edit definition was approved, so the edit does not revoke the approval. Only a
190
+ * `directTools` change carries the approval over; any other difference, such as a concurrent edit to
191
+ * the file, still needs a new approval. A server without a matching approval stays unapproved: an
192
+ * adapter edit never creates one.
193
+ */
194
+ export function keepProjectServerApprovals(
195
+ ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">,
196
+ before: McpConfig,
197
+ after: McpConfig,
198
+ serverNames: Iterable<string>,
199
+ ): void {
200
+ if (!isProjectTrusted(ctx)) return;
201
+ const projectServers = asLoadedConfig(after).projectServers;
202
+ const projectRoot = projectApprovalScope(ctx.cwd);
203
+ const approvals = loadApprovals();
204
+ for (const name of serverNames) {
205
+ const previous = before.mcpServers[name];
206
+ const next = after.mcpServers[name];
207
+ if (!previous || !next || !projectServers.has(name)) continue;
208
+ if (!isApproved(approvals, projectRoot, name, previous)) continue;
209
+ const { directTools: _previousDirectTools, ...previousRest } = previous;
210
+ const { directTools: _nextDirectTools, ...nextRest } = next;
211
+ if (hashProjectServerDefinition(previousRest) !== hashProjectServerDefinition(nextRest)) continue;
212
+ approveProjectServer(ctx.cwd, name, next);
213
+ }
214
+ }
215
+
172
216
  function describeServer(definition: ServerDefinition): string {
173
217
  if (definition.command) {
174
218
  return [definition.command, ...(definition.args ?? [])].map(value => JSON.stringify(value)).join(" ");
@@ -186,12 +230,7 @@ export async function applyProjectServerTrust(
186
230
  const blockedServers = new Map<string, ProjectServerBlock>();
187
231
  if (loaded.projectServers.size === 0) return { config, blockedServers };
188
232
 
189
- let projectTrusted = false;
190
- try {
191
- projectTrusted = ctx.isProjectTrusted();
192
- } catch {
193
- projectTrusted = false;
194
- }
233
+ const projectTrusted = isProjectTrusted(ctx);
195
234
  // RPC sessions also have a UI, but Pi reads RPC input only after session_start, so a dialog here never resolves.
196
235
  const interactive = ctx.hasUI && ctx.mode === "tui";
197
236
  const projectRoot = projectApprovalScope(ctx.cwd);
@@ -200,9 +239,7 @@ export async function applyProjectServerTrust(
200
239
  for (const [name, source] of loaded.projectServers) {
201
240
  const definition = config.mcpServers[name];
202
241
  if (!definition || isServerDisabled(definition)) continue;
203
- const definitionHash = hashProjectServerDefinition(definition);
204
- const approved = approvals.approvals.some(entry =>
205
- entry.projectRoot === projectRoot && entry.serverName === name && entry.definitionHash === definitionHash);
242
+ const approved = isApproved(approvals, projectRoot, name, definition);
206
243
  if (projectTrusted && (approved || (!interactive && loaded.projectServerPolicy === "allow"))) continue;
207
244
 
208
245
  let reason: ProjectServerBlockReason;
package/proxy-modes.ts CHANGED
@@ -7,7 +7,7 @@ import { getServerPrefix, getToolNameCandidates, isServerDisabled, parseUiPrompt
7
7
  import { lazyConnect, markKeepAliveAfterConnect, notifyToolMetadataUpdated, updateServerMetadata, updateMetadataCache, getFailureAgeSeconds, updateStatusBar, clearFailure, recordFailure } from "./init.ts";
8
8
  import { abortable, throwIfAborted } from "./abort.ts";
9
9
  import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
10
- import { buildToolMetadata, getToolNames, formatSchema } from "./tool-metadata.ts";
10
+ import { buildToolMetadata, getToolNames, formatSchema, isPlainTypeSchema } from "./tool-metadata.ts";
11
11
  import { renderTsShape } from "./ts-shape.ts";
12
12
  import { getObservedOutput, observedOutputRecorder, renderOutputShape } from "./output-shape.ts";
13
13
  import { reconstructPromptMetadata } from "./metadata-cache.ts";
@@ -800,7 +800,9 @@ export function executeDescribe(state: McpExtensionState, toolName: string, serv
800
800
 
801
801
  if (toolMeta.inputSchema && !toolMeta.resourceUri) {
802
802
  const shape = renderTsShape(toolMeta.inputSchema);
803
- text += shape === null ? `\nParameters:\n${formatSchema(toolMeta.inputSchema)}` : `\nShape:\n${shape}`;
803
+ text += shape !== null && isPlainTypeSchema(toolMeta.inputSchema)
804
+ ? `\nShape:\n${shape}`
805
+ : `\nInput schema (JSON Schema):\n\`\`\`json\n${JSON.stringify(toolMeta.inputSchema, null, 2)}\n\`\`\``;
804
806
  } else if (toolMeta.resourceUri) {
805
807
  text += `\nNo parameters required (resource tool).`;
806
808
  } else {
@@ -819,6 +821,16 @@ export function executeDescribe(state: McpExtensionState, toolName: string, serv
819
821
  };
820
822
  }
821
823
 
824
+ export async function executeGuardedDescribe(state: McpExtensionState, toolName: string, serverOverride?: string): Promise<ProxyToolResult> {
825
+ const result = executeDescribe(state, toolName, serverOverride);
826
+ const guarded = await guardMcpOutput(result.content, resolveMcpOutputGuardOptions(state.config.settings));
827
+ return {
828
+ ...result,
829
+ content: guarded.content,
830
+ details: { ...result.details, ...guardedMcpDetails(guarded) },
831
+ };
832
+ }
833
+
822
834
  function renderSearchResults(
823
835
  state: McpExtensionState,
824
836
  query: string,
@@ -868,6 +880,9 @@ function renderSearchResults(
868
880
  text += shape === null
869
881
  ? `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n`
870
882
  : `\n Shape:\n${shape.split("\n").map(line => ` ${line}`).join("\n")}\n`;
883
+ if (shape === null || !isPlainTypeSchema(match.tool.inputSchema)) {
884
+ text += ` Parameter summary only. Full input schema: mcp(${JSON.stringify({ describe: match.tool.name, server: match.server })})\n`;
885
+ }
871
886
  } else if (match.tool.resourceUri) {
872
887
  text += " No parameters (resource tool).\n";
873
888
  }
@@ -1,4 +1,4 @@
1
- import type { Api, AssistantMessage, Message, Model, TextContent } from "@earendil-works/pi-ai";
1
+ import type { Api, AssistantMessage, Message, Model, ModelThinkingLevel, TextContent } from "@earendil-works/pi-ai";
2
2
  import { truncateAtWord } from "./utils.ts";
3
3
  import { throwIfAborted } from "./abort.ts";
4
4
  import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
@@ -12,7 +12,7 @@ import {
12
12
  } from "@modelcontextprotocol/client";
13
13
 
14
14
  export type SamplingUIContext = Pick<ExtensionUIContext, "confirm">;
15
- export type SamplingModelRegistry = Pick<ModelRegistry, "getAvailable" | "complete">;
15
+ export type SamplingModelRegistry = Pick<ModelRegistry, "getAvailable" | "complete"> & Partial<Pick<ModelRegistry, "streamSimple">>;
16
16
 
17
17
  export interface SamplingHandlerOptions {
18
18
  serverName: string;
@@ -20,6 +20,7 @@ export interface SamplingHandlerOptions {
20
20
  ui?: SamplingUIContext;
21
21
  modelRegistry: SamplingModelRegistry;
22
22
  getCurrentModel: () => Model<Api> | undefined;
23
+ getThinkingLevel: () => ModelThinkingLevel;
23
24
  getSignal: () => AbortSignal | undefined;
24
25
  }
25
26
 
@@ -65,19 +66,24 @@ export async function handleSamplingRequest(
65
66
  );
66
67
  throwIfAborted(signal);
67
68
 
68
- const result = await options.modelRegistry.complete(
69
- model,
70
- {
71
- ...(params.systemPrompt !== undefined ? { systemPrompt: params.systemPrompt } : {}),
72
- messages,
73
- },
74
- {
75
- maxTokens: params.maxTokens,
76
- ...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
77
- ...(params.metadata !== undefined ? { metadata: params.metadata as Record<string, unknown> } : {}),
78
- ...(signal ? { signal } : {}),
79
- },
80
- );
69
+ const context = {
70
+ ...(params.systemPrompt !== undefined ? { systemPrompt: params.systemPrompt } : {}),
71
+ messages,
72
+ };
73
+ const requestOptions = {
74
+ maxTokens: params.maxTokens,
75
+ ...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
76
+ ...(params.metadata !== undefined ? { metadata: params.metadata as Record<string, unknown> } : {}),
77
+ ...(signal ? { signal } : {}),
78
+ };
79
+ const thinkingLevel = options.getThinkingLevel();
80
+ // Only streamSimple applies the thinking level; Pi before 0.86 has no streamSimple, so sampling runs with thinking off there.
81
+ const result = options.modelRegistry.streamSimple
82
+ ? await options.modelRegistry.streamSimple(model, context, {
83
+ ...requestOptions,
84
+ ...(thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
85
+ }).result()
86
+ : await options.modelRegistry.complete(model, context, requestOptions);
81
87
 
82
88
  const converted = convertAssistantResult(result);
83
89
  throwIfAborted(signal);
package/server-manager.ts CHANGED
@@ -1307,7 +1307,7 @@ export class McpServerManager {
1307
1307
  transport,
1308
1308
  requestTimeoutMs: this.getResolvedRequestTimeoutMs(definition),
1309
1309
  clientInfo: { name: `pi-mcp-${name}`, version: "1.0.0" },
1310
- clientCapabilities: this.buildClientCapabilities(),
1310
+ clientCapabilities: this.buildClientCapabilities(definition),
1311
1311
  ...(this.elicitationConfig
1312
1312
  ? {
1313
1313
  onElicitation: (request: Parameters<typeof handleElicitationRequest>[1], signal?: AbortSignal) =>
@@ -1449,13 +1449,12 @@ export class McpServerManager {
1449
1449
  }
1450
1450
  }
1451
1451
 
1452
- private buildClientCapabilities() {
1452
+ private buildClientCapabilities(definition: ServerDefinition) {
1453
1453
  return {
1454
- extensions: {
1455
- "io.modelcontextprotocol/ui": {
1456
- mimeTypes: [RESOURCE_MIME_TYPE],
1457
- },
1458
- },
1454
+ // openUi: false tells the server there is no UI, so MCP App tools return results instead of deferring to a form.
1455
+ ...(definition.openUi === false
1456
+ ? {}
1457
+ : { extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [RESOURCE_MIME_TYPE] } } }),
1459
1458
  ...(this.samplingConfig ? { sampling: {} } : {}),
1460
1459
  ...(this.elicitationConfig
1461
1460
  ? {
@@ -1469,7 +1468,7 @@ export class McpServerManager {
1469
1468
  }
1470
1469
 
1471
1470
  private createClient(serverName: string, definition: ServerDefinition): Client {
1472
- const capabilities = this.buildClientCapabilities();
1471
+ const capabilities = this.buildClientCapabilities(definition);
1473
1472
  const versionNegotiation = resolveVersionNegotiation(definition);
1474
1473
  let client: Client;
1475
1474
  client = new AdapterClient(
package/tool-metadata.ts CHANGED
@@ -154,6 +154,25 @@ export function findToolByName(metadata: ToolMetadata[] | undefined, toolName: s
154
154
  return metadata.find(m => m.name.replace(/-/g, "_") === normalized);
155
155
  }
156
156
 
157
+ /** Keep only plain structural schemas compact; preserve everything else as original JSON. */
158
+ export function isPlainTypeSchema(schema: unknown): boolean {
159
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false;
160
+ const s = schema as Record<string, unknown>;
161
+ if (Object.keys(s).some(key => !["type", "properties", "required", "items"].includes(key))) return false;
162
+ if (s.type === "integer" || Array.isArray(s.type)) return false;
163
+
164
+ const properties = s.properties;
165
+ if (properties !== undefined) {
166
+ if (s.type !== "object" || !properties || typeof properties !== "object" || Array.isArray(properties)) return false;
167
+ if (!Object.values(properties).every(isPlainTypeSchema)) return false;
168
+ }
169
+ if (s.required !== undefined) {
170
+ if (!Array.isArray(s.required) || s.required.some(name => typeof name !== "string" || !Object.hasOwn(properties ?? {}, name))) return false;
171
+ }
172
+ if (s.items !== undefined && (s.type !== "array" || !isPlainTypeSchema(s.items))) return false;
173
+ return true;
174
+ }
175
+
157
176
  /** Whether the schema formatter has field descriptions worth showing beside a compact shape. */
158
177
  export function hasSchemaDescriptions(schema: unknown, includeRoot = false): boolean {
159
178
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false;
package/types.ts CHANGED
@@ -493,7 +493,7 @@ export interface ServerEntry {
493
493
  searchKeywords?: Record<string, string[]>;
494
494
  // Require interactive approval before calling matching MCP tools/resources.
495
495
  approveTools?: boolean | "destructive" | string[];
496
- /** Set to false to never open MCP App UIs for this server's tools; results stay inline. */
496
+ /** Set to false to tell this server the host has no MCP App UI; its tools run and return results inline. */
497
497
  openUi?: boolean;
498
498
  // Debug
499
499
  debug?: boolean; // Show server stderr (default: false)
@@ -785,8 +785,17 @@ export interface ServerProvenance {
785
785
  path: string;
786
786
  kind: "user" | "project" | "import";
787
787
  importKind?: string;
788
- /** Settings from Pi's `mcp.json` that the adapter could not translate for this server. */
788
+ /** Settings from Pi's `mcp.json` that the adapter could not translate, or that OpenCode ignores in an imported server. */
789
789
  ignoredSettings?: string[];
790
+ /** Set when `ignoredSettings` came from an imported OpenCode server rather than Pi's `mcp.json`. */
791
+ ignoredSettingsSource?: "opencode";
792
+ }
793
+
794
+ /** The line `/mcp-adapter` and `doctor` show for a server's ignored settings. */
795
+ export function describeIgnoredSettings(provenance: ServerProvenance | undefined): string | undefined {
796
+ if (!provenance?.ignoredSettings) return undefined;
797
+ const file = provenance.ignoredSettingsSource === "opencode" ? "OpenCode" : "Pi mcp.json";
798
+ return `Ignored ${file} settings: ${provenance.ignoredSettings.join(", ")}`;
790
799
  }
791
800
 
792
801
  export interface McpAuthResult {
package/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { spawnSync } from "node:child_process";
2
+ import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process";
3
3
  import { existsSync, realpathSync } from "node:fs";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -180,6 +180,41 @@ export function interpolateEnvRecord(values: Record<string, string> | undefined,
180
180
  const COMMAND_SECRET_TIMEOUT_MS = 10_000;
181
181
  const COMMAND_SECRET_MAX_OUTPUT_BYTES = 1024 * 1024;
182
182
 
183
+ let windowsCommandBash: string | null | undefined;
184
+
185
+ function isLegacyWslBash(path: string): boolean {
186
+ return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(path.replace(/\//g, "\\").toLowerCase());
187
+ }
188
+
189
+ function findWindowsBash(): string | null {
190
+ const installs = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]]
191
+ .filter((dir): dir is string => Boolean(dir))
192
+ .map(dir => `${dir}\\Git\\bin\\bash.exe`);
193
+ const found = installs.find(path => existsSync(path));
194
+ if (found) return found;
195
+ try {
196
+ const result = spawnSync("where", ["bash.exe"], { encoding: "utf8", timeout: 5000, windowsHide: true });
197
+ if (result.status !== 0 || !result.stdout) return null;
198
+ return result.stdout.split(/\r?\n/)
199
+ .map(line => line.trim())
200
+ .find(path => path && !isLegacyWslBash(path) && existsSync(path)) ?? null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * On Windows, the bash used to run `!command` values: Git Bash in its standard
208
+ * install locations, then bash.exe on PATH (skipping the legacy WSL launcher),
209
+ * as Pi does. Undefined elsewhere or when none is found, so callers keep the
210
+ * platform default shell.
211
+ */
212
+ export function getWindowsCommandBash(): string | undefined {
213
+ if (process.platform !== "win32") return undefined;
214
+ if (windowsCommandBash === undefined) windowsCommandBash = findWindowsBash();
215
+ return windowsCommandBash ?? undefined;
216
+ }
217
+
183
218
  /** Resolve a secret value, executing only a single leading `!` command marker. */
184
219
  export function resolveCommandSecret(value: string, context: string): string;
185
220
  export function resolveCommandSecret(value: undefined, context: string): undefined;
@@ -189,14 +224,17 @@ export function resolveCommandSecret(value: string | undefined, context: string)
189
224
  if (value.startsWith("!!")) return interpolateEnvVars(value.slice(1));
190
225
  if (!value.startsWith("!")) return interpolateEnvVars(value);
191
226
 
192
- const result = spawnSync(value.slice(1), {
193
- shell: true,
227
+ const options: SpawnSyncOptionsWithStringEncoding = {
194
228
  encoding: "utf8",
195
229
  timeout: COMMAND_SECRET_TIMEOUT_MS,
196
230
  maxBuffer: COMMAND_SECRET_MAX_OUTPUT_BYTES,
197
231
  stdio: ["ignore", "pipe", "ignore"],
198
232
  windowsHide: true,
199
- });
233
+ };
234
+ const bash = getWindowsCommandBash();
235
+ const result = bash === undefined
236
+ ? spawnSync(value.slice(1), { ...options, shell: true })
237
+ : spawnSync(bash, ["-c", value.slice(1)], { ...options, shell: false });
200
238
  if (result.error) {
201
239
  const code = (result.error as NodeJS.ErrnoException).code;
202
240
  const reason = code === "ETIMEDOUT"