pi-mcp-adapter 3.0.0 → 3.2.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 +56 -0
- package/OAUTH.md +7 -7
- package/README.md +53 -5
- package/cli.js +17 -15
- package/commands.ts +15 -3
- package/config.ts +51 -44
- package/direct-tools.ts +4 -3
- package/dist/config.d.ts +1 -1
- package/dist/config.js +53 -47
- package/dist/config.js.map +1 -1
- package/dist/mcp-callback-server.js +1 -2
- package/dist/mcp-callback-server.js.map +1 -1
- package/dist/mcp-oauth-provider.d.ts +2 -1
- package/dist/mcp-oauth-provider.js +5 -2
- package/dist/mcp-oauth-provider.js.map +1 -1
- package/dist/metadata-cache.js +4 -1
- package/dist/metadata-cache.js.map +1 -1
- package/dist/state.d.ts +2 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +4 -1
- package/dist/utils.js.map +1 -1
- package/failure-backoff.ts +11 -0
- package/host-managed.ts +547 -0
- package/index.ts +24 -27
- package/init.ts +18 -10
- package/mcp-callback-server.ts +1 -1
- package/mcp-code.ts +2 -2
- package/mcp-oauth-provider.ts +5 -2
- package/mcp-panel.ts +7 -6
- package/mcp-script-wasm.ts +18 -1
- package/mcp-script-worker.mjs +9 -2
- package/mcp-setup-panel.ts +18 -4
- package/metadata-cache.ts +4 -1
- package/namespace-tools.ts +22 -2
- package/package.json +9 -2
- package/project-server-trust.ts +47 -8
- package/proxy-modes.ts +9 -10
- package/search-ranking.ts +1 -1
- package/semantic-search.ts +6 -5
- package/state.ts +2 -1
- package/tool-approval.ts +11 -6
- package/tool-parameters.ts +11 -0
- package/utils.ts +5 -1
package/init.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { McpExtensionState } from "./state.ts";
|
|
|
3
3
|
import { formatToolName, isServerDisabled, resolveToolPrefix, type McpAdapterOptions, type PromptMetadata, type ToolMetadata, type ToolSelectorCandidateIndex } from "./types.ts";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { cloneMcpConfig, loadMcpConfig, resolveConfiguredClaudePluginMcp } from "./config.ts";
|
|
6
|
-
import { applyProjectServerTrustToConfig, describeProjectServerBlock } from "./project-server-trust.ts";
|
|
6
|
+
import { applyProjectServerTrustToConfig, describeProjectServerBlock, excludeProjectServersAtLoadTime } from "./project-server-trust.ts";
|
|
7
7
|
import { ConsentManager } from "./consent-manager.ts";
|
|
8
8
|
import { McpLifecycleManager } from "./lifecycle.ts";
|
|
9
9
|
import {
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
createSessionApprovalWriter,
|
|
44
44
|
restoreSessionApprovalState,
|
|
45
45
|
} from "./session-approvals.ts";
|
|
46
|
-
export { getFailureAgeSeconds, getFailureMessage
|
|
46
|
+
export { getFailureAgeSeconds, getFailureMessage } from "./failure-backoff.ts";
|
|
47
47
|
|
|
48
48
|
const MAX_FAILURE_MESSAGE_CHARS = 8 * 1024;
|
|
49
49
|
const failureExpiryTimers = new WeakMap<McpExtensionState, Map<string, ReturnType<typeof setTimeout>>>();
|
|
@@ -103,6 +103,9 @@ export function isTuiMode(ctx: Pick<ExtensionContext, "hasUI" | "mode">): boolea
|
|
|
103
103
|
type McpInitializationOptions = McpAdapterOptions & {
|
|
104
104
|
oauthRuntime?: McpOAuthRuntime;
|
|
105
105
|
statusEvents?: McpExtensionState["statusEvents"];
|
|
106
|
+
onProjectTrustResolved?: () => void;
|
|
107
|
+
/** Load-time runs have no Pi context, so project trust is unknown; session_start decides later. */
|
|
108
|
+
excludeProjectServers?: boolean;
|
|
106
109
|
};
|
|
107
110
|
|
|
108
111
|
export async function initializeMcp(
|
|
@@ -141,7 +144,10 @@ export async function initializeMcp(
|
|
|
141
144
|
const runtimeSignal = combineAbortSignals(owner.signal, initialSignal);
|
|
142
145
|
const trustResult = options.config !== undefined
|
|
143
146
|
? { config: resolveConfiguredClaudePluginMcp(cloneMcpConfig(options.config), cwd), blockedServers: new Map() }
|
|
144
|
-
:
|
|
147
|
+
: options.excludeProjectServers
|
|
148
|
+
? { config: excludeProjectServersAtLoadTime(loadMcpConfig(configPath, cwd)), blockedServers: new Map() }
|
|
149
|
+
: await applyProjectServerTrustToConfig(loadMcpConfig(configPath, cwd), ctx);
|
|
150
|
+
options.onProjectTrustResolved?.();
|
|
145
151
|
const config = trustResult.config;
|
|
146
152
|
const authStorageOptions = getAuthStorageOptions(config.settings?.oauthDir, cwd, config.settings?.oauthCredentialStore);
|
|
147
153
|
|
|
@@ -591,6 +597,7 @@ export function updateMetadataCache(
|
|
|
591
597
|
if (!definition || isServerDisabled(definition)) return;
|
|
592
598
|
|
|
593
599
|
const configHash = computeServerHash(definition);
|
|
600
|
+
if (connection.definition && computeServerHash(connection.definition) !== configHash) return;
|
|
594
601
|
const existing = loadMetadataCache();
|
|
595
602
|
const existingEntry = existing?.servers?.[serverName];
|
|
596
603
|
|
|
@@ -600,13 +607,13 @@ export function updateMetadataCache(
|
|
|
600
607
|
? existingEntry?.configHash === configHash ? existingEntry.prompts : undefined
|
|
601
608
|
: serializePrompts(connection.prompts ?? []);
|
|
602
609
|
|
|
603
|
-
if (
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
isServerCacheValid(existingEntry, definition)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
+
if (definition.exposeResources !== false && connection.resourceDiscoveryFailed === true) {
|
|
611
|
+
const sessionEntry = state.sessionMetadata?.get(serverName);
|
|
612
|
+
if (sessionEntry?.configHash === configHash) {
|
|
613
|
+
resources = sessionEntry.resources ?? [];
|
|
614
|
+
} else if (existingEntry?.resources?.length && isServerCacheValid(existingEntry, definition)) {
|
|
615
|
+
resources = existingEntry.resources;
|
|
616
|
+
}
|
|
610
617
|
}
|
|
611
618
|
|
|
612
619
|
const entry: ServerCacheEntry = {
|
|
@@ -620,6 +627,7 @@ export function updateMetadataCache(
|
|
|
620
627
|
cachedAt: Date.now(),
|
|
621
628
|
};
|
|
622
629
|
|
|
630
|
+
(state.sessionMetadata ??= new Map()).set(serverName, entry);
|
|
623
631
|
saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
|
|
624
632
|
}
|
|
625
633
|
|
package/mcp-callback-server.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http"
|
|
9
9
|
import { getAppName } from "./agent-dir.ts"
|
|
10
10
|
import {
|
|
11
|
+
DEFAULT_OAUTH_CALLBACK_HOST,
|
|
11
12
|
DEFAULT_OAUTH_CALLBACK_PATH,
|
|
12
13
|
getConfiguredOAuthCallbackPort,
|
|
13
14
|
getOAuthCallbackPath,
|
|
@@ -197,7 +198,6 @@ interface EnsureCallbackServerOptions {
|
|
|
197
198
|
reserveState?: boolean
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
const DEFAULT_OAUTH_CALLBACK_HOST = "localhost"
|
|
201
201
|
let callbackServerHost = DEFAULT_OAUTH_CALLBACK_HOST
|
|
202
202
|
|
|
203
203
|
/**
|
package/mcp-code.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { formatWithOptions } from "node:util";
|
|
|
3
3
|
import { Worker } from "node:worker_threads";
|
|
4
4
|
import { throwIfAborted } from "./abort.ts";
|
|
5
5
|
import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
|
|
6
|
-
import { loadMcpScriptWasm } from "./mcp-script-wasm.ts";
|
|
6
|
+
import { loadMcpScriptWasm, resolveMcpScriptQuickJsUrl } from "./mcp-script-wasm.ts";
|
|
7
7
|
import { evaluateJev, validateJevSettings } from "./jev-client.ts";
|
|
8
8
|
import type { JevErrorCode, JevEvaluateInput, JevEvaluationEnvelope } from "./jev-contracts.ts";
|
|
9
9
|
import { executeCall } from "./proxy-modes.ts";
|
|
@@ -408,7 +408,7 @@ export async function runMcpScript(
|
|
|
408
408
|
try {
|
|
409
409
|
const wasm = await Promise.race([loadMcpScriptWasm(), timeout, aborted]);
|
|
410
410
|
worker = new Worker(new URL("./mcp-script-worker.mjs", import.meta.url), {
|
|
411
|
-
workerData: { code, wasm, interrupt, outputMaxBytes: MCP_SCRIPT_OUTPUT_MAX_BYTES },
|
|
411
|
+
workerData: { code, wasm, quickjsUrl: resolveMcpScriptQuickJsUrl(), interrupt, outputMaxBytes: MCP_SCRIPT_OUTPUT_MAX_BYTES },
|
|
412
412
|
env: {},
|
|
413
413
|
// The sandbox cannot open files, and the host always terminates this worker.
|
|
414
414
|
// Disable Node's unmanaged FD bookkeeping, which emits false warnings when
|
package/mcp-oauth-provider.ts
CHANGED
|
@@ -89,6 +89,9 @@ function toOAuthTokens(tokens: StoredTokens): IssuerBoundTokens {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// Callback server configuration
|
|
92
|
+
// RFC 8252 section 7.3: use the loopback IP literal, not "localhost". The
|
|
93
|
+
// callback listener binds this same host so the advertised redirect reaches it.
|
|
94
|
+
const DEFAULT_OAUTH_CALLBACK_HOST = "127.0.0.1"
|
|
92
95
|
const DEFAULT_OAUTH_CALLBACK_PORT = 19876
|
|
93
96
|
const DEFAULT_OAUTH_CALLBACK_PATH = "/callback"
|
|
94
97
|
|
|
@@ -287,7 +290,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
287
290
|
this.flowState = initialState
|
|
288
291
|
this.redirectUrlSnapshot = config.grantType === "client_credentials"
|
|
289
292
|
? undefined
|
|
290
|
-
: config.redirectUri ?? `http
|
|
293
|
+
: config.redirectUri ?? `http://${DEFAULT_OAUTH_CALLBACK_HOST}:${getOAuthCallbackPort()}${getOAuthCallbackPath()}`
|
|
291
294
|
}
|
|
292
295
|
|
|
293
296
|
setAuthFetch(fetchFn: OAuthFetch): void {
|
|
@@ -816,4 +819,4 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
816
819
|
}
|
|
817
820
|
}
|
|
818
821
|
|
|
819
|
-
export { DEFAULT_OAUTH_CALLBACK_PORT, DEFAULT_OAUTH_CALLBACK_PATH }
|
|
822
|
+
export { DEFAULT_OAUTH_CALLBACK_HOST, DEFAULT_OAUTH_CALLBACK_PORT, DEFAULT_OAUTH_CALLBACK_PATH }
|
package/mcp-panel.ts
CHANGED
|
@@ -612,12 +612,13 @@ class McpPanel {
|
|
|
612
612
|
|
|
613
613
|
if (query && !this.authOnly) {
|
|
614
614
|
this.visibleItems = this.visibleItems.filter((item) => {
|
|
615
|
-
if (item.type
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
615
|
+
if (item.type !== "server") return true;
|
|
616
|
+
const server = this.servers[item.serverIndex];
|
|
617
|
+
// Servers without cached tools (disabled, never connected) can only match by name.
|
|
618
|
+
if (mode === "name" && server && fuzzyScore(query, server.name) > 0) return true;
|
|
619
|
+
return this.visibleItems.some(
|
|
620
|
+
(other) => other.type === "tool" && other.serverIndex === item.serverIndex,
|
|
621
|
+
);
|
|
621
622
|
});
|
|
622
623
|
}
|
|
623
624
|
}
|
package/mcp-script-wasm.ts
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
3
4
|
|
|
4
5
|
export type McpScriptWasmModule = object;
|
|
5
6
|
|
|
6
7
|
const cached = new Map<string, Promise<McpScriptWasmModule>>();
|
|
7
8
|
|
|
9
|
+
function resolveQuickJsWasmPath(): string {
|
|
10
|
+
return createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the quickjs-wasi entry on the host thread. The worker imports this
|
|
15
|
+
* file URL because Bun-compiled executables cannot resolve bare package names
|
|
16
|
+
* from a worker file loaded from disk.
|
|
17
|
+
* Those executables also fail to resolve the bare `quickjs-wasi` root on the
|
|
18
|
+
* host thread, while package subpaths resolve. So the entry is located next to
|
|
19
|
+
* the wasm file, which is how quickjs-wasi's `"."` export lays it out.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveMcpScriptQuickJsUrl(): string {
|
|
22
|
+
return new URL("./dist/index.js", pathToFileURL(resolveQuickJsWasmPath())).href;
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
/** Load and compile the packaged QuickJS runtime once per host process. */
|
|
9
|
-
export function loadMcpScriptWasm(path =
|
|
26
|
+
export function loadMcpScriptWasm(path = resolveQuickJsWasmPath()): Promise<McpScriptWasmModule> {
|
|
10
27
|
let module = cached.get(path);
|
|
11
28
|
if (!module) {
|
|
12
29
|
const webAssembly = (globalThis as unknown as {
|
package/mcp-script-worker.mjs
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
-
import { JSException, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
|
|
3
2
|
|
|
4
3
|
const MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
|
|
5
4
|
const ERROR_MAX_BYTES = 64 * 1024;
|
|
6
5
|
const ERROR_TRUNCATION_MARKER = "\n...[mcpScript error truncated]";
|
|
7
6
|
|
|
7
|
+
// Set once main() imports quickjs-wasi from the host-resolved file URL.
|
|
8
|
+
let JSException;
|
|
9
|
+
|
|
8
10
|
function post(message) {
|
|
9
11
|
parentPort?.postMessage(message);
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
function errorText(error) {
|
|
13
|
-
if (error instanceof JSException) {
|
|
15
|
+
if (JSException && error instanceof JSException) {
|
|
14
16
|
const head = error.message ? `${error.name}: ${error.message}` : error.name;
|
|
15
17
|
return error.stack ? `${head}\n${error.stack}` : head;
|
|
16
18
|
}
|
|
@@ -172,6 +174,11 @@ const PRELUDE_SOURCE = `(function (bridge) {
|
|
|
172
174
|
})`;
|
|
173
175
|
|
|
174
176
|
async function main() {
|
|
177
|
+
// Bun-compiled executables cannot resolve bare package names from this
|
|
178
|
+
// worker file, so import quickjs-wasi from the URL the host resolved.
|
|
179
|
+
const quickjs = await import(workerData.quickjsUrl);
|
|
180
|
+
JSException = quickjs.JSException;
|
|
181
|
+
const { MAX_STACK_SIZE, QuickJS } = quickjs;
|
|
175
182
|
const interrupt = new Int32Array(workerData.interrupt);
|
|
176
183
|
let outputBytes = 0;
|
|
177
184
|
let outputExceeded = false;
|
package/mcp-setup-panel.ts
CHANGED
|
@@ -185,8 +185,11 @@ class McpSetupPanelView implements Component {
|
|
|
185
185
|
}
|
|
186
186
|
lines.push("");
|
|
187
187
|
|
|
188
|
-
const
|
|
189
|
-
lines.push(...
|
|
188
|
+
const previewWidth = this.previewWidth(innerWidth);
|
|
189
|
+
lines.push(...this.previewOrError(
|
|
190
|
+
() => this.getActionPreview(state, actions[state.actionCursor], previewWidth),
|
|
191
|
+
previewWidth,
|
|
192
|
+
));
|
|
190
193
|
lines.push("");
|
|
191
194
|
const hint = compact ? "Enter select · Esc back" : "Enter selects, Esc goes back, Ctrl+C closes.";
|
|
192
195
|
lines.push(this.theme.description(hint));
|
|
@@ -208,8 +211,11 @@ class McpSetupPanelView implements Component {
|
|
|
208
211
|
const selected = state.discovery.imports
|
|
209
212
|
.filter((entry) => state.selectedImports.has(entry.kind))
|
|
210
213
|
.map((entry) => entry.kind);
|
|
211
|
-
const
|
|
212
|
-
lines.push(...this.
|
|
214
|
+
const previewWidth = this.previewWidth(innerWidth);
|
|
215
|
+
lines.push(...this.previewOrError(
|
|
216
|
+
() => this.formatWritePreview("Compatibility import write preview", this.callbacks.previewImports(selected), [], previewWidth),
|
|
217
|
+
previewWidth,
|
|
218
|
+
));
|
|
213
219
|
return lines;
|
|
214
220
|
}
|
|
215
221
|
|
|
@@ -391,6 +397,14 @@ class McpSetupPanelView implements Component {
|
|
|
391
397
|
return preview;
|
|
392
398
|
}
|
|
393
399
|
|
|
400
|
+
private previewOrError(renderPreview: () => string[], width: number): string[] {
|
|
401
|
+
try {
|
|
402
|
+
return renderPreview();
|
|
403
|
+
} catch (error) {
|
|
404
|
+
return this.formatPreview([`Preview unavailable: ${error instanceof Error ? error.message : String(error)}`], width);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
394
408
|
private formatWritePreview(title: string, preview: ConfigWritePreview, intro: string[] = [], width = DESKTOP_PREVIEW_WIDTH): string[] {
|
|
395
409
|
const lines: string[] = [];
|
|
396
410
|
for (const line of intro) lines.push(...wrapText(line, width));
|
package/metadata-cache.ts
CHANGED
|
@@ -85,11 +85,14 @@ export function computeServerHash(definition: ServerEntry, environment: NodeJS.P
|
|
|
85
85
|
// Hash only fields that affect server identity and tool/resource output.
|
|
86
86
|
// Exclude lifecycle, idleTimeout, requestTimeoutMs, debug — those are runtime behavior settings
|
|
87
87
|
// that don't change which tools a server exposes.
|
|
88
|
+
const isStdio = !!definition.command;
|
|
89
|
+
const literalEnv = isBuiltInAgentPlugin(definition, "env") || (isStdio && definition.literalEnv === true);
|
|
88
90
|
const identity: Record<string, unknown> = {
|
|
89
91
|
command: resolveConfigPath(definition.command, environment),
|
|
90
92
|
args: definition.args,
|
|
91
93
|
socket: resolveConfigPath(definition.socket, environment),
|
|
92
|
-
env:
|
|
94
|
+
env: literalEnv ? definition.env : interpolateEnvRecord(definition.env, environment),
|
|
95
|
+
...(isStdio ? { inheritEnv: definition.inheritEnv !== false, literalEnv } : {}),
|
|
93
96
|
cwd: isBuiltInAgentPlugin(definition, "cwd") ? definition.cwd : resolveConfigPath(definition.cwd, environment),
|
|
94
97
|
url: resolveServerUrl(definition, environment),
|
|
95
98
|
headers: isBuiltInAgentPlugin(definition, "headers") ? definition.headers : interpolateEnvRecord(definition.headers, environment),
|
package/namespace-tools.ts
CHANGED
|
@@ -190,6 +190,10 @@ export interface SyncNamespaceProxyToolsInput {
|
|
|
190
190
|
activeDirectNames?: ReadonlySet<string>;
|
|
191
191
|
existingNamespaceNames: Set<string>;
|
|
192
192
|
unavailableServers?: ReadonlySet<string>;
|
|
193
|
+
// Names the adapter removed from Pi's active tools because Pi could not
|
|
194
|
+
// unregister them. Pi does not re-activate a name it already knows, so a
|
|
195
|
+
// later re-registration must add the name back.
|
|
196
|
+
fallbackDeactivatedNames?: Set<string>;
|
|
193
197
|
pi: ExtensionAPI;
|
|
194
198
|
getState: GetState;
|
|
195
199
|
getInitPromise: GetInitPromise;
|
|
@@ -265,10 +269,19 @@ function registerNamespaceProxyTool(
|
|
|
265
269
|
),
|
|
266
270
|
});
|
|
267
271
|
input.guardReentrant?.();
|
|
272
|
+
if (input.fallbackDeactivatedNames?.delete(spec.toolName)) {
|
|
273
|
+
// Only undo a fallback deactivation the adapter still owns.
|
|
274
|
+
input.guardReentrant?.();
|
|
275
|
+
const activeTools = getActiveToolsIfReady(input.pi);
|
|
276
|
+
input.guardReentrant?.();
|
|
277
|
+
if (activeTools && !activeTools.includes(spec.toolName)) {
|
|
278
|
+
input.pi.setActiveTools([...activeTools, spec.toolName]);
|
|
279
|
+
input.guardReentrant?.();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
268
282
|
}
|
|
269
283
|
|
|
270
|
-
function
|
|
271
|
-
if (staleNames.length === 0) return undefined;
|
|
284
|
+
function getActiveToolsIfReady(pi: ExtensionAPI): string[] | undefined {
|
|
272
285
|
try {
|
|
273
286
|
return pi.getActiveTools?.();
|
|
274
287
|
} catch (error) {
|
|
@@ -277,6 +290,11 @@ function getActiveToolsForStaleCleanup(pi: ExtensionAPI, staleNames: string[]):
|
|
|
277
290
|
}
|
|
278
291
|
}
|
|
279
292
|
|
|
293
|
+
function getActiveToolsForStaleCleanup(pi: ExtensionAPI, staleNames: string[]): string[] | undefined {
|
|
294
|
+
if (staleNames.length === 0) return undefined;
|
|
295
|
+
return getActiveToolsIfReady(pi);
|
|
296
|
+
}
|
|
297
|
+
|
|
280
298
|
function deactivateStaleNamespaceTools(input: SyncNamespaceProxyToolsInput, nextNames: Set<string>): string[] {
|
|
281
299
|
const activeDirectNames = input.activeDirectNames ?? new Set<string>();
|
|
282
300
|
const staleNames = [...input.existingNamespaceNames].filter(
|
|
@@ -298,12 +316,14 @@ function deactivateStaleNamespaceTools(input: SyncNamespaceProxyToolsInput, next
|
|
|
298
316
|
if (!activeTools) return deactivated;
|
|
299
317
|
|
|
300
318
|
const stale = new Set(staleNames);
|
|
319
|
+
const fallbackRemoved = staleNames.filter((name) => !deactivated.includes(name) && activeTools.includes(name));
|
|
301
320
|
const nextActiveTools = activeTools.filter((name) => !stale.has(name));
|
|
302
321
|
if (nextActiveTools.length === activeTools.length) return deactivated;
|
|
303
322
|
|
|
304
323
|
input.guardReentrant?.();
|
|
305
324
|
input.pi.setActiveTools(nextActiveTools);
|
|
306
325
|
input.guardReentrant?.();
|
|
326
|
+
for (const name of fallbackRemoved) input.fallbackDeactivatedNames?.add(name);
|
|
307
327
|
for (const name of staleNames) {
|
|
308
328
|
if (!deactivated.includes(name)) deactivated.push(name);
|
|
309
329
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mcp-adapter",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.ts",
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
"import": "./oauth.ts",
|
|
21
21
|
"default": "./oauth.ts"
|
|
22
22
|
},
|
|
23
|
+
"./host-managed": {
|
|
24
|
+
"types": "./host-managed.ts",
|
|
25
|
+
"import": "./host-managed.ts",
|
|
26
|
+
"default": "./host-managed.ts"
|
|
27
|
+
},
|
|
23
28
|
"./config": {
|
|
24
29
|
"types": "./dist/config.d.ts",
|
|
25
30
|
"import": "./dist/config.js",
|
|
@@ -86,6 +91,8 @@
|
|
|
86
91
|
"ts-shape.ts",
|
|
87
92
|
"search-ranking.ts",
|
|
88
93
|
"tool-approval.ts",
|
|
94
|
+
"tool-parameters.ts",
|
|
95
|
+
"host-managed.ts",
|
|
89
96
|
"session-approvals.ts",
|
|
90
97
|
"project-server-trust.ts",
|
|
91
98
|
"init.ts",
|
|
@@ -183,7 +190,7 @@
|
|
|
183
190
|
"open": "^10.2.0",
|
|
184
191
|
"quickjs-wasi": "3.6.2",
|
|
185
192
|
"recheck": "4.6.0-beta.3",
|
|
186
|
-
"smol-toml": "^1.
|
|
193
|
+
"smol-toml": "^1.9.0",
|
|
187
194
|
"strip-json-comments": "^5.0.3",
|
|
188
195
|
"undici": "^6.28.1",
|
|
189
196
|
"zod": "^3.25.0 || ^4.0.0"
|
package/project-server-trust.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { getAgentPath } from "./agent-dir.ts";
|
|
6
6
|
import type { LoadedMcpConfig } from "./config.ts";
|
|
7
|
-
import type
|
|
7
|
+
import { isServerDisabled, type McpConfig, type ProjectServerBlock, type ProjectServerBlockReason, type ServerDefinition } from "./types.ts";
|
|
8
8
|
|
|
9
9
|
const APPROVALS_VERSION = 1;
|
|
10
10
|
const APPROVALS_FILE = "mcp-project-approvals.json";
|
|
@@ -82,6 +82,45 @@ export function canonicalProjectRoot(cwd: string): string {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Git worktrees share approvals per repository, keyed by the same relative path. A `.git`
|
|
87
|
+
* file counts only when it is a regular file (not a symlink to a real worktree's) and git's
|
|
88
|
+
* admin entry for it links back to it; otherwise a directory could claim another checkout's
|
|
89
|
+
* approvals. Worktrees of a regular checkout use that checkout's path, so its existing
|
|
90
|
+
* approvals still apply. Bare repositories (even one stored as a `.git` folder, whose
|
|
91
|
+
* parent is not a checkout) and `--separate-git-dir` ones get a `git-dir:` key, which no
|
|
92
|
+
* canonical path equals, so an admin entry planted in a checkout's tracked files cannot
|
|
93
|
+
* borrow that checkout's approvals.
|
|
94
|
+
* A `--separate-git-dir` main checkout keeps its own path: git records no link back to it.
|
|
95
|
+
*/
|
|
96
|
+
function projectApprovalScope(cwd: string): string {
|
|
97
|
+
const root = canonicalProjectRoot(cwd);
|
|
98
|
+
for (let dir = root; ; dir = dirname(dir)) {
|
|
99
|
+
const dotGit = join(dir, ".git");
|
|
100
|
+
if (existsSync(dotGit)) {
|
|
101
|
+
const repoScope = linkedWorktreeRepoScope(dotGit);
|
|
102
|
+
return repoScope ? join(repoScope, relative(dir, root)) : root;
|
|
103
|
+
}
|
|
104
|
+
if (dirname(dir) === dir) return root;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function linkedWorktreeRepoScope(dotGit: string): string | undefined {
|
|
109
|
+
try {
|
|
110
|
+
if (!lstatSync(dotGit).isFile()) return undefined;
|
|
111
|
+
const pointer = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1]?.trim();
|
|
112
|
+
if (!pointer) return undefined;
|
|
113
|
+
const adminDir = realpathSync(resolve(dirname(dotGit), pointer));
|
|
114
|
+
const backLink = readFileSync(join(adminDir, "gitdir"), "utf8").trim();
|
|
115
|
+
if (realpathSync(resolve(adminDir, backLink)) !== realpathSync(dotGit)) return undefined;
|
|
116
|
+
const commonDir = dirname(dirname(adminDir));
|
|
117
|
+
const bare = /^\s*bare\s*=\s*(true|yes|on|1)\s*$/im.test(readFileSync(join(commonDir, "config"), "utf8"));
|
|
118
|
+
return basename(commonDir) === ".git" && !bare ? dirname(commonDir) : `git-dir:${commonDir}`;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
function approvalPath(): string {
|
|
86
125
|
return getAgentPath(APPROVALS_FILE);
|
|
87
126
|
}
|
|
@@ -119,7 +158,7 @@ function saveApproval(record: ApprovalRecord): void {
|
|
|
119
158
|
|
|
120
159
|
export function approveProjectServer(cwd: string, serverName: string, definition: ServerDefinition): void {
|
|
121
160
|
saveApproval({
|
|
122
|
-
projectRoot:
|
|
161
|
+
projectRoot: projectApprovalScope(cwd),
|
|
123
162
|
serverName,
|
|
124
163
|
definitionHash: hashProjectServerDefinition(definition),
|
|
125
164
|
approvedAt: new Date().toISOString(),
|
|
@@ -149,12 +188,12 @@ export async function applyProjectServerTrust(
|
|
|
149
188
|
} catch {
|
|
150
189
|
projectTrusted = false;
|
|
151
190
|
}
|
|
152
|
-
const projectRoot =
|
|
191
|
+
const projectRoot = projectApprovalScope(ctx.cwd);
|
|
153
192
|
const approvals = loadApprovals();
|
|
154
193
|
|
|
155
194
|
for (const [name, source] of loaded.projectServers) {
|
|
156
195
|
const definition = config.mcpServers[name];
|
|
157
|
-
if (!definition) continue;
|
|
196
|
+
if (!definition || isServerDisabled(definition)) continue;
|
|
158
197
|
const definitionHash = hashProjectServerDefinition(definition);
|
|
159
198
|
const approved = approvals.approvals.some(entry =>
|
|
160
199
|
entry.projectRoot === projectRoot && entry.serverName === name && entry.definitionHash === definitionHash);
|
|
@@ -167,9 +206,9 @@ export async function applyProjectServerTrust(
|
|
|
167
206
|
reason = "approval-required";
|
|
168
207
|
} else if (await ctx.ui.confirm(
|
|
169
208
|
`Allow project MCP server “${name}”?`,
|
|
170
|
-
`
|
|
209
|
+
`Project config: ${source.path}\nEndpoint: ${describeServer(definition)}\n\nThis server can run local commands or make network requests with your user permissions.`,
|
|
171
210
|
)) {
|
|
172
|
-
approveProjectServer(
|
|
211
|
+
approveProjectServer(ctx.cwd, name, definition);
|
|
173
212
|
continue;
|
|
174
213
|
} else {
|
|
175
214
|
reason = "denied";
|
package/proxy-modes.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session
|
|
|
19
19
|
import { callToolViaTaskSession } from "./mcp-tasks.ts";
|
|
20
20
|
import { paginate, rankSuggestions, rankToolMatches, resolveSearchKeywords } from "./search-ranking.ts";
|
|
21
21
|
import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.ts";
|
|
22
|
-
import { isServerInActiveFailureBackoff } from "./failure-backoff.ts";
|
|
22
|
+
import { describeFailure, isServerInActiveFailureBackoff } from "./failure-backoff.ts";
|
|
23
23
|
import { semanticSearch, type SemanticSearchBackend, type SemanticSearchEvaluator } from "./semantic-search.ts";
|
|
24
24
|
import { getInputRequiredNeedsUiDetails } from "./errors.ts";
|
|
25
25
|
import { createJsonSchemaValidator } from "./json-schema-validator.ts";
|
|
@@ -114,8 +114,7 @@ function getEnabledOriginalToolMatches(state: McpExtensionState, toolName: strin
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
function serverBackoffResult(state: McpExtensionState, mode: string, serverName: string): ProxyToolResult {
|
|
117
|
-
const
|
|
118
|
-
const message = `Server "${serverName}" not available (last failed ${failedAgo}s ago)`;
|
|
117
|
+
const message = `Server "${serverName}" not available (last ${describeFailure(state, serverName) ?? "failed 0s ago"})`;
|
|
119
118
|
return {
|
|
120
119
|
content: [{ type: "text" as const, text: message }],
|
|
121
120
|
details: { mode, error: "server_backoff", server: serverName },
|
|
@@ -583,7 +582,7 @@ export function executeStatus(state: McpExtensionState): ProxyToolResult {
|
|
|
583
582
|
continue;
|
|
584
583
|
}
|
|
585
584
|
if (server.status === "failed") {
|
|
586
|
-
text += `✗ ${server.name} (failed ${server.failedAgo ?? 0}s ago)\n`;
|
|
585
|
+
text += `✗ ${server.name} (${describeFailure(state, server.name) ?? `failed ${server.failedAgo ?? 0}s ago`})\n`;
|
|
587
586
|
continue;
|
|
588
587
|
}
|
|
589
588
|
text += `○ ${server.name} (not listening; disconnected)\n`;
|
|
@@ -1286,10 +1285,10 @@ export async function executeCall(
|
|
|
1286
1285
|
}
|
|
1287
1286
|
|
|
1288
1287
|
if (!toolMeta) {
|
|
1289
|
-
const
|
|
1290
|
-
if (
|
|
1288
|
+
const failure = describeFailure(state, serverName);
|
|
1289
|
+
if (failure !== null) {
|
|
1291
1290
|
return {
|
|
1292
|
-
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last
|
|
1291
|
+
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last ${failure})` }],
|
|
1293
1292
|
details: { mode: "call", error: "server_backoff", server: serverName, requestedTool: toolName },
|
|
1294
1293
|
};
|
|
1295
1294
|
}
|
|
@@ -1409,10 +1408,10 @@ export async function executeCall(
|
|
|
1409
1408
|
}
|
|
1410
1409
|
}
|
|
1411
1410
|
if (!connection || connection.status !== "connected") {
|
|
1412
|
-
const
|
|
1413
|
-
if (
|
|
1411
|
+
const failure = describeFailure(state, serverName);
|
|
1412
|
+
if (failure !== null) {
|
|
1414
1413
|
return {
|
|
1415
|
-
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last
|
|
1414
|
+
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last ${failure})` }],
|
|
1416
1415
|
details: { mode: "call", error: "server_backoff", ...callIdentity },
|
|
1417
1416
|
};
|
|
1418
1417
|
}
|
package/search-ranking.ts
CHANGED
package/semantic-search.ts
CHANGED
|
@@ -40,7 +40,7 @@ function stableHash(value: string): string {
|
|
|
40
40
|
return createHash("sha256").update(value).digest("hex");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function eligibleMatches(state: McpExtensionState, server: string | undefined, allowed:
|
|
43
|
+
function eligibleMatches(state: McpExtensionState, server: string | undefined, allowed: ReadonlySet<string>): RankedToolMatch[] {
|
|
44
44
|
const matches: RankedToolMatch[] = [];
|
|
45
45
|
for (const [serverName, metadata] of state.toolMetadata) {
|
|
46
46
|
if ((server && serverName !== server) || !allowed.has(serverName)
|
|
@@ -83,10 +83,10 @@ function selectSemanticCandidates(
|
|
|
83
83
|
state: McpExtensionState,
|
|
84
84
|
query: string,
|
|
85
85
|
server: string | undefined,
|
|
86
|
-
allowedServers: string
|
|
86
|
+
allowedServers: ReadonlySet<string>,
|
|
87
87
|
limit: number,
|
|
88
88
|
): Candidate[] {
|
|
89
|
-
const eligible = eligibleMatches(state, server,
|
|
89
|
+
const eligible = eligibleMatches(state, server, allowedServers);
|
|
90
90
|
let selected = eligible;
|
|
91
91
|
if (eligible.length > limit) {
|
|
92
92
|
const eligiblePaths = new Set(eligible.map(match => `${match.server}\0${match.tool.name}`));
|
|
@@ -131,7 +131,8 @@ export async function semanticSearch(
|
|
|
131
131
|
if (query.trim().length === 0) {
|
|
132
132
|
return { ok: false, error: { code: "empty_query", message: "Semantic search query cannot be empty." } };
|
|
133
133
|
}
|
|
134
|
-
const
|
|
134
|
+
const allowedServers = new Set(settings.allowedServers);
|
|
135
|
+
const candidates = selectSemanticCandidates(state, query, server, allowedServers, settings.semanticCandidateLimit);
|
|
135
136
|
if (candidates.length === 0) {
|
|
136
137
|
return { ok: false, error: { code: "no_eligible_tools", message: "Semantic search has no eligible cached tools from the allowed servers. Connect an allowed server or update settings.jev.allowedServers." } };
|
|
137
138
|
}
|
|
@@ -158,7 +159,7 @@ export async function semanticSearch(
|
|
|
158
159
|
if (envelope.error.code === "timeout" || envelope.error.code === "rate_limited" || envelope.error.code === "service_unavailable") {
|
|
159
160
|
return {
|
|
160
161
|
ok: true,
|
|
161
|
-
matches: rankToolMatches(state, query, server),
|
|
162
|
+
matches: rankToolMatches(state, query, server).filter(match => allowedServers.has(match.server)),
|
|
162
163
|
backend: { requested: "semantic", used: "lexical", degraded: true, reason: envelope.error.code },
|
|
163
164
|
};
|
|
164
165
|
}
|
package/state.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ConsentManager } from "./consent-manager.ts";
|
|
|
3
3
|
import type { McpLifecycleManager } from "./lifecycle.ts";
|
|
4
4
|
import type { McpServerManager } from "./server-manager.ts";
|
|
5
5
|
import type { AuthStorageOptions } from "./mcp-auth.ts";
|
|
6
|
-
import type { ServerDefinition, ToolMetadata, PromptMetadata, McpConfig, UiSessionMessages, UiStreamSummary, McpStatusEventBus, UiServerHandle, ProjectServerBlock } from "./types.ts";
|
|
6
|
+
import type { ServerDefinition, ToolMetadata, PromptMetadata, ServerCacheEntry, McpConfig, UiSessionMessages, UiStreamSummary, McpStatusEventBus, UiServerHandle, ProjectServerBlock } from "./types.ts";
|
|
7
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";
|
|
@@ -33,6 +33,7 @@ export interface McpExtensionState {
|
|
|
33
33
|
manager: McpServerManager;
|
|
34
34
|
lifecycle: McpLifecycleManager;
|
|
35
35
|
toolMetadata: Map<string, ToolMetadata[]>;
|
|
36
|
+
sessionMetadata?: Map<string, ServerCacheEntry>;
|
|
36
37
|
/** Number of tools currently registered directly with Pi, by server. */
|
|
37
38
|
directToolCounts: Map<string, number>;
|
|
38
39
|
/** Resource counts retained separately because tool metadata includes resource tools. */
|
package/tool-approval.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { abortable } from "./abort.ts";
|
|
3
4
|
import { combineAbortSignals } from "./runtime-owner.ts";
|
|
4
5
|
import type { McpExtensionState } from "./state.ts";
|
|
@@ -88,15 +89,19 @@ function isMcpToolApprovalDecision(value: unknown): value is McpToolApprovalDeci
|
|
|
88
89
|
|| value === "abstain";
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Returns "abstain" when no handler claims the request. A handler that throws
|
|
94
|
+
* or returns an unknown value counts as "deny". Rethrows only when `signal` aborted.
|
|
95
|
+
*/
|
|
96
|
+
export async function requestBrokerApproval(
|
|
97
|
+
events: ExtensionAPI["events"] | undefined,
|
|
93
98
|
serverName: string,
|
|
94
|
-
toolMeta: ToolMetadata,
|
|
99
|
+
toolMeta: Pick<ToolMetadata, "name" | "originalName">,
|
|
95
100
|
args: Record<string, unknown> | undefined,
|
|
96
101
|
origin: McpToolApprovalOrigin,
|
|
97
102
|
signal?: AbortSignal,
|
|
98
103
|
): Promise<McpToolApprovalDecision> {
|
|
99
|
-
if (!
|
|
104
|
+
if (!events) return "abstain";
|
|
100
105
|
|
|
101
106
|
let acceptingClaim = true;
|
|
102
107
|
let handler: McpToolApprovalHandler | undefined;
|
|
@@ -115,7 +120,7 @@ async function requestBrokerApproval(
|
|
|
115
120
|
},
|
|
116
121
|
};
|
|
117
122
|
|
|
118
|
-
|
|
123
|
+
events.emit(MCP_TOOL_APPROVAL_REQUEST_EVENT, request);
|
|
119
124
|
acceptingClaim = false;
|
|
120
125
|
if (!handler) return "abstain";
|
|
121
126
|
|
|
@@ -140,7 +145,7 @@ export async function ensureToolCallApproved(
|
|
|
140
145
|
const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
|
|
141
146
|
const approvedServers = state.approvedServers ??= new Map();
|
|
142
147
|
const serverIdentity = getServerApprovalIdentity(state, serverName);
|
|
143
|
-
const brokerDecision = await requestBrokerApproval(state, serverName, toolMeta, args, origin, signal);
|
|
148
|
+
const brokerDecision = await requestBrokerApproval(state.approvalEvents, serverName, toolMeta, args, origin, signal);
|
|
144
149
|
if (brokerDecision === "allow_once") return { ok: true };
|
|
145
150
|
if (brokerDecision === "allow_for_session") {
|
|
146
151
|
rememberToolApproval(state, serverName, toolMeta, args);
|