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/proxy-modes.ts CHANGED
@@ -9,15 +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, observedOutputRecorder, renderOutputShape } from "./output-shape.ts";
12
13
  import { reconstructPromptMetadata } from "./metadata-cache.ts";
13
14
  import { resolveMcpResultContent, transformMcpResourceContents } from "./tool-registrar.ts";
14
- import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
15
+ import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions, scriptPipeHint } from "./mcp-output-guard.ts";
15
16
  import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
16
17
  import { formatAuthRequiredMessage, formatMcpStatus, normalizeToolArguments, resolveServerUrl, truncateAtWord, withToolCallIdMeta } from "./utils.ts";
17
18
  import { authenticate, completeAuthFromInput, getAuthStatus, startAuth, supportsOAuth } from "./mcp-auth-flow.ts";
18
19
  import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.ts";
19
20
  import { callToolViaTaskSession } from "./mcp-tasks.ts";
20
- import { paginate, rankSuggestions, rankToolMatches, resolveSearchKeywords } from "./search-ranking.ts";
21
+ import { callToolPausingForElicitation } from "./elicitation-handler.ts";
22
+ import { paginate, rankSuggestions, rankToolMatches, resolveSearchKeywords, type RankedToolMatch } from "./search-ranking.ts";
21
23
  import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.ts";
22
24
  import { describeFailure, isServerInActiveFailureBackoff } from "./failure-backoff.ts";
23
25
  import { semanticSearch, type SemanticSearchBackend, type SemanticSearchEvaluator } from "./semantic-search.ts";
@@ -704,26 +706,44 @@ export async function executeAuthComplete(state: McpExtensionState, serverName:
704
706
  }
705
707
  }
706
708
 
707
- export function executeDescribe(state: McpExtensionState, toolName: string, serverOverride?: string): ProxyToolResult {
708
- let serverName: string | undefined;
709
- let toolMeta: ToolMetadata | undefined;
710
- let disabledMatch: string | undefined;
711
- let failedMatch: string | undefined;
709
+ function formatToolHints(toolMeta: ToolMetadata): string {
710
+ const annotations = toolMeta.annotations;
711
+ if (!annotations) return "";
712
+ const hints: string[] = [];
713
+ const flag = (value: boolean | undefined, yes: string, no: string) => {
714
+ if (value !== undefined) hints.push(value ? yes : no);
715
+ };
716
+ flag(annotations.readOnlyHint, "read-only", "not read-only");
717
+ flag(annotations.destructiveHint, "destructive", "non-destructive");
718
+ flag(annotations.idempotentHint, "idempotent", "not idempotent");
719
+ flag(annotations.openWorldHint, "open-world", "closed-world");
720
+ const title = annotations.title?.trim();
721
+ if (title && title !== toolMeta.originalName && title !== toolMeta.name) hints.push(`title ${JSON.stringify(title)}`);
722
+ return hints.join(", ");
723
+ }
712
724
 
725
+ export function resolveDescribeTarget(
726
+ state: McpExtensionState,
727
+ toolName: string,
728
+ serverOverride?: string,
729
+ ): { server: string; tool: ToolMetadata } | { error: ProxyToolResult } {
713
730
  if (serverOverride) {
714
731
  if (!state.config.mcpServers[serverOverride]) {
715
732
  return {
716
- content: [{ type: "text" as const, text: `Server "${serverOverride}" not found. Use mcp({}) to see available servers.` }],
717
- 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
+ },
718
737
  };
719
738
  }
720
739
  const match = getServerScopedToolMatch(state.toolMetadata.get(serverOverride), toolName);
721
- if (match === "ambiguous") return ambiguousServerToolResult("describe", toolName, serverOverride);
722
- if (isServerDisabled(state.config.mcpServers[serverOverride])) return disabledResult(state, "describe", serverOverride);
723
- if (isServerInActiveFailureBackoff(state, serverOverride)) return serverBackoffResult(state, "describe", serverOverride);
724
- serverName = serverOverride;
725
- 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 };
726
744
  } else {
745
+ let disabledMatch: string | undefined;
746
+ let failedMatch: string | undefined;
727
747
  const matches: Array<{ server: string; tool: ToolMetadata; precedence: number }> = [];
728
748
  for (const [server, metadata] of state.toolMetadata.entries()) {
729
749
  const candidates = getServerScopedToolCandidates(metadata, toolName);
@@ -741,27 +761,31 @@ export function executeDescribe(state: McpExtensionState, toolName: string, serv
741
761
  if (matches.length > 0) {
742
762
  const precedence = Math.min(...matches.map(match => match.precedence));
743
763
  const bestMatches = matches.filter(match => match.precedence === precedence);
744
- if (bestMatches.length > 1) return ambiguousToolResult("describe", toolName);
745
- serverName = bestMatches[0]!.server;
746
- toolMeta = bestMatches[0]!.tool;
764
+ if (bestMatches.length > 1) return { error: ambiguousToolResult("describe", toolName) };
765
+ return { server: bestMatches[0]!.server, tool: bestMatches[0]!.tool };
747
766
  }
767
+ if (disabledMatch) return { error: disabledResult(state, "describe", disabledMatch) };
768
+ if (failedMatch) return { error: serverBackoffResult(state, "describe", failedMatch) };
748
769
  }
749
770
 
750
- if (!serverName || !toolMeta) {
751
- if (disabledMatch) return disabledResult(state, "describe", disabledMatch);
752
- if (failedMatch) return serverBackoffResult(state, "describe", failedMatch);
753
- const suggestions = rankSuggestions(state, toolName, 5, serverOverride);
754
- const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}` : "";
755
- const scopeText = serverOverride ? ` on server "${serverOverride}"` : "";
756
- const searchHint = serverOverride
757
- ? `mcp({ search: "...", server: "${serverOverride}" })`
758
- : `mcp({ search: "..." })`;
759
- 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: {
760
779
  content: [{ type: "text" as const, text: `Tool "${toolName}" not found${scopeText}. Use ${searchHint} to search.${suggestionText}` }],
761
780
  details: { mode: "describe", error: "tool_not_found", server: serverOverride, requestedTool: toolName, suggestions },
762
- };
763
- }
781
+ },
782
+ };
783
+ }
764
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;
765
789
  const approvalMarker = isToolCallApprovalRequired(state.config, serverName, toolMeta, state.toolMetadata)
766
790
  ? " (requires approval)"
767
791
  : "";
@@ -770,6 +794,8 @@ export function executeDescribe(state: McpExtensionState, toolName: string, serv
770
794
  if (toolMeta.resourceUri) {
771
795
  text += `Type: Resource (reads from ${toolMeta.resourceUri})\n`;
772
796
  }
797
+ const hints = formatToolHints(toolMeta);
798
+ if (hints) text += `Hints: ${hints}\n`;
773
799
  text += `\n${toolMeta.description || "(no description)"}\n`;
774
800
 
775
801
  if (toolMeta.inputSchema && !toolMeta.resourceUri) {
@@ -781,6 +807,12 @@ export function executeDescribe(state: McpExtensionState, toolName: string, serv
781
807
  text += `\nNo parameters defined.`;
782
808
  }
783
809
 
810
+ const observed = toolMeta.resourceUri ? undefined : getObservedOutput(state, serverName, toolMeta);
811
+ if (observed) {
812
+ const source = observed.source === "structuredContent" ? "structuredContent" : "JSON text result";
813
+ text += `\n\nObserved output (${source}, from earlier calls, not a contract):\n${renderOutputShape(observed.shape)}`;
814
+ }
815
+
784
816
  return {
785
817
  content: [{ type: "text" as const, text: text.trim() }],
786
818
  details: { mode: "describe", tool: toolMeta, server: serverName },
@@ -859,81 +891,66 @@ function renderSearchResults(
859
891
  };
860
892
  }
861
893
 
862
- export function executeSearch(
863
- state: McpExtensionState,
864
- query: string,
865
- regex?: boolean,
866
- server?: string,
867
- includeSchemas?: boolean,
868
- limit = 12,
869
- offset = 0,
870
- searchMode: "lexical" | "semantic" = "lexical",
871
- signal?: AbortSignal,
872
- semanticEvaluator?: SemanticSearchEvaluator,
873
- ): ProxyToolResult | Promise<ProxyToolResult> {
874
- const showSchemas = includeSchemas !== false;
875
- if ((searchMode as string) !== "lexical" && (searchMode as string) !== "semantic") {
876
- return {
877
- content: [{ type: "text" as const, text: "Search mode must be lexical or semantic." }],
878
- details: { mode: "search", error: "invalid_search_mode", query },
879
- };
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 });
880
916
  }
881
- if (server && isServerDisabled(state.config.mcpServers[server])) return disabledResult(state, "search", server);
882
- 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) };
883
919
  if (searchMode === "semantic" && regex) {
884
- return {
885
- content: [{ type: "text" as const, text: "Semantic search cannot be combined with regex search." }],
886
- details: { mode: "search", error: "invalid_search_mode", query },
887
- };
920
+ return failure("Semantic search cannot be combined with regex search.", { error: "invalid_search_mode", query });
888
921
  }
889
922
  if (searchMode === "semantic") {
890
- return semanticSearch(state, query, server, signal, semanticEvaluator).then(result => {
891
- if (!result.ok) {
892
- return {
893
- content: [{ type: "text" as const, text: `Semantic search failed: ${result.error.message}` }],
894
- details: { mode: "search", error: result.error.code, message: result.error.message, query },
895
- };
896
- }
897
- return renderSearchResults(state, query, server, showSchemas, limit, offset, result.matches, result.backend);
898
- });
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 }));
899
926
  }
900
927
 
901
- let matches: Array<{ server: string; tool: ToolMetadata; score: number }>;
902
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
+ }
903
935
  let pattern: RegExp;
904
936
  try {
905
- if (query.length > MAX_REGEX_SEARCH_QUERY_LENGTH) {
906
- return {
907
- content: [{ type: "text" as const, text: `Regex query is too long; maximum length is ${MAX_REGEX_SEARCH_QUERY_LENGTH} characters.` }],
908
- details: { mode: "search", error: "query_too_long", query, maxLength: MAX_REGEX_SEARCH_QUERY_LENGTH },
909
- };
910
- }
911
937
  pattern = new RegExp(query, "i");
912
- let safety;
913
- try {
914
- const { checkSync } = require("recheck") as typeof import("recheck");
915
- safety = checkSync(query, "i", REGEX_SAFETY_CHECK_PARAMS);
916
- } catch (error) {
917
- const reason = error instanceof Error ? error.message : String(error);
918
- return {
919
- content: [{ type: "text" as const, text: "Regex query rejected because safety analysis failed." }],
920
- details: { mode: "search", error: "unsafe_pattern", query, reason },
921
- };
922
- }
923
- if (safety.status !== "safe") {
924
- return {
925
- content: [{ type: "text" as const, text: `Regex query rejected as unsafe (${safety.status}).` }],
926
- details: { mode: "search", error: "unsafe_pattern", query, safetyStatus: safety.status },
927
- };
928
- }
929
938
  } catch {
930
- return {
931
- content: [{ type: "text" as const, text: `Invalid regex: ${query}` }],
932
- details: { mode: "search", error: "invalid_pattern", query },
933
- };
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 });
934
951
  }
935
952
 
936
- matches = [];
953
+ const matches: RankedToolMatch[] = [];
937
954
  const globalPrefix = state.config.settings?.toolPrefix ?? "server";
938
955
  for (const [serverName, metadata] of state.toolMetadata.entries()) {
939
956
  const definition = state.config.mcpServers[serverName];
@@ -946,21 +963,37 @@ export function executeSearch(
946
963
  if (matched) matches.push({ server: serverName, tool, score: 0 });
947
964
  }
948
965
  }
949
- } else if (query.trim().length === 0) {
950
- if (!server) {
951
- return {
952
- content: [{ type: "text" as const, text: "Search query cannot be empty" }],
953
- details: { mode: "search", error: "empty_query" },
954
- };
955
- }
956
- matches = (state.toolMetadata.get(server) ?? [])
957
- .map(tool => ({ server, tool, score: 0 }))
958
- .sort((a, b) => a.tool.name.localeCompare(b.tool.name));
959
- } else {
960
- matches = rankToolMatches(state, query, server);
966
+ return { matches };
961
967
  }
962
968
 
963
- return renderSearchResults(state, query, server, showSchemas, limit, offset, matches);
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
+ };
976
+ }
977
+ return { matches: rankToolMatches(state, query, server) };
978
+ }
979
+
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);
964
997
  }
965
998
 
966
999
  export function executeList(state: McpExtensionState, server: string): ProxyToolResult {
@@ -1152,6 +1185,12 @@ export async function executeConnect(state: McpExtensionState, serverName: strin
1152
1185
  }
1153
1186
  }
1154
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
+
1155
1194
  export async function executeCall(
1156
1195
  state: McpExtensionState,
1157
1196
  toolName: string,
@@ -1610,6 +1649,7 @@ export async function executeCall(
1610
1649
  : null;
1611
1650
 
1612
1651
  const requestMeta = withToolCallIdMeta(uiSession?.requestMeta, toolCallId);
1652
+ const recordOutput = observedOutputRecorder(state, serverName, toolMeta.originalName);
1613
1653
  const result = await withSessionRecovery<ClientCallToolResult>(
1614
1654
  {
1615
1655
  manager: state.manager,
@@ -1629,13 +1669,14 @@ export async function executeCall(
1629
1669
  requestTimeoutMs: requestOptions?.timeout,
1630
1670
  }) as unknown as ClientCallToolResult;
1631
1671
  }
1632
- return abortable(conn.client.callTool({
1672
+ return abortable(callToolPausingForElicitation(conn.client, {
1633
1673
  name: toolMeta.originalName,
1634
1674
  arguments: normalizedArgs,
1635
1675
  _meta: requestMeta,
1636
1676
  }, requestOptions), ownedSignal);
1637
1677
  },
1638
1678
  );
1679
+ if (!result.isError) recordOutput(result as Record<string, unknown>);
1639
1680
 
1640
1681
  if (toolMeta.uiResourceUri) {
1641
1682
  uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/client").CallToolResult);
@@ -1687,7 +1728,9 @@ export async function executeCall(
1687
1728
 
1688
1729
  const content = resolveMcpResultContent(result as Record<string, unknown>, state.owner?.signal);
1689
1730
  const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
1690
- 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 });
1691
1734
  return {
1692
1735
  content: guarded.content,
1693
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,49 +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.
24
+ ## API
36
25
 
37
- 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.
38
31
 
39
- 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
40
33
 
41
- 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 `{}`.
42
35
 
43
- `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.
44
37
 
45
- 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
46
39
 
47
- `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`.
48
44
 
49
- 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
@@ -8,6 +8,7 @@ import type { UiResourceHandler } from "./ui-resource-handler.ts";
8
8
  import type { McpRuntimeOwner } from "./runtime-owner.ts";
9
9
  import type { McpOAuthRuntime } from "./mcp-auth-flow.ts";
10
10
  import type { SessionApprovalWriter } from "./session-approvals.ts";
11
+ import type { ObservedOutput } from "./output-shape.ts";
11
12
 
12
13
  export interface CompletedUiSession {
13
14
  serverName: string;
@@ -54,8 +55,13 @@ export interface McpExtensionState {
54
55
  failureMessages: Map<string, string>;
55
56
  /** Session-only approvals keyed by server, tool definition, and arguments. */
56
57
  approvedToolCalls: Map<string, true>;
58
+ /** Session-only output shapes learned from successful calls to tools without an outputSchema, by tool name.
59
+ * Keyed by the server's config entry so a replaced or reconfigured server starts over. Never persisted. */
60
+ observedOutputs?: WeakMap<ServerDefinition, Map<string, ObservedOutput>>;
57
61
  /** Configured project servers disabled by trust or approval policy for this session. */
58
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;
59
65
  /** Runtime-only server grants. Never persisted or restored from session entries. */
60
66
  approvedServers?: Map<string, { definition: ServerDefinition; hash: string }>;
61
67
  /** Optional active-session sink for approval decisions. */