@fitchmultz/pi-mcp-adapter 5.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 +750 -0
- package/LICENSE +21 -0
- package/OAUTH.md +462 -0
- package/README.md +820 -0
- package/abort.ts +34 -0
- package/agent-dir.ts +24 -0
- package/app-bridge.bundle.js +67 -0
- package/banner.png +0 -0
- package/checkpoint.ts +55 -0
- package/cli.js +222 -0
- package/commands.ts +619 -0
- package/config.ts +1204 -0
- package/consent-manager.ts +25 -0
- package/direct-tools.ts +408 -0
- package/dist/abort.d.ts +2 -0
- package/dist/abort.js +35 -0
- package/dist/agent-dir.d.ts +3 -0
- package/dist/agent-dir.js +21 -0
- package/dist/app-bridge.bundle.js +67 -0
- package/dist/checkpoint.d.ts +18 -0
- package/dist/checkpoint.js +53 -0
- package/dist/commands.d.ts +20 -0
- package/dist/commands.js +521 -0
- package/dist/config.d.ts +109 -0
- package/dist/config.js +1013 -0
- package/dist/consent-manager.d.ts +8 -0
- package/dist/consent-manager.js +21 -0
- package/dist/direct-tools.d.ts +10 -0
- package/dist/direct-tools.js +350 -0
- package/dist/docs/client-metadata.json +14 -0
- package/dist/elicitation-handler.d.ts +17 -0
- package/dist/elicitation-handler.js +308 -0
- package/dist/error-signal.d.ts +15 -0
- package/dist/error-signal.js +21 -0
- package/dist/glimpse-ui.d.ts +9 -0
- package/dist/glimpse-ui.js +75 -0
- package/dist/host-html-template.d.ts +15 -0
- package/dist/host-html-template.js +429 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +840 -0
- package/dist/init.d.ts +25 -0
- package/dist/init.js +562 -0
- package/dist/legacy-migration.d.ts +18 -0
- package/dist/legacy-migration.js +45 -0
- package/dist/lifecycle.d.ts +40 -0
- package/dist/lifecycle.js +167 -0
- package/dist/logger.d.ts +38 -0
- package/dist/logger.js +102 -0
- package/dist/mcp-auth-flow.d.ts +113 -0
- package/dist/mcp-auth-flow.js +834 -0
- package/dist/mcp-auth.d.ts +127 -0
- package/dist/mcp-auth.js +658 -0
- package/dist/mcp-callback-server.d.ts +56 -0
- package/dist/mcp-callback-server.js +371 -0
- package/dist/mcp-code.d.ts +51 -0
- package/dist/mcp-code.js +366 -0
- package/dist/mcp-keyring-helper.cjs +89 -0
- package/dist/mcp-oauth-provider.d.ts +154 -0
- package/dist/mcp-oauth-provider.js +672 -0
- package/dist/mcp-output-guard.d.ts +59 -0
- package/dist/mcp-output-guard.js +332 -0
- package/dist/mcp-panel.d.ts +66 -0
- package/dist/mcp-panel.js +889 -0
- package/dist/mcp-script-worker.mjs +128 -0
- package/dist/mcp-setup-panel.d.ts +84 -0
- package/dist/mcp-setup-panel.js +561 -0
- package/dist/mcp-status.d.ts +11 -0
- package/dist/mcp-status.js +96 -0
- package/dist/mcp-trace.d.ts +95 -0
- package/dist/mcp-trace.js +233 -0
- package/dist/metadata-cache.d.ts +18 -0
- package/dist/metadata-cache.js +277 -0
- package/dist/npx-resolver.d.ts +6 -0
- package/dist/npx-resolver.js +448 -0
- package/dist/onboarding-state.d.ts +11 -0
- package/dist/onboarding-state.js +47 -0
- package/dist/panel-keys.d.ts +16 -0
- package/dist/panel-keys.js +15 -0
- package/dist/prompts.d.ts +56 -0
- package/dist/prompts.js +284 -0
- package/dist/proxy-modes.d.ts +38 -0
- package/dist/proxy-modes.js +1235 -0
- package/dist/resource-tools.d.ts +1 -0
- package/dist/resource-tools.js +14 -0
- package/dist/runtime-owner.d.ts +18 -0
- package/dist/runtime-owner.js +117 -0
- package/dist/sampling-handler.d.ts +15 -0
- package/dist/sampling-handler.js +211 -0
- package/dist/search-ranking.d.ts +20 -0
- package/dist/search-ranking.js +180 -0
- package/dist/server-manager.d.ts +114 -0
- package/dist/server-manager.js +1175 -0
- package/dist/session-recovery.d.ts +31 -0
- package/dist/session-recovery.js +310 -0
- package/dist/state.d.ts +63 -0
- package/dist/state.js +1 -0
- package/dist/tool-approval.d.ts +10 -0
- package/dist/tool-approval.js +42 -0
- package/dist/tool-metadata.d.ts +9 -0
- package/dist/tool-metadata.js +193 -0
- package/dist/tool-registrar.d.ts +10 -0
- package/dist/tool-registrar.js +64 -0
- package/dist/tool-result-renderer.d.ts +45 -0
- package/dist/tool-result-renderer.js +224 -0
- package/dist/ts-shape.d.ts +2 -0
- package/dist/ts-shape.js +147 -0
- package/dist/types.d.ts +516 -0
- package/dist/types.js +168 -0
- package/dist/ui-resource-handler.d.ts +17 -0
- package/dist/ui-resource-handler.js +209 -0
- package/dist/ui-server.d.ts +51 -0
- package/dist/ui-server.js +669 -0
- package/dist/ui-session.d.ts +39 -0
- package/dist/ui-session.js +463 -0
- package/dist/ui-stream-types.d.ts +104 -0
- package/dist/ui-stream-types.js +58 -0
- package/dist/ui-tool-visibility.d.ts +4 -0
- package/dist/ui-tool-visibility.js +26 -0
- package/dist/unix-socket-transport.d.ts +15 -0
- package/dist/unix-socket-transport.js +84 -0
- package/dist/utils.d.ts +30 -0
- package/dist/utils.js +276 -0
- package/docs/client-metadata.json +14 -0
- package/elicitation-handler.ts +349 -0
- package/error-signal.ts +21 -0
- package/glimpse-ui.ts +80 -0
- package/host-html-template.ts +456 -0
- package/index.ts +900 -0
- package/init.ts +627 -0
- package/legacy-migration.ts +47 -0
- package/lifecycle.ts +165 -0
- package/logger.ts +127 -0
- package/mcp-auth-flow.ts +975 -0
- package/mcp-auth.ts +793 -0
- package/mcp-callback-server.ts +450 -0
- package/mcp-code.ts +386 -0
- package/mcp-keyring-helper.cjs +89 -0
- package/mcp-oauth-provider.ts +774 -0
- package/mcp-output-guard.ts +412 -0
- package/mcp-panel.ts +978 -0
- package/mcp-script-worker.mjs +128 -0
- package/mcp-setup-panel.ts +666 -0
- package/mcp-status.ts +109 -0
- package/mcp-trace.ts +295 -0
- package/metadata-cache.ts +333 -0
- package/npx-resolver.ts +488 -0
- package/onboarding-state.ts +58 -0
- package/package.json +182 -0
- package/panel-keys.ts +37 -0
- package/prompts.ts +337 -0
- package/proxy-modes.ts +1361 -0
- package/resource-tools.ts +17 -0
- package/runtime-owner.ts +123 -0
- package/sampling-handler.ts +273 -0
- package/scripts/build.mjs +131 -0
- package/scripts/prepare.mjs +102 -0
- package/search-ranking.ts +196 -0
- package/server-manager.ts +1320 -0
- package/session-recovery.ts +316 -0
- package/skills/mcp-scripting/SKILL.md +38 -0
- package/state.ts +64 -0
- package/tool-approval.ts +78 -0
- package/tool-metadata.ts +235 -0
- package/tool-registrar.ts +69 -0
- package/tool-result-renderer.ts +286 -0
- package/ts-shape.ts +145 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.json +16 -0
- package/types.ts +810 -0
- package/ui-resource-handler.ts +249 -0
- package/ui-server.ts +808 -0
- package/ui-session.ts +568 -0
- package/ui-stream-types.ts +89 -0
- package/ui-tool-visibility.ts +25 -0
- package/unix-socket-transport.ts +85 -0
- package/utils.ts +309 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resourceNameToToolName(name: string): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// resource-tools.ts - MCP resource name utilities
|
|
2
|
+
export function resourceNameToToolName(name) {
|
|
3
|
+
let result = name
|
|
4
|
+
.replace(/[^a-zA-Z0-9]/g, "_")
|
|
5
|
+
.replace(/_+/g, "_")
|
|
6
|
+
.replace(/^_+/, "") // Remove leading underscores
|
|
7
|
+
.replace(/_+$/, "") // Remove trailing underscores
|
|
8
|
+
.toLowerCase();
|
|
9
|
+
// Ensure we have a valid name
|
|
10
|
+
if (!result || /^\d/.test(result)) {
|
|
11
|
+
result = "resource" + (result ? "_" + result : "");
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { McpCheckpointEvent } from "./checkpoint.ts";
|
|
2
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
export interface McpRuntimeOwner {
|
|
4
|
+
readonly signal: AbortSignal;
|
|
5
|
+
isActive(): boolean;
|
|
6
|
+
addCleanup(cleanup: () => void | Promise<void>): void;
|
|
7
|
+
stop(reason?: string): Promise<void>;
|
|
8
|
+
throwIfInactive(): void;
|
|
9
|
+
beforeActivity(): void;
|
|
10
|
+
holdCheckpoint(event: McpCheckpointEvent): () => void;
|
|
11
|
+
runCallback<T>(kind: "beforeExecute" | "onToolCall", run: () => Promise<T>): Promise<T>;
|
|
12
|
+
getCheckpointBlocker(): string | undefined;
|
|
13
|
+
}
|
|
14
|
+
export declare function createMcpRuntimeOwner(): McpRuntimeOwner;
|
|
15
|
+
export declare function combineAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined;
|
|
16
|
+
/** Fence session-bound UI calls after the owning extension runtime stops. */
|
|
17
|
+
export declare function createOwnedUi(ui: ExtensionUIContext, owner: McpRuntimeOwner): ExtensionUIContext;
|
|
18
|
+
export declare function isAbortError(error: unknown, signal?: AbortSignal): boolean;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { formatTerminalError } from "./utils.js";
|
|
2
|
+
export function createMcpRuntimeOwner() {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const cleanups = [];
|
|
5
|
+
let stopPromise;
|
|
6
|
+
let checkpoint;
|
|
7
|
+
const beforeActivity = () => checkpoint?.invalidate();
|
|
8
|
+
// These two host hooks can outlive abortable() at their call sites. Count their underlying
|
|
9
|
+
// promises through settlement, without cancelling, serializing, or replaying callback work.
|
|
10
|
+
const callbacks = { beforeExecute: 0, onToolCall: 0 };
|
|
11
|
+
const getCheckpointBlocker = () => {
|
|
12
|
+
if (callbacks.beforeExecute)
|
|
13
|
+
return "MCP beforeExecute callback is pending";
|
|
14
|
+
if (callbacks.onToolCall)
|
|
15
|
+
return "MCP onToolCall callback is pending";
|
|
16
|
+
return undefined;
|
|
17
|
+
};
|
|
18
|
+
const reportCleanupFailure = (error, late) => {
|
|
19
|
+
console.error(`MCP: ${late ? "late " : ""}runtime cleanup failed: ${formatTerminalError(error)}`);
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
beforeActivity,
|
|
24
|
+
getCheckpointBlocker,
|
|
25
|
+
runCallback: async (kind, run) => {
|
|
26
|
+
beforeActivity();
|
|
27
|
+
// Invocation/cancellation policy stays at the call site. In particular, an accepted
|
|
28
|
+
// tool's outcome callback may still need to persist its result during shutdown.
|
|
29
|
+
callbacks[kind]++;
|
|
30
|
+
try {
|
|
31
|
+
return await run();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
callbacks[kind]--;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
holdCheckpoint: event => {
|
|
38
|
+
if (checkpoint)
|
|
39
|
+
throw new Error("MCP checkpoint is already held");
|
|
40
|
+
checkpoint = event;
|
|
41
|
+
return () => { if (checkpoint === event)
|
|
42
|
+
checkpoint = undefined; };
|
|
43
|
+
},
|
|
44
|
+
isActive: () => !controller.signal.aborted,
|
|
45
|
+
addCleanup: cleanup => {
|
|
46
|
+
if (controller.signal.aborted) {
|
|
47
|
+
void Promise.resolve().then(cleanup).catch(error => reportCleanupFailure(error, true));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
cleanups.push(cleanup);
|
|
51
|
+
},
|
|
52
|
+
stop: (reason = "MCP extension runtime stopped") => {
|
|
53
|
+
if (stopPromise)
|
|
54
|
+
return stopPromise;
|
|
55
|
+
beforeActivity();
|
|
56
|
+
controller.abort(new Error(reason));
|
|
57
|
+
const pendingCleanups = cleanups.splice(0).reverse().map(cleanup => Promise.resolve().then(cleanup));
|
|
58
|
+
stopPromise = Promise.allSettled(pendingCleanups).then(results => {
|
|
59
|
+
const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
60
|
+
const pendingCallback = getCheckpointBlocker();
|
|
61
|
+
if (pendingCallback)
|
|
62
|
+
failures.push(new Error(pendingCallback));
|
|
63
|
+
if (failures.length > 0) {
|
|
64
|
+
const aggregate = new AggregateError(failures, "MCP runtime cleanup failed");
|
|
65
|
+
console.error(`MCP: runtime cleanup failed: ${formatTerminalError(aggregate)}`);
|
|
66
|
+
throw aggregate;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return stopPromise;
|
|
70
|
+
},
|
|
71
|
+
throwIfInactive: () => { beforeActivity(); controller.signal.throwIfAborted(); },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function combineAbortSignals(...signals) {
|
|
75
|
+
const active = signals.filter((signal) => signal !== undefined);
|
|
76
|
+
if (active.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (active.length === 1)
|
|
79
|
+
return active[0];
|
|
80
|
+
return AbortSignal.any(active);
|
|
81
|
+
}
|
|
82
|
+
/** Fence session-bound UI calls after the owning extension runtime stops. */
|
|
83
|
+
export function createOwnedUi(ui, owner) {
|
|
84
|
+
const proxies = new WeakMap();
|
|
85
|
+
const wrap = (value) => {
|
|
86
|
+
if ((typeof value !== "object" || value === null) && typeof value !== "function") {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
const object = value;
|
|
90
|
+
const existing = proxies.get(object);
|
|
91
|
+
if (existing)
|
|
92
|
+
return existing;
|
|
93
|
+
const proxy = new Proxy(object, {
|
|
94
|
+
get(target, property, receiver) {
|
|
95
|
+
if (!owner.isActive())
|
|
96
|
+
return undefined;
|
|
97
|
+
const member = Reflect.get(target, property, receiver);
|
|
98
|
+
if (typeof member === "function") {
|
|
99
|
+
return (...args) => {
|
|
100
|
+
if (!owner.isActive())
|
|
101
|
+
return undefined;
|
|
102
|
+
return Reflect.apply(member, target, args);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return owner.isActive() ? wrap(member) : undefined;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
proxies.set(object, proxy);
|
|
109
|
+
return proxy;
|
|
110
|
+
};
|
|
111
|
+
return wrap(ui);
|
|
112
|
+
}
|
|
113
|
+
export function isAbortError(error, signal) {
|
|
114
|
+
if (signal?.aborted)
|
|
115
|
+
return true;
|
|
116
|
+
return error instanceof Error && (error.name === "AbortError" || error.message === "MCP extension runtime stopped");
|
|
117
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Client } from "@modelcontextprotocol/client";
|
|
4
|
+
import { type CreateMessageRequest, type CreateMessageResult } from "@modelcontextprotocol/client";
|
|
5
|
+
export interface SamplingHandlerOptions {
|
|
6
|
+
serverName: string;
|
|
7
|
+
autoApprove: boolean;
|
|
8
|
+
ui?: ExtensionUIContext;
|
|
9
|
+
modelRegistry: ModelRegistry;
|
|
10
|
+
getCurrentModel: () => Model<Api> | undefined;
|
|
11
|
+
getSignal: () => AbortSignal | undefined;
|
|
12
|
+
}
|
|
13
|
+
export type ServerSamplingConfig = Omit<SamplingHandlerOptions, "serverName">;
|
|
14
|
+
export declare function registerSamplingHandler(client: Client, options: SamplingHandlerOptions, own?: (run: () => Promise<CreateMessageResult>) => Promise<CreateMessageResult>): void;
|
|
15
|
+
export declare function handleSamplingRequest(options: SamplingHandlerOptions, request: CreateMessageRequest): Promise<CreateMessageResult>;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { truncateAtWord } from "./utils.js";
|
|
2
|
+
import { throwIfAborted } from "./abort.js";
|
|
3
|
+
export function registerSamplingHandler(client, options, own = run => run()) {
|
|
4
|
+
// Own the handler's real promise, not the SDK caller's cancellable response facade.
|
|
5
|
+
client.setRequestHandler("sampling/createMessage", request => own(() => handleSamplingRequest(options, request)));
|
|
6
|
+
}
|
|
7
|
+
export async function handleSamplingRequest(options, request) {
|
|
8
|
+
const params = request.params;
|
|
9
|
+
const signal = options.getSignal();
|
|
10
|
+
throwIfAborted(signal);
|
|
11
|
+
if ("task" in params && params.task) {
|
|
12
|
+
throw new Error("MCP sampling tasks are not supported");
|
|
13
|
+
}
|
|
14
|
+
if (params.includeContext && params.includeContext !== "none") {
|
|
15
|
+
throw new Error("MCP sampling context inclusion is not supported");
|
|
16
|
+
}
|
|
17
|
+
if (params.tools?.length) {
|
|
18
|
+
throw new Error("MCP sampling tool use is not supported");
|
|
19
|
+
}
|
|
20
|
+
if (params.toolChoice) {
|
|
21
|
+
throw new Error("MCP sampling tool choice is not supported");
|
|
22
|
+
}
|
|
23
|
+
if (params.stopSequences?.length) {
|
|
24
|
+
throw new Error("MCP sampling stop sequences are not supported");
|
|
25
|
+
}
|
|
26
|
+
const messages = params.messages.map(convertSamplingMessage);
|
|
27
|
+
const model = await resolveSamplingModel(options, params.modelPreferences);
|
|
28
|
+
throwIfAborted(signal);
|
|
29
|
+
await confirmSampling(options, "Approve MCP sampling request", formatRequestApproval(options.serverName, `${model.provider}/${model.id}`, params.systemPrompt, messages));
|
|
30
|
+
throwIfAborted(signal);
|
|
31
|
+
const result = await options.modelRegistry.complete(model, {
|
|
32
|
+
...(params.systemPrompt !== undefined ? { systemPrompt: params.systemPrompt } : {}),
|
|
33
|
+
messages,
|
|
34
|
+
}, {
|
|
35
|
+
maxTokens: params.maxTokens,
|
|
36
|
+
...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
|
|
37
|
+
...(params.metadata !== undefined ? { metadata: params.metadata } : {}),
|
|
38
|
+
...(signal ? { signal } : {}),
|
|
39
|
+
});
|
|
40
|
+
const converted = convertAssistantResult(result);
|
|
41
|
+
throwIfAborted(signal);
|
|
42
|
+
await confirmSampling(options, "Return MCP sampling response", formatResponseApproval(options.serverName, converted));
|
|
43
|
+
return converted;
|
|
44
|
+
}
|
|
45
|
+
function formatRequestApproval(serverName, modelName, systemPrompt, messages) {
|
|
46
|
+
const lines = [`${serverName} wants to sample ${messages.length} message${messages.length === 1 ? "" : "s"} with ${modelName}.`];
|
|
47
|
+
if (systemPrompt) {
|
|
48
|
+
lines.push(`System: ${truncateAtWord(systemPrompt, 400)}`);
|
|
49
|
+
}
|
|
50
|
+
for (const [index, message] of messages.entries()) {
|
|
51
|
+
lines.push(`${index + 1}. ${message.role}: ${truncateAtWord(messageText(message), 400)}`);
|
|
52
|
+
}
|
|
53
|
+
return lines.join("\n\n");
|
|
54
|
+
}
|
|
55
|
+
function formatResponseApproval(serverName, response) {
|
|
56
|
+
const text = response.content.type === "text" ? response.content.text : `[${response.content.type} content]`;
|
|
57
|
+
return `${serverName} will receive this response from ${response.model}:\n\n${truncateAtWord(text, 1000)}`;
|
|
58
|
+
}
|
|
59
|
+
function messageText(message) {
|
|
60
|
+
if (typeof message.content === "string")
|
|
61
|
+
return message.content;
|
|
62
|
+
return message.content.map((block) => {
|
|
63
|
+
if (block.type === "text")
|
|
64
|
+
return block.text;
|
|
65
|
+
if (block.type === "image")
|
|
66
|
+
return `[image: ${block.mimeType}]`;
|
|
67
|
+
if (block.type === "thinking")
|
|
68
|
+
return "[thinking]";
|
|
69
|
+
if (block.type === "toolCall")
|
|
70
|
+
return `[tool call: ${block.name}]`;
|
|
71
|
+
return "[content]";
|
|
72
|
+
}).join("\n");
|
|
73
|
+
}
|
|
74
|
+
async function resolveSamplingModel(options, modelPreferences) {
|
|
75
|
+
const candidates = [];
|
|
76
|
+
const availableModels = options.modelRegistry.getAvailable();
|
|
77
|
+
for (const hint of modelPreferences?.hints ?? []) {
|
|
78
|
+
const normalizedHint = hint.name?.trim().toLowerCase();
|
|
79
|
+
if (!normalizedHint)
|
|
80
|
+
continue;
|
|
81
|
+
for (const model of availableModels) {
|
|
82
|
+
const searchableNames = [`${model.provider}/${model.id}`, model.id, model.name];
|
|
83
|
+
if (searchableNames.some((name) => name.toLowerCase().includes(normalizedHint))) {
|
|
84
|
+
addSamplingCandidate(candidates, model);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const currentModel = options.getCurrentModel();
|
|
89
|
+
if (currentModel)
|
|
90
|
+
addSamplingCandidate(candidates, currentModel);
|
|
91
|
+
for (const model of availableModels) {
|
|
92
|
+
addSamplingCandidate(candidates, model);
|
|
93
|
+
}
|
|
94
|
+
const errors = [];
|
|
95
|
+
const signal = options.getSignal();
|
|
96
|
+
for (const model of candidates) {
|
|
97
|
+
throwIfAborted(signal);
|
|
98
|
+
const auth = await options.modelRegistry.getApiKeyAndHeaders(model);
|
|
99
|
+
throwIfAborted(signal);
|
|
100
|
+
if (auth.ok === false) {
|
|
101
|
+
errors.push(`${model.provider}/${model.id}: ${auth.error}`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
return model;
|
|
105
|
+
}
|
|
106
|
+
if (errors.length > 0) {
|
|
107
|
+
throw new Error(`No configured auth for MCP sampling model. ${errors.join("; ")}`);
|
|
108
|
+
}
|
|
109
|
+
throw new Error("No Pi model is available for MCP sampling");
|
|
110
|
+
}
|
|
111
|
+
function addSamplingCandidate(candidates, model) {
|
|
112
|
+
if (!candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id)) {
|
|
113
|
+
candidates.push(model);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function confirmSampling(options, title, message) {
|
|
117
|
+
if (options.autoApprove)
|
|
118
|
+
return;
|
|
119
|
+
if (!options.ui) {
|
|
120
|
+
throw new Error("MCP sampling requires interactive approval. Set settings.samplingAutoApprove to true to allow it without UI.");
|
|
121
|
+
}
|
|
122
|
+
const approved = await options.ui.confirm(title, message);
|
|
123
|
+
if (!approved) {
|
|
124
|
+
throw new Error("MCP sampling request was declined");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function convertSamplingMessage(message) {
|
|
128
|
+
const blocks = Array.isArray(message.content) ? message.content : [message.content];
|
|
129
|
+
if (message.role === "user") {
|
|
130
|
+
return {
|
|
131
|
+
role: "user",
|
|
132
|
+
content: blocks.map(convertUserContent),
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
role: "assistant",
|
|
138
|
+
content: blocks.map(convertAssistantContent),
|
|
139
|
+
api: "mcp-sampling",
|
|
140
|
+
provider: "mcp",
|
|
141
|
+
model: "sampling-request",
|
|
142
|
+
usage: zeroUsage(),
|
|
143
|
+
stopReason: "stop",
|
|
144
|
+
timestamp: Date.now(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function convertUserContent(block) {
|
|
148
|
+
if (block.type === "text") {
|
|
149
|
+
return { type: "text", text: block.text };
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`MCP sampling ${block.type} content is not supported`);
|
|
152
|
+
}
|
|
153
|
+
function convertAssistantContent(block) {
|
|
154
|
+
if (block.type === "text") {
|
|
155
|
+
return { type: "text", text: block.text };
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`MCP sampling assistant ${block.type} content is not supported`);
|
|
158
|
+
}
|
|
159
|
+
function convertAssistantResult(message) {
|
|
160
|
+
if (message.stopReason === "error") {
|
|
161
|
+
throw new Error(message.errorMessage ?? "MCP sampling model call failed");
|
|
162
|
+
}
|
|
163
|
+
if (message.stopReason === "aborted") {
|
|
164
|
+
throw new Error(message.errorMessage ?? "MCP sampling model call was aborted");
|
|
165
|
+
}
|
|
166
|
+
const text = message.content
|
|
167
|
+
.map((block) => {
|
|
168
|
+
if (block.type === "text")
|
|
169
|
+
return block.text;
|
|
170
|
+
if (block.type === "thinking")
|
|
171
|
+
return undefined;
|
|
172
|
+
throw new Error(`MCP sampling result ${block.type} content is not supported`);
|
|
173
|
+
})
|
|
174
|
+
.filter((value) => value !== undefined)
|
|
175
|
+
.join("\n\n")
|
|
176
|
+
.trim();
|
|
177
|
+
if (!text) {
|
|
178
|
+
throw new Error("MCP sampling result did not contain text content");
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
role: "assistant",
|
|
182
|
+
content: { type: "text", text },
|
|
183
|
+
model: `${message.provider}/${message.model}`,
|
|
184
|
+
stopReason: mapStopReason(message.stopReason),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function mapStopReason(reason) {
|
|
188
|
+
if (reason === "stop")
|
|
189
|
+
return "endTurn";
|
|
190
|
+
if (reason === "length")
|
|
191
|
+
return "maxTokens";
|
|
192
|
+
if (reason === "toolUse")
|
|
193
|
+
return "toolUse";
|
|
194
|
+
return reason;
|
|
195
|
+
}
|
|
196
|
+
function zeroUsage() {
|
|
197
|
+
return {
|
|
198
|
+
input: 0,
|
|
199
|
+
output: 0,
|
|
200
|
+
cacheRead: 0,
|
|
201
|
+
cacheWrite: 0,
|
|
202
|
+
totalTokens: 0,
|
|
203
|
+
cost: {
|
|
204
|
+
input: 0,
|
|
205
|
+
output: 0,
|
|
206
|
+
cacheRead: 0,
|
|
207
|
+
cacheWrite: 0,
|
|
208
|
+
total: 0,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { McpExtensionState } from "./state.ts";
|
|
2
|
+
import type { ToolMetadata } from "./types.ts";
|
|
3
|
+
export declare const MAX_PAGE_SIZE = 100;
|
|
4
|
+
export declare const MAX_TOOL_NAME_LENGTH = 512;
|
|
5
|
+
export interface RankedToolMatch {
|
|
6
|
+
server: string;
|
|
7
|
+
tool: ToolMetadata;
|
|
8
|
+
score: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function normalizeSearchText(value: string): string;
|
|
11
|
+
export declare function tokenize(value: string): string[];
|
|
12
|
+
export declare function scoreToolMatch(tool: ToolMetadata, server: string, query: string): number | null;
|
|
13
|
+
export declare function rankToolMatches(state: McpExtensionState, query: string, server?: string): RankedToolMatch[];
|
|
14
|
+
export declare function paginate<T>(items: T[], offset: number, limit: number): {
|
|
15
|
+
items: T[];
|
|
16
|
+
total: number;
|
|
17
|
+
hasMore: boolean;
|
|
18
|
+
nextOffset: number | null;
|
|
19
|
+
};
|
|
20
|
+
export declare function rankSuggestions(state: McpExtensionState, name: string, limit: number, server?: string): string[];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { getServerPrefix, isServerDisabled, resolveToolPrefix } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shortest field token allowed to stem-match a longer query token.
|
|
4
|
+
* Real descriptions tokenize possessives into single letters ("project's" -> ["project", "s"]),
|
|
5
|
+
* which would otherwise make every query starting with that letter a match.
|
|
6
|
+
*/
|
|
7
|
+
const MIN_STEM_LENGTH = 4;
|
|
8
|
+
export const MAX_PAGE_SIZE = 100;
|
|
9
|
+
export const MAX_TOOL_NAME_LENGTH = 512;
|
|
10
|
+
const FIELD_WEIGHTS = {
|
|
11
|
+
name: 12,
|
|
12
|
+
originalName: 10,
|
|
13
|
+
server: 8,
|
|
14
|
+
description: 5,
|
|
15
|
+
};
|
|
16
|
+
export function normalizeSearchText(value) {
|
|
17
|
+
return value
|
|
18
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
19
|
+
.replace(/[_./:-]+/g, " ")
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
export function tokenize(value) {
|
|
23
|
+
return normalizeSearchText(value).split(/[^a-z0-9]+/).filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
export function scoreToolMatch(tool, server, query) {
|
|
26
|
+
const normalizedQuery = normalizeSearchText(query).trim();
|
|
27
|
+
const queryTokens = tokenize(query);
|
|
28
|
+
if (queryTokens.length === 0)
|
|
29
|
+
return null;
|
|
30
|
+
const fields = {
|
|
31
|
+
name: normalizeSearchText(tool.name),
|
|
32
|
+
originalName: normalizeSearchText(tool.originalName),
|
|
33
|
+
server: normalizeSearchText(server),
|
|
34
|
+
description: normalizeSearchText(tool.description),
|
|
35
|
+
};
|
|
36
|
+
let score = 0;
|
|
37
|
+
let phraseMatched = false;
|
|
38
|
+
let wholeFieldExact = false;
|
|
39
|
+
const matchedTokens = new Set();
|
|
40
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
41
|
+
const weight = FIELD_WEIGHTS[field];
|
|
42
|
+
const fieldTokens = tokenize(value);
|
|
43
|
+
if (value === normalizedQuery) {
|
|
44
|
+
score += weight * 14;
|
|
45
|
+
phraseMatched = true;
|
|
46
|
+
wholeFieldExact = true;
|
|
47
|
+
}
|
|
48
|
+
else if (value.startsWith(normalizedQuery)) {
|
|
49
|
+
score += weight * 9;
|
|
50
|
+
phraseMatched = true;
|
|
51
|
+
}
|
|
52
|
+
else if (value.includes(normalizedQuery)) {
|
|
53
|
+
score += weight * 6;
|
|
54
|
+
phraseMatched = true;
|
|
55
|
+
}
|
|
56
|
+
for (const token of queryTokens) {
|
|
57
|
+
if (fieldTokens.includes(token)) {
|
|
58
|
+
score += weight * 4;
|
|
59
|
+
matchedTokens.add(token);
|
|
60
|
+
}
|
|
61
|
+
else if (fieldTokens.some(fieldToken => fieldToken.startsWith(token) || (fieldToken.length >= MIN_STEM_LENGTH && token.startsWith(fieldToken)))) {
|
|
62
|
+
score += weight * 2;
|
|
63
|
+
matchedTokens.add(token);
|
|
64
|
+
}
|
|
65
|
+
else if (value.includes(token)) {
|
|
66
|
+
score += weight;
|
|
67
|
+
matchedTokens.add(token);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const coverage = matchedTokens.size / queryTokens.length;
|
|
72
|
+
if (!phraseMatched && (queryTokens.length <= 2 ? coverage !== 1 : coverage < 0.6))
|
|
73
|
+
return null;
|
|
74
|
+
score += coverage === 1 ? 25 : Math.round(coverage * 10);
|
|
75
|
+
const firstQueryToken = queryTokens[0];
|
|
76
|
+
if (firstQueryToken !== undefined && tokenize(fields.name).includes(firstQueryToken))
|
|
77
|
+
score += 8;
|
|
78
|
+
if (wholeFieldExact)
|
|
79
|
+
score += 20;
|
|
80
|
+
return score;
|
|
81
|
+
}
|
|
82
|
+
export function rankToolMatches(state, query, server) {
|
|
83
|
+
const matches = [];
|
|
84
|
+
for (const [serverName, metadata] of state.toolMetadata.entries()) {
|
|
85
|
+
if (server && serverName !== server)
|
|
86
|
+
continue;
|
|
87
|
+
if (isServerDisabled(state.config.mcpServers[serverName]))
|
|
88
|
+
continue;
|
|
89
|
+
for (const tool of metadata) {
|
|
90
|
+
const score = scoreToolMatch(tool, serverName, query);
|
|
91
|
+
if (score !== null)
|
|
92
|
+
matches.push({ server: serverName, tool, score });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return matches.sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name));
|
|
96
|
+
}
|
|
97
|
+
export function paginate(items, offset, limit) {
|
|
98
|
+
const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0;
|
|
99
|
+
const safeLimit = Number.isFinite(limit) ? Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(limit))) : 1;
|
|
100
|
+
const total = items.length;
|
|
101
|
+
const page = items.slice(safeOffset, safeOffset + safeLimit);
|
|
102
|
+
const nextOffset = safeOffset + page.length;
|
|
103
|
+
return {
|
|
104
|
+
items: page,
|
|
105
|
+
total,
|
|
106
|
+
hasMore: nextOffset < total,
|
|
107
|
+
nextOffset: nextOffset < total ? nextOffset : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function editDistance(a, b, maxDistance) {
|
|
111
|
+
if (Math.abs(a.length - b.length) > maxDistance)
|
|
112
|
+
return maxDistance + 1;
|
|
113
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
114
|
+
for (let i = 1; i <= a.length; i++) {
|
|
115
|
+
const current = [i];
|
|
116
|
+
let rowMin = i;
|
|
117
|
+
for (let j = 1; j <= b.length; j++) {
|
|
118
|
+
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
119
|
+
rowMin = Math.min(rowMin, current[j]);
|
|
120
|
+
}
|
|
121
|
+
if (rowMin > maxDistance)
|
|
122
|
+
return maxDistance + 1;
|
|
123
|
+
previous = current;
|
|
124
|
+
}
|
|
125
|
+
return previous[b.length];
|
|
126
|
+
}
|
|
127
|
+
function rankClosest(tools, query, limit, candidateName) {
|
|
128
|
+
const normalizedQuery = normalizeSearchText(query).replace(/\s+/g, "_");
|
|
129
|
+
const maxDistance = Math.max(2, Math.floor(normalizedQuery.length * 0.2));
|
|
130
|
+
return tools
|
|
131
|
+
.map(tool => ({
|
|
132
|
+
tool,
|
|
133
|
+
distance: editDistance(normalizedQuery, normalizeSearchText(candidateName(tool)).replace(/\s+/g, "_"), maxDistance),
|
|
134
|
+
}))
|
|
135
|
+
.filter(({ distance }) => distance <= maxDistance)
|
|
136
|
+
.sort((a, b) => a.distance - b.distance || a.tool.name.localeCompare(b.tool.name))
|
|
137
|
+
.slice(0, limit)
|
|
138
|
+
.map(({ tool }) => tool.name);
|
|
139
|
+
}
|
|
140
|
+
export function rankSuggestions(state, name, limit, server) {
|
|
141
|
+
if (name.length > MAX_TOOL_NAME_LENGTH)
|
|
142
|
+
return [];
|
|
143
|
+
const globalPrefix = state.config.settings?.toolPrefix ?? "server";
|
|
144
|
+
const inferred = server ? undefined : Object.entries(state.config.mcpServers)
|
|
145
|
+
.filter(([, definition]) => !isServerDisabled(definition))
|
|
146
|
+
.map(([candidateServer, definition]) => ({
|
|
147
|
+
server: candidateServer,
|
|
148
|
+
prefix: getServerPrefix(candidateServer, resolveToolPrefix(definition, globalPrefix)),
|
|
149
|
+
}))
|
|
150
|
+
.filter((candidate) => Boolean(candidate.prefix) && name.startsWith(`${candidate.prefix}_`))
|
|
151
|
+
.sort((a, b) => b.prefix.length - a.prefix.length)[0];
|
|
152
|
+
const matchedServer = server ?? inferred?.server;
|
|
153
|
+
if (!matchedServer) {
|
|
154
|
+
const tools = [...state.toolMetadata.entries()]
|
|
155
|
+
.filter(([candidateServer]) => !isServerDisabled(state.config.mcpServers[candidateServer]))
|
|
156
|
+
.flatMap(([, metadata]) => metadata);
|
|
157
|
+
const closest = rankClosest(tools, name, limit, tool => tool.name);
|
|
158
|
+
return closest.length > 0 ? closest : rankToolMatches(state, name).slice(0, limit).map(match => match.tool.name);
|
|
159
|
+
}
|
|
160
|
+
const definition = state.config.mcpServers[matchedServer];
|
|
161
|
+
if (!definition || isServerDisabled(definition))
|
|
162
|
+
return [];
|
|
163
|
+
const prefix = inferred?.prefix ?? getServerPrefix(matchedServer, resolveToolPrefix(definition, globalPrefix));
|
|
164
|
+
const query = prefix && name.startsWith(`${prefix}_`) ? name.slice(prefix.length + 1) : name;
|
|
165
|
+
const tools = state.toolMetadata.get(matchedServer) ?? [];
|
|
166
|
+
const closest = rankClosest(tools, query, limit, tool => tool.originalName);
|
|
167
|
+
if (closest.length > 0)
|
|
168
|
+
return closest;
|
|
169
|
+
const queryTokens = tokenize(query);
|
|
170
|
+
const tokenMatches = rankToolMatches(state, query, matchedServer)
|
|
171
|
+
.filter(({ tool }) => {
|
|
172
|
+
const candidateTokens = tokenize(tool.originalName);
|
|
173
|
+
return queryTokens.every(token => candidateTokens.some(candidate => candidate === token
|
|
174
|
+
|| candidate.startsWith(token)
|
|
175
|
+
|| (candidate.length >= MIN_STEM_LENGTH && token.startsWith(candidate))));
|
|
176
|
+
})
|
|
177
|
+
.slice(0, limit)
|
|
178
|
+
.map(match => match.tool.name);
|
|
179
|
+
return tokenMatches.length > 0 || !server ? tokenMatches : rankSuggestions(state, name, limit);
|
|
180
|
+
}
|