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.
- package/CHANGELOG.md +80 -0
- package/OAUTH.md +4 -2
- package/README.md +72 -10
- package/cli.js +1 -0
- package/commands.ts +20 -2
- package/config.ts +19 -1
- package/direct-tool-surface.ts +3 -2
- package/direct-tools.ts +12 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/elicitation-handler.d.ts +9 -1
- package/dist/elicitation-handler.js +65 -2
- package/dist/elicitation-handler.js.map +1 -1
- package/dist/jev-client.js +7 -5
- package/dist/jev-client.js.map +1 -1
- package/dist/jev-key-store.d.ts +1 -0
- package/dist/jev-key-store.js +11 -0
- package/dist/jev-key-store.js.map +1 -1
- package/dist/mcp-auth-flow.d.ts +2 -0
- package/dist/mcp-auth-flow.js +12 -3
- package/dist/mcp-auth-flow.js.map +1 -1
- package/dist/metadata-cache.d.ts +12 -0
- package/dist/metadata-cache.js +50 -6
- package/dist/metadata-cache.js.map +1 -1
- package/dist/output-shape.d.ts +36 -0
- package/dist/output-shape.js +338 -0
- package/dist/output-shape.js.map +1 -0
- package/dist/package-mcp-loader.js +9 -5
- package/dist/package-mcp-loader.js.map +1 -1
- package/dist/server-manager.js +27 -16
- package/dist/server-manager.js.map +1 -1
- package/dist/state.d.ts +6 -0
- package/dist/ts-shape.d.ts +3 -0
- package/dist/ts-shape.js +153 -0
- package/dist/ts-shape.js.map +1 -0
- package/dist/types.d.ts +24 -3
- package/dist/types.js.map +1 -1
- package/dist/unix-socket-transport.js +1 -1
- package/dist/unix-socket-transport.js.map +1 -1
- package/dist/utils.d.ts +6 -1
- package/dist/utils.js +17 -0
- package/dist/utils.js.map +1 -1
- package/elicitation-handler.ts +78 -1
- package/index.ts +72 -12
- package/init.ts +20 -5
- package/jev-client.ts +7 -4
- package/jev-key-store.ts +7 -0
- package/mcp-auth-flow.ts +13 -2
- package/mcp-code.ts +126 -68
- package/mcp-output-guard.ts +24 -5
- package/mcp-script-worker.mjs +6 -2
- package/mcp-setup-panel.ts +583 -302
- package/metadata-cache.ts +60 -5
- package/output-shape.ts +348 -0
- package/package-mcp-loader.ts +8 -5
- package/package.json +6 -6
- package/prompts.ts +68 -0
- package/proxy-modes.ts +149 -106
- package/server-manager.ts +28 -15
- package/skills/mcp-scripting/SKILL.md +24 -28
- package/skills/mcp-scripting/references/jev.md +3 -0
- package/state.ts +6 -0
- package/tool-approval.ts +17 -4
- package/tool-metadata.ts +3 -1
- package/ts-shape.ts +1 -1
- package/types.ts +22 -3
- package/ui-server.ts +5 -2
- package/unix-socket-transport.ts +1 -1
- 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 {
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
892
|
-
|
|
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
|
-
|
|
932
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(...
|
|
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(...
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
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
|
|
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
|
-
|
|
24
|
-
return
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
## Reading results
|
|
40
33
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
## Limits
|
|
46
39
|
|
|
47
|
-
|
|
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
|
-
|
|
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. */
|