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/proxy-modes.ts CHANGED
@@ -9,17 +9,17 @@ import { abortable, throwIfAborted } from "./abort.ts";
9
9
  import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
10
10
  import { buildToolMetadata, getToolNames, formatSchema } from "./tool-metadata.ts";
11
11
  import { renderTsShape } from "./ts-shape.ts";
12
- import { getObservedOutput, recordObservedOutput, renderOutputShape } from "./output-shape.ts";
12
+ import { getObservedOutput, observedOutputRecorder, renderOutputShape } from "./output-shape.ts";
13
13
  import { reconstructPromptMetadata } from "./metadata-cache.ts";
14
14
  import { resolveMcpResultContent, transformMcpResourceContents } from "./tool-registrar.ts";
15
- import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
15
+ import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions, scriptPipeHint } from "./mcp-output-guard.ts";
16
16
  import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
17
17
  import { formatAuthRequiredMessage, formatMcpStatus, normalizeToolArguments, resolveServerUrl, truncateAtWord, withToolCallIdMeta } from "./utils.ts";
18
18
  import { authenticate, completeAuthFromInput, getAuthStatus, startAuth, supportsOAuth } from "./mcp-auth-flow.ts";
19
19
  import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.ts";
20
20
  import { callToolViaTaskSession } from "./mcp-tasks.ts";
21
21
  import { callToolPausingForElicitation } from "./elicitation-handler.ts";
22
- import { paginate, rankSuggestions, rankToolMatches, resolveSearchKeywords } from "./search-ranking.ts";
22
+ import { paginate, rankSuggestions, rankToolMatches, resolveSearchKeywords, type RankedToolMatch } from "./search-ranking.ts";
23
23
  import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.ts";
24
24
  import { describeFailure, isServerInActiveFailureBackoff } from "./failure-backoff.ts";
25
25
  import { semanticSearch, type SemanticSearchBackend, type SemanticSearchEvaluator } from "./semantic-search.ts";
@@ -722,26 +722,28 @@ function formatToolHints(toolMeta: ToolMetadata): string {
722
722
  return hints.join(", ");
723
723
  }
724
724
 
725
- export function executeDescribe(state: McpExtensionState, toolName: string, serverOverride?: string): ProxyToolResult {
726
- let serverName: string | undefined;
727
- let toolMeta: ToolMetadata | undefined;
728
- let disabledMatch: string | undefined;
729
- let failedMatch: string | undefined;
730
-
725
+ export function resolveDescribeTarget(
726
+ state: McpExtensionState,
727
+ toolName: string,
728
+ serverOverride?: string,
729
+ ): { server: string; tool: ToolMetadata } | { error: ProxyToolResult } {
731
730
  if (serverOverride) {
732
731
  if (!state.config.mcpServers[serverOverride]) {
733
732
  return {
734
- content: [{ type: "text" as const, text: `Server "${serverOverride}" not found. Use mcp({}) to see available servers.` }],
735
- details: { mode: "describe", error: "server_not_found", server: serverOverride, requestedTool: toolName },
733
+ error: {
734
+ content: [{ type: "text" as const, text: `Server "${serverOverride}" not found. Use mcp({}) to see available servers.` }],
735
+ details: { mode: "describe", error: "server_not_found", server: serverOverride, requestedTool: toolName },
736
+ },
736
737
  };
737
738
  }
738
739
  const match = getServerScopedToolMatch(state.toolMetadata.get(serverOverride), toolName);
739
- if (match === "ambiguous") return ambiguousServerToolResult("describe", toolName, serverOverride);
740
- if (isServerDisabled(state.config.mcpServers[serverOverride])) return disabledResult(state, "describe", serverOverride);
741
- if (isServerInActiveFailureBackoff(state, serverOverride)) return serverBackoffResult(state, "describe", serverOverride);
742
- serverName = serverOverride;
743
- toolMeta = match?.tool;
740
+ if (match === "ambiguous") return { error: ambiguousServerToolResult("describe", toolName, serverOverride) };
741
+ if (isServerDisabled(state.config.mcpServers[serverOverride])) return { error: disabledResult(state, "describe", serverOverride) };
742
+ if (isServerInActiveFailureBackoff(state, serverOverride)) return { error: serverBackoffResult(state, "describe", serverOverride) };
743
+ if (match) return { server: serverOverride, tool: match.tool };
744
744
  } else {
745
+ let disabledMatch: string | undefined;
746
+ let failedMatch: string | undefined;
745
747
  const matches: Array<{ server: string; tool: ToolMetadata; precedence: number }> = [];
746
748
  for (const [server, metadata] of state.toolMetadata.entries()) {
747
749
  const candidates = getServerScopedToolCandidates(metadata, toolName);
@@ -759,27 +761,31 @@ export function executeDescribe(state: McpExtensionState, toolName: string, serv
759
761
  if (matches.length > 0) {
760
762
  const precedence = Math.min(...matches.map(match => match.precedence));
761
763
  const bestMatches = matches.filter(match => match.precedence === precedence);
762
- if (bestMatches.length > 1) return ambiguousToolResult("describe", toolName);
763
- serverName = bestMatches[0]!.server;
764
- toolMeta = bestMatches[0]!.tool;
764
+ if (bestMatches.length > 1) return { error: ambiguousToolResult("describe", toolName) };
765
+ return { server: bestMatches[0]!.server, tool: bestMatches[0]!.tool };
765
766
  }
767
+ if (disabledMatch) return { error: disabledResult(state, "describe", disabledMatch) };
768
+ if (failedMatch) return { error: serverBackoffResult(state, "describe", failedMatch) };
766
769
  }
767
770
 
768
- if (!serverName || !toolMeta) {
769
- if (disabledMatch) return disabledResult(state, "describe", disabledMatch);
770
- if (failedMatch) return serverBackoffResult(state, "describe", failedMatch);
771
- const suggestions = rankSuggestions(state, toolName, 5, serverOverride);
772
- const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}` : "";
773
- const scopeText = serverOverride ? ` on server "${serverOverride}"` : "";
774
- const searchHint = serverOverride
775
- ? `mcp({ search: "...", server: "${serverOverride}" })`
776
- : `mcp({ search: "..." })`;
777
- return {
771
+ const suggestions = rankSuggestions(state, toolName, 5, serverOverride);
772
+ const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}` : "";
773
+ const scopeText = serverOverride ? ` on server "${serverOverride}"` : "";
774
+ const searchHint = serverOverride
775
+ ? `mcp({ search: "...", server: "${serverOverride}" })`
776
+ : `mcp({ search: "..." })`;
777
+ return {
778
+ error: {
778
779
  content: [{ type: "text" as const, text: `Tool "${toolName}" not found${scopeText}. Use ${searchHint} to search.${suggestionText}` }],
779
780
  details: { mode: "describe", error: "tool_not_found", server: serverOverride, requestedTool: toolName, suggestions },
780
- };
781
- }
781
+ },
782
+ };
783
+ }
782
784
 
785
+ export function executeDescribe(state: McpExtensionState, toolName: string, serverOverride?: string): ProxyToolResult {
786
+ const target = resolveDescribeTarget(state, toolName, serverOverride);
787
+ if ("error" in target) return target.error;
788
+ const { server: serverName, tool: toolMeta } = target;
783
789
  const approvalMarker = isToolCallApprovalRequired(state.config, serverName, toolMeta, state.toolMetadata)
784
790
  ? " (requires approval)"
785
791
  : "";
@@ -804,7 +810,7 @@ export function executeDescribe(state: McpExtensionState, toolName: string, serv
804
810
  const observed = toolMeta.resourceUri ? undefined : getObservedOutput(state, serverName, toolMeta);
805
811
  if (observed) {
806
812
  const source = observed.source === "structuredContent" ? "structuredContent" : "JSON text result";
807
- text += `\n\nObserved output (${source}, from ${observed.calls} call${observed.calls === 1 ? "" : "s"} this session, not a contract):\n${renderOutputShape(observed.shape)}`;
813
+ text += `\n\nObserved output (${source}, from earlier calls, not a contract):\n${renderOutputShape(observed.shape)}`;
808
814
  }
809
815
 
810
816
  return {
@@ -885,81 +891,66 @@ function renderSearchResults(
885
891
  };
886
892
  }
887
893
 
888
- export function executeSearch(
889
- state: McpExtensionState,
890
- query: string,
891
- regex?: boolean,
892
- server?: string,
893
- includeSchemas?: boolean,
894
- limit = 12,
895
- offset = 0,
896
- searchMode: "lexical" | "semantic" = "lexical",
897
- signal?: AbortSignal,
898
- semanticEvaluator?: SemanticSearchEvaluator,
899
- ): ProxyToolResult | Promise<ProxyToolResult> {
900
- const showSchemas = includeSchemas !== false;
901
- if ((searchMode as string) !== "lexical" && (searchMode as string) !== "semantic") {
902
- return {
903
- content: [{ type: "text" as const, text: "Search mode must be lexical or semantic." }],
904
- details: { mode: "search", error: "invalid_search_mode", query },
905
- };
894
+ export interface ToolSearchInput {
895
+ query: string;
896
+ regex?: boolean | undefined;
897
+ server?: string | undefined;
898
+ searchMode?: unknown;
899
+ signal?: AbortSignal | undefined;
900
+ semanticEvaluator?: SemanticSearchEvaluator | undefined;
901
+ observedSources?: readonly string[];
902
+ }
903
+
904
+ /** Ranked matches, unpaginated; each surface pages and renders them its own way. */
905
+ export type ToolSearchOutcome =
906
+ | { matches: RankedToolMatch[]; backend?: SemanticSearchBackend }
907
+ | { error: ProxyToolResult };
908
+
909
+ export function findTools(state: McpExtensionState, input: ToolSearchInput): ToolSearchOutcome | Promise<ToolSearchOutcome> {
910
+ const { query, regex, server, searchMode = "lexical" } = input;
911
+ const failure = (text: string, details: Record<string, unknown>): ToolSearchOutcome => ({
912
+ error: { content: [{ type: "text" as const, text }], details: { mode: "search", ...details } },
913
+ });
914
+ if (searchMode !== "lexical" && searchMode !== "semantic") {
915
+ return failure("Search mode must be lexical or semantic.", { error: "invalid_search_mode", query });
906
916
  }
907
- if (server && isServerDisabled(state.config.mcpServers[server])) return disabledResult(state, "search", server);
908
- if (server && isServerInActiveFailureBackoff(state, server)) return serverBackoffResult(state, "search", server);
917
+ if (server && isServerDisabled(state.config.mcpServers[server])) return { error: disabledResult(state, "search", server) };
918
+ if (server && isServerInActiveFailureBackoff(state, server)) return { error: serverBackoffResult(state, "search", server) };
909
919
  if (searchMode === "semantic" && regex) {
910
- return {
911
- content: [{ type: "text" as const, text: "Semantic search cannot be combined with regex search." }],
912
- details: { mode: "search", error: "invalid_search_mode", query },
913
- };
920
+ return failure("Semantic search cannot be combined with regex search.", { error: "invalid_search_mode", query });
914
921
  }
915
922
  if (searchMode === "semantic") {
916
- return semanticSearch(state, query, server, signal, semanticEvaluator).then(result => {
917
- if (!result.ok) {
918
- return {
919
- content: [{ type: "text" as const, text: `Semantic search failed: ${result.error.message}` }],
920
- details: { mode: "search", error: result.error.code, message: result.error.message, query },
921
- };
922
- }
923
- return renderSearchResults(state, query, server, showSchemas, limit, offset, result.matches, result.backend);
924
- });
923
+ return semanticSearch(state, query, server, input.signal, input.semanticEvaluator, input.observedSources).then(result => result.ok
924
+ ? { matches: result.matches, backend: result.backend }
925
+ : failure(`Semantic search failed: ${result.error.message}`, { error: result.error.code, message: result.error.message, query }));
925
926
  }
926
927
 
927
- let matches: Array<{ server: string; tool: ToolMetadata; score: number }>;
928
928
  if (regex) {
929
+ if (query.length > MAX_REGEX_SEARCH_QUERY_LENGTH) {
930
+ return failure(
931
+ `Regex query is too long; maximum length is ${MAX_REGEX_SEARCH_QUERY_LENGTH} characters.`,
932
+ { error: "query_too_long", query, maxLength: MAX_REGEX_SEARCH_QUERY_LENGTH },
933
+ );
934
+ }
929
935
  let pattern: RegExp;
930
936
  try {
931
- if (query.length > MAX_REGEX_SEARCH_QUERY_LENGTH) {
932
- return {
933
- content: [{ type: "text" as const, text: `Regex query is too long; maximum length is ${MAX_REGEX_SEARCH_QUERY_LENGTH} characters.` }],
934
- details: { mode: "search", error: "query_too_long", query, maxLength: MAX_REGEX_SEARCH_QUERY_LENGTH },
935
- };
936
- }
937
937
  pattern = new RegExp(query, "i");
938
- let safety;
939
- try {
940
- const { checkSync } = require("recheck") as typeof import("recheck");
941
- safety = checkSync(query, "i", REGEX_SAFETY_CHECK_PARAMS);
942
- } catch (error) {
943
- const reason = error instanceof Error ? error.message : String(error);
944
- return {
945
- content: [{ type: "text" as const, text: "Regex query rejected because safety analysis failed." }],
946
- details: { mode: "search", error: "unsafe_pattern", query, reason },
947
- };
948
- }
949
- if (safety.status !== "safe") {
950
- return {
951
- content: [{ type: "text" as const, text: `Regex query rejected as unsafe (${safety.status}).` }],
952
- details: { mode: "search", error: "unsafe_pattern", query, safetyStatus: safety.status },
953
- };
954
- }
955
938
  } catch {
956
- return {
957
- content: [{ type: "text" as const, text: `Invalid regex: ${query}` }],
958
- details: { mode: "search", error: "invalid_pattern", query },
959
- };
939
+ return failure(`Invalid regex: ${query}`, { error: "invalid_pattern", query });
940
+ }
941
+ let safety;
942
+ try {
943
+ const { checkSync } = require("recheck") as typeof import("recheck");
944
+ safety = checkSync(query, "i", REGEX_SAFETY_CHECK_PARAMS);
945
+ } catch (error) {
946
+ const reason = error instanceof Error ? error.message : String(error);
947
+ return failure("Regex query rejected because safety analysis failed.", { error: "unsafe_pattern", query, reason });
948
+ }
949
+ if (safety.status !== "safe") {
950
+ return failure(`Regex query rejected as unsafe (${safety.status}).`, { error: "unsafe_pattern", query, safetyStatus: safety.status });
960
951
  }
961
952
 
962
- matches = [];
953
+ const matches: RankedToolMatch[] = [];
963
954
  const globalPrefix = state.config.settings?.toolPrefix ?? "server";
964
955
  for (const [serverName, metadata] of state.toolMetadata.entries()) {
965
956
  const definition = state.config.mcpServers[serverName];
@@ -972,21 +963,37 @@ export function executeSearch(
972
963
  if (matched) matches.push({ server: serverName, tool, score: 0 });
973
964
  }
974
965
  }
975
- } else if (query.trim().length === 0) {
976
- if (!server) {
977
- return {
978
- content: [{ type: "text" as const, text: "Search query cannot be empty" }],
979
- details: { mode: "search", error: "empty_query" },
980
- };
981
- }
982
- matches = (state.toolMetadata.get(server) ?? [])
983
- .map(tool => ({ server, tool, score: 0 }))
984
- .sort((a, b) => a.tool.name.localeCompare(b.tool.name));
985
- } else {
986
- matches = rankToolMatches(state, query, server);
966
+ return { matches };
967
+ }
968
+
969
+ if (query.trim().length === 0) {
970
+ if (!server) return failure("Search query cannot be empty", { error: "empty_query" });
971
+ return {
972
+ matches: (state.toolMetadata.get(server) ?? [])
973
+ .map(tool => ({ server, tool, score: 0 }))
974
+ .sort((a, b) => a.tool.name.localeCompare(b.tool.name)),
975
+ };
987
976
  }
977
+ return { matches: rankToolMatches(state, query, server) };
978
+ }
988
979
 
989
- return renderSearchResults(state, query, server, showSchemas, limit, offset, matches);
980
+ export function executeSearch(
981
+ state: McpExtensionState,
982
+ query: string,
983
+ regex?: boolean,
984
+ server?: string,
985
+ includeSchemas?: boolean,
986
+ limit = 12,
987
+ offset = 0,
988
+ searchMode: "lexical" | "semantic" = "lexical",
989
+ signal?: AbortSignal,
990
+ semanticEvaluator?: SemanticSearchEvaluator,
991
+ ): ProxyToolResult | Promise<ProxyToolResult> {
992
+ const render = (outcome: ToolSearchOutcome): ProxyToolResult => "error" in outcome
993
+ ? outcome.error
994
+ : renderSearchResults(state, query, server, includeSchemas !== false, limit, offset, outcome.matches, outcome.backend);
995
+ const outcome = findTools(state, { query, regex, server, searchMode, signal, semanticEvaluator });
996
+ return outcome instanceof Promise ? outcome.then(render) : render(outcome);
990
997
  }
991
998
 
992
999
  export function executeList(state: McpExtensionState, server: string): ProxyToolResult {
@@ -1178,6 +1185,12 @@ export async function executeConnect(state: McpExtensionState, serverName: strin
1178
1185
  }
1179
1186
  }
1180
1187
 
1188
+ /** Whether executeCall without a server resolves this exact displayed tool name to serverName (its first, exact-name lookup). */
1189
+ export function unscopedCallReachesServer(state: McpExtensionState, toolName: string, serverName: string): boolean {
1190
+ const matches = getEnabledToolMatches(state, toolName, true);
1191
+ return matches.length === 1 && matches[0]!.server === serverName;
1192
+ }
1193
+
1181
1194
  export async function executeCall(
1182
1195
  state: McpExtensionState,
1183
1196
  toolName: string,
@@ -1636,6 +1649,7 @@ export async function executeCall(
1636
1649
  : null;
1637
1650
 
1638
1651
  const requestMeta = withToolCallIdMeta(uiSession?.requestMeta, toolCallId);
1652
+ const recordOutput = observedOutputRecorder(state, serverName, toolMeta.originalName);
1639
1653
  const result = await withSessionRecovery<ClientCallToolResult>(
1640
1654
  {
1641
1655
  manager: state.manager,
@@ -1662,7 +1676,7 @@ export async function executeCall(
1662
1676
  }, requestOptions), ownedSignal);
1663
1677
  },
1664
1678
  );
1665
- if (!result.isError) recordObservedOutput(state, serverName, toolMeta.originalName, result as Record<string, unknown>);
1679
+ if (!result.isError) recordOutput(result as Record<string, unknown>);
1666
1680
 
1667
1681
  if (toolMeta.uiResourceUri) {
1668
1682
  uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/client").CallToolResult);
@@ -1714,7 +1728,9 @@ export async function executeCall(
1714
1728
 
1715
1729
  const content = resolveMcpResultContent(result as Record<string, unknown>, state.owner?.signal);
1716
1730
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1717
- const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, rawMcpResult: result });
1731
+ // Runtime event-bus callers ("script" origin) are extensions, not the model, so they get no hint.
1732
+ const pipeHint = scriptPipeHint(origin !== "script" && state.scriptTool, outputContent);
1733
+ const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, ...pipeHint, rawMcpResult: result });
1718
1734
  return {
1719
1735
  content: guarded.content,
1720
1736
  details: { mode: "call", ...guardedMcpDetails(guarded), ...callIdentity },
package/server-manager.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type SubscriptionFilter,
19
19
  type CacheableRequestOptions,
20
20
  type RequestOptions,
21
+ RegistrationRejectedError,
21
22
  type UrlElicitationRequiredError,
22
23
  type VersionNegotiationOptions,
23
24
  } from "@modelcontextprotocol/client";
@@ -43,7 +44,7 @@ import { logger } from "./logger.ts";
43
44
  import { RESOURCE_MIME_TYPE } from "./ui-app-bridge-helpers.ts";
44
45
  import { isBuiltInAgentPlugin } from "./agent-plugin-provenance.ts";
45
46
  import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
46
- import { extractOAuthConfig, supportsOAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
47
+ import { explainRegistrationRejection, extractOAuthConfig, supportsOAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
47
48
  import {
48
49
  captureOAuthAuthority,
49
50
  inspectAuthForUrl,
@@ -110,17 +111,22 @@ function isLiteralLocalAddress(url: string): boolean {
110
111
  return local.check(hostname, family === 6 ? "ipv6" : "ipv4");
111
112
  }
112
113
 
113
- function localNetworkFailureCodes(error: unknown, seen = new Set<object>()): string[] {
114
+ function isLoopbackUrl(url: string): boolean {
115
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
116
+ }
117
+
118
+ const LOCAL_NETWORK_FAILURE_CODES = ["EHOSTUNREACH", "ENETUNREACH", "EACCES"];
119
+
120
+ function networkFailureCodes(error: unknown, wanted: readonly string[], seen = new Set<object>()): string[] {
114
121
  if (typeof error !== "object" || error === null || seen.has(error)) return [];
115
122
  seen.add(error);
116
123
  const codes: string[] = [];
117
- if ("code" in error && typeof error.code === "string"
118
- && ["EHOSTUNREACH", "ENETUNREACH", "EACCES"].includes(error.code)) {
124
+ if ("code" in error && typeof error.code === "string" && wanted.includes(error.code)) {
119
125
  codes.push(error.code);
120
126
  }
121
- if ("cause" in error) codes.push(...localNetworkFailureCodes(error.cause, seen));
127
+ if ("cause" in error) codes.push(...networkFailureCodes(error.cause, wanted, seen));
122
128
  if (error instanceof AggregateError) {
123
- for (const nested of error.errors) codes.push(...localNetworkFailureCodes(nested, seen));
129
+ for (const nested of error.errors) codes.push(...networkFailureCodes(nested, wanted, seen));
124
130
  }
125
131
  return [...new Set(codes)];
126
132
  }
@@ -1287,9 +1293,15 @@ export class McpServerManager {
1287
1293
  }
1288
1294
 
1289
1295
  private async enrichHttpConnectionError(definition: ServerDefinition, error: unknown): Promise<Error> {
1296
+ // The transport's OAuth provider can register a client during connect, bypassing startAuth.
1297
+ if (error instanceof RegistrationRejectedError) return explainRegistrationRejection(error, resolveServerUrl(definition)!);
1290
1298
  const originalMessage = error instanceof Error ? error.message : String(error);
1299
+ if (networkFailureCodes(error, ["ECONNREFUSED"]).length > 0 && isLoopbackUrl(resolveServerUrl(definition)!)) {
1300
+ const url = new URL(resolveServerUrl(definition)!);
1301
+ return new Error(`${originalMessage} — Nothing is listening at ${url.origin}${url.pathname}. Start the app or local process that serves this MCP server.`, { cause: error });
1302
+ }
1291
1303
  if (process.platform === "darwin") {
1292
- const codes = localNetworkFailureCodes(error);
1304
+ const codes = networkFailureCodes(error, LOCAL_NETWORK_FAILURE_CODES);
1293
1305
  if (codes.length > 0 && isLiteralLocalAddress(resolveServerUrl(definition)!)) {
1294
1306
  return new Error(`${originalMessage} — ${codes.join(", ")} — macOS Local Network Privacy may be blocking access. Check System Settings > Privacy & Security > Local Network for the app hosting Pi; enable access if listed and restart it. Try launching Pi from Terminal.app or SSH. Routing or firewall problems can also cause this error.`, { cause: error });
1295
1307
  }
@@ -1422,8 +1434,7 @@ export class McpServerManager {
1422
1434
  if (!connection || connection.client !== client || connection.status !== "connected") return;
1423
1435
  connection.tools = tools;
1424
1436
  connection.toolsRevision = (connection.toolsRevision ?? 0) + 1;
1425
- this.metadataListChangedListener?.(serverName, "tools-list-changed");
1426
- this.pendingMetadataPublications.delete(serverName);
1437
+ this.publishMetadataChanged(serverName, connection, "tools-list-changed");
1427
1438
  }
1428
1439
 
1429
1440
  private handlePromptsListChanged(
@@ -1441,8 +1452,7 @@ export class McpServerManager {
1441
1452
  if (!connection || connection.client !== client || connection.status !== "connected") return;
1442
1453
  connection.prompts = prompts;
1443
1454
  connection.promptDiscoveryFailed = false;
1444
- this.metadataListChangedListener?.(serverName, "prompts-list-changed");
1445
- this.pendingMetadataPublications.delete(serverName);
1455
+ this.publishMetadataChanged(serverName, connection, "prompts-list-changed");
1446
1456
  }
1447
1457
 
1448
1458
  private handleResourcesListChanged(
@@ -1460,8 +1470,7 @@ export class McpServerManager {
1460
1470
  if (!connection || connection.client !== client || connection.status !== "connected") return;
1461
1471
  connection.resources = resources;
1462
1472
  connection.resourceDiscoveryFailed = false;
1463
- this.metadataListChangedListener?.(serverName, "resources-list-changed");
1464
- this.pendingMetadataPublications.delete(serverName);
1473
+ this.publishMetadataChanged(serverName, connection, "resources-list-changed");
1465
1474
  }
1466
1475
 
1467
1476
  async handleUrlElicitationRequired(
@@ -1631,13 +1640,17 @@ export class McpServerManager {
1631
1640
  > => {
1632
1641
  const authProvider = "provider" in authState ? authState.provider : undefined;
1633
1642
  let sseFetchFailure: unknown;
1634
- const transportFetch: FetchLike | undefined = kind === "sse" && process.platform === "darwin" && isLiteralLocalAddress(serverUrl)
1643
+ const sseFailureCodes = kind !== "sse" ? []
1644
+ : isLoopbackUrl(serverUrl) ? ["ECONNREFUSED"]
1645
+ : process.platform === "darwin" && isLiteralLocalAddress(serverUrl) ? LOCAL_NETWORK_FAILURE_CODES
1646
+ : [];
1647
+ const transportFetch: FetchLike | undefined = sseFailureCodes.length > 0
1635
1648
  ? async (input, init) => {
1636
1649
  try {
1637
1650
  return await (requestFetch ?? globalThis.fetch)(input, init);
1638
1651
  } catch (error) {
1639
1652
  // EventSource discards the fetch cause before the SDK creates SseError.
1640
- if (localNetworkFailureCodes(error).length > 0) sseFetchFailure = error;
1653
+ if (networkFailureCodes(error, sseFailureCodes).length > 0) sseFetchFailure = error;
1641
1654
  throw error;
1642
1655
  }
1643
1656
  }
@@ -1,51 +1,45 @@
1
1
  ---
2
2
  name: mcp-scripting
3
- description: Write mcpScript JavaScript for discovering, inspecting, and calling MCP tools.
3
+ description: Read before writing mcpScript code. Covers tool discovery, the shape of call results (including JSON returned as text), and script limits.
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
7
7
  # MCP scripting
8
8
 
9
- For multi-call MCP work, write ordinary JavaScript with loops, filtering, chaining, fan-out, or other logic between calls. Run that source with `mcpScript`; it is the primary MCP orchestration surface. For a single MCP search, describe, status check, auth action, or tool call, use `mcp` instead.
9
+ Use `mcpScript` when a request needs several MCP calls with logic between them, or when one call's output should go straight into another call. For a single search, describe, status check, auth action, or call, use `mcp`.
10
10
 
11
- Write the source naturally, then pass it as `mcpScript`'s `code` argument:
11
+ Only what the script emits, logs, or returns enters the conversation, so keep intermediate data in variables and return a small result:
12
12
 
13
13
  ```js
14
- const { items } = await tools.search({ query: "search issues", server: "github" });
15
- const candidate = items[0];
16
- if (!candidate) return { error: "No matching tool" };
14
+ const found = await tools.search({ query: "list issues", server: "github" });
15
+ const path = found.items[0]?.path;
16
+ if (!path) return { error: "No matching tool" };
17
17
 
18
- const details = await tools.describe({ path: candidate.path });
19
- if (details.error) return details;
20
-
21
- const result = await tools.call(details.path, { query: "is:open label:bug" });
18
+ const result = await tools.call(path, { state: "open" });
22
19
  if (!result.ok) return result;
23
- emit({ tool: details.path, completed: true });
24
- return result.data;
20
+ const issues = result.data.structuredContent ?? JSON.parse(result.data.content[0].text);
21
+ return issues.filter((issue) => issue.comments === 0).map((issue) => issue.number);
25
22
  ```
26
23
 
27
- ## Workflow
28
-
29
- 1. Find candidate tools with `await tools.search({ query, server?, limit?, offset? })`.
30
- 2. Inspect the exact returned path with `await tools.describe({ path })`.
31
- 3. Call it with `tools.call(path, args)`.
32
-
33
- Descriptors include `inputTypeScript` (a compact parameter shape, or formatted schema fallback). When a compact shape would omit documented fields, `inputGuidance` preserves their descriptions, including formats and units. Undocumented inputs stay compact.
34
-
35
- When advertised by the server, `outputSchema` is the original JSON Schema and `outputSchemaTarget` is `"data.structuredContent"`: it describes structured output inside the successful `{ ok: true, data }` call envelope, not the envelope itself. Inspect this schema for result fields and constraints; unsupported constructs remain intact rather than being presented as an approximate TypeScript type. Both output fields are absent when no output schema is advertised. Discovery and cache refresh preserve these optional schemas; old cache entries gain them on the next server metadata refresh. Ordinary search results do not include schemas.
36
-
37
- When no output schema is advertised but the tool already returned structured content or a JSON text result earlier in this session, `observedOutput` gives `{ target, typeScript, calls }`: the path inside the call envelope (`"data.structuredContent"` or `"JSON.parse(data.content[0].text)"`), the field names and types seen so far, and how many calls they came from. It is a hint, not a contract: fields missing from some results are optional, and wide objects appear as `Record<string, ...>`.
24
+ ## API
38
25
 
39
- Calls resolve to `{ ok: true, data }` or `{ ok: false, error }`; handle failed calls instead of expecting them to stop the script. `emit(value)` adds user-visible output before the final `return` value. `console` output is captured too.
26
+ - `await tools.search({ query, server?, regex?, searchMode?, limit?, offset? })` returns `{ items: [{ path, name, server, description? }], total, hasMore, nextOffset }`, plus `error: { code, message }` when the search cannot run. It is the same search as `mcp({ search })`; an empty `query` with `server` lists that server's tools. Filter the items in code; follow `nextOffset` for more than one page.
27
+ - `await tools.describe({ path, server? })` returns `inputTypeScript` (plus `inputGuidance` when documented fields would otherwise be lost) and optional `annotations`. When the server declares an `outputSchema`, it describes `data.structuredContent`. Otherwise, once the tool has returned JSON (in this or an earlier `mcpScript` session), `observedOutput` gives `{ target, typeScript }`: where the JSON is, and the field names and types seen so far. It is a hint, not a contract.
28
+ - `await tools.call(path, args, { server }?)` returns `{ ok: true, data }` or `{ ok: false, error: { code, message } }`. A failed call does not stop the script. When two servers share a tool name, pass the hit's `server` to `describe` and `call`.
29
+ - Known paths can be called directly and resolve the same `{ ok, data }` way: `tools.github_search_issues(args)`, or `tools["server_tool-name"](args)` for hyphenated names. `tools` cannot be enumerated. `search`, `call`, `describe`, `then`, `catch`, `finally`, `toJSON`, `toString`, and `valueOf` are reserved; call a colliding path with `tools.call`.
30
+ - `emit(value)` adds output before the final `return` value; `console` output is captured too.
40
31
 
41
- With `settings.jev.scriptEvaluation` enabled, call `jev.evaluate({ state, questions, sources })`; declare every MCP server represented in state. The host conservatively taints the whole script with every server-attributed call result or error, so every declared or observed source must remain enabled and allowed for later direct evaluations and semantic searches. Semantic search requires `searchMode: "semantic"`. Direct and semantic attempts share count, UTF-8 request-byte, deadline, and provider-reported token budgets. Scores do not bypass `tools.call` approval. Action loops must use fresh observations, positive step/time/evaluation budgets, validated operations and targets, and stop on uncertainty, staleness, no progress, no match, or missing information. Do not retry a possibly side-effectful action.
32
+ ## Reading results
42
33
 
43
- On success, `data` may still be the raw MCP `CallToolResult` envelope rather than the domain payload. Check `data.structuredContent` for the fields your script expects; if they are absent, inspect text blocks in `data.content` too (some servers emit newline-delimited JSON). If neither shape is understood, return or emit the envelope for inspection instead of coercing it to `[]` or `{}`.
34
+ A tool call's `data` is the raw MCP `CallToolResult` (`{ content, structuredContent?, isError? }`), not the domain payload; resource reads return text. Use `data.structuredContent` when present. Otherwise most JSON APIs return their payload as text, so parse `data.content[0].text` (some servers emit newline-delimited JSON). If neither shape is understood, emit the envelope for inspection instead of coercing it to `[]` or `{}`.
44
35
 
45
- `tools` is a non-enumerable proxy: `Object.keys(tools)` throws. Always use `tools.search` for discovery. When a known flat path is a valid identifier, direct calls such as `tools.github_search_issues(args)` are supported; use bracket syntax for hyphenated names: `tools["server_tool-name"](args)`. `search`, `call`, `describe`, and promise/serialization names (`then`, `catch`, `finally`, `toJSON`, `toString`, `valueOf`) are reserved on the proxy; if a flat path collides with one, call it via `tools.call("exact-path", args)`.
36
+ Write the real script first instead of spending a turn looking at a result. Use `observedOutput` when describe has it; otherwise use the tool description and the field names the API most likely uses. Before a loop that writes (comments, closes, creates), check that the first item has the fields you filter on and throw if it does not, so a wrong guess stops before it changes anything. When a script throws, times out, or returns `[]`, `{}`, `null`, or `""`, its result lists the fields seen from the tools it called; fix the script from that. Never emit a whole list to inspect it.
46
37
 
47
- Successful intermediate data bypasses presentation truncation, summarization, and artifact spill until the script emits, logs, or returns it. A fixed, non-configurable **16 MiB cumulative UTF-8 JSON transfer budget per script** covers sequential and parallel calls. A result exceeding the remaining budget returns `{ ok: false, error: { code: "intermediate_result_too_large", message } }` with a failed call trace; rejected calls do not consume the budget, but upstream side effects may already have happened. Resource calls retain transformed text or `"(empty resource)"`; emitted, logged, or returned output and ordinary MCP calls remain guarded. This is not a memory limit: responses, serialization, copies, concurrency, and script values still allocate memory, and synchronous serialization can delay deadline handling.
38
+ ## Limits
48
39
 
49
- `tools.search` and `tools.describe` are asynchronous and must be awaited. The default script timeout is 30 seconds; the worker is terminated at the deadline, including for infinite loops. Every invocation still uses normal lazy connection, authentication, and approval gates. Result details contain a concise `calls` trace with every search, describe, and call operation; each entry includes its query or path, outcome, and duration.
40
+ - Scripts time out after 30 seconds by default (`timeoutMs` changes it). The worker is stopped at the deadline, including infinite loops.
41
+ - Intermediate results share a fixed 16 MiB transfer budget per script. A result that does not fit returns `{ ok: false, error: { code: "intermediate_result_too_large" } }`, and the upstream call may still have had side effects.
42
+ - Every call goes through the normal connection, auth, and approval gates. The result details list each search, describe, and call with its outcome and duration.
43
+ - There are no fluent helpers such as `tools.find`, `tools.parallel`, or `tools.retry`; use loops and `Promise.all`.
50
44
 
51
- Use plain JavaScript loops and Promise utilities for composition. Fluent helpers such as `tools.find(...).one()`, `tools.parallel(...)`, and `tools.retry(...)` are not provided.
45
+ When `settings.jev.scriptEvaluation` is enabled, read [references/jev.md](references/jev.md) before calling `jev.evaluate`.
@@ -0,0 +1,3 @@
1
+ # jev.evaluate in mcpScript
2
+
3
+ With `settings.jev.scriptEvaluation` enabled, call `jev.evaluate({ state, questions, sources })`; declare every MCP server represented in state. The host conservatively taints the whole script with every server-attributed call result or error, so every declared or observed source must remain enabled and allowed for later direct evaluations and semantic searches. Semantic search requires `searchMode: "semantic"`. Direct and semantic attempts share count, UTF-8 request-byte, deadline, and provider-reported token budgets. Scores do not bypass `tools.call` approval. Action loops must use fresh observations, positive step/time/evaluation budgets, validated operations and targets, and stop on uncertainty, staleness, no progress, no match, or missing information. Do not retry a possibly side-effectful action.
package/state.ts CHANGED
@@ -60,6 +60,8 @@ export interface McpExtensionState {
60
60
  observedOutputs?: WeakMap<ServerDefinition, Map<string, ObservedOutput>>;
61
61
  /** Configured project servers disabled by trust or approval policy for this session. */
62
62
  blockedProjectServers?: Map<string, ProjectServerBlock>;
63
+ /** mcpScript is registered. Fixed when Pi loads the adapter, so a session's own config cannot change it. */
64
+ scriptTool?: boolean;
63
65
  /** Runtime-only server grants. Never persisted or restored from session entries. */
64
66
  approvedServers?: Map<string, { definition: ServerDefinition; hash: string }>;
65
67
  /** Optional active-session sink for approval decisions. */
package/types.ts CHANGED
@@ -634,8 +634,10 @@ export interface McpSettings {
634
634
  directToolResultDetails?: "lean" | "bounded";
635
635
  /** Show the advisory when 75 or more direct tools resolve. Defaults to true. */
636
636
  warnOnLargeDirectTools?: boolean;
637
- /** Register the trusted MCP-only JavaScript scripting tool. Defaults to true; set false to hide it. */
637
+ /** Register the MCP-only JavaScript scripting tool and its manual skill. Defaults to false. */
638
638
  scriptMode?: boolean;
639
+ /** `"model"` points the model at the mcp-scripting skill from the mcpScript description. Defaults to `"manual"`: `/skill:mcp-scripting` only. */
640
+ scriptSkill?: "manual" | "model";
639
641
  /** Expose MCP resources as tools (default: true). Set to false to disable globally across all servers. */
640
642
  exposeResources?: boolean;
641
643
  /** Optional Jev (System One) integrations. A valid key enables semantic search; script evaluation remains disabled by default. */
@@ -818,6 +820,11 @@ export interface ServerCacheEntry {
818
820
  /** Server-level hints from the aggregated tools/list result. */
819
821
  ttlMs?: ListToolsResult["ttlMs"];
820
822
  cacheScope?: ListToolsResult["cacheScope"];
823
+ /**
824
+ * Result shapes (field names and types, never values) seen from tools without an outputSchema,
825
+ * keyed by original tool name. Dropped when the server config or that tool's description or input schema changes.
826
+ */
827
+ outputShapes?: Record<string, { source: "structuredContent" | "jsonText"; shape: unknown }>;
821
828
  cachedAt: number;
822
829
  }
823
830