From 5a9148c0c547fd640066006a1a884c51e30b929b Mon Sep 17 00:00:00 2001 From: Nikhil Pitta Date: Wed, 9 Sep 2026 16:22:02 -0700 Subject: [PATCH 1/2] feat(mcp): bridge remote elicitation to plugin hosts --- package-lock.json | 55 +++++++++++- package.json | 5 +- shared/glean/mcp/src/auth-provider.ts | 38 +++++--- shared/glean/mcp/src/index.ts | 17 +++- shared/glean/mcp/src/remote-client.ts | 41 +++++++-- shared/glean/mcp/src/tools/find-skills.ts | 2 +- .../glean/mcp/src/tools/remote-passthrough.ts | 2 +- shared/glean/mcp/src/tools/run-tool.ts | 2 +- shared/glean/mcp/tests/find-skills.test.ts | 3 - shared/glean/mcp/tests/remote-client.test.ts | 88 +++++++++++++++++-- 10 files changed, 215 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d1164c..280d41b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "3.4.2", "license": "MIT", "dependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/sdk": "^1.12.1", "yaml": "^2.7.0" }, "devDependencies": { "@gleanwork/pluginpack": "^0.11.0", + "@modelcontextprotocol/server": "^2.0.0", "@release-it/bumper": "^8.0.0", "@release-it/conventional-changelog": "^11.0.1", "@types/node": "^22.0.0", @@ -21,7 +23,8 @@ "release-it": "^20.2.0", "tsx": "^4.19.0", "typescript": "^5.6.0", - "vitest": "^3.0.0" + "vitest": "^3.0.0", + "zod": "^4.6.1" }, "engines": { "node": ">=24" @@ -897,6 +900,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -946,6 +979,20 @@ "node": ">= 0.6" } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6103,9 +6150,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f5a6acb..5e24b63 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "release": "release-it" }, "dependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/sdk": "^1.12.1", "yaml": "^2.7.0" }, "devDependencies": { "@gleanwork/pluginpack": "^0.11.0", + "@modelcontextprotocol/server": "^2.0.0", "@release-it/bumper": "^8.0.0", "@release-it/conventional-changelog": "^11.0.1", "@types/node": "^22.0.0", @@ -37,7 +39,8 @@ "release-it": "^20.2.0", "tsx": "^4.19.0", "typescript": "^5.6.0", - "vitest": "^3.0.0" + "vitest": "^3.0.0", + "zod": "^4.6.1" }, "//overrides": "Pin hono>=4.12.25 (CVE-2026-54290, transitive from @modelcontextprotocol/sdk); pin esbuild/vite to clear CVE blocks that prevented install. Mirrors glean-experimental-plugins overrides.", "overrides": { diff --git a/shared/glean/mcp/src/auth-provider.ts b/shared/glean/mcp/src/auth-provider.ts index 375d507..4db4351 100644 --- a/shared/glean/mcp/src/auth-provider.ts +++ b/shared/glean/mcp/src/auth-provider.ts @@ -1,16 +1,21 @@ -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import type { - OAuthClientInformationMixed, + OAuthClientProvider, OAuthClientMetadata, - OAuthTokens, -} from "@modelcontextprotocol/sdk/shared/auth.js"; + StoredOAuthClientInformation, + StoredOAuthTokens, +} from "@modelcontextprotocol/client"; import { execFile, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { platform } from "node:os"; import { getCallbackUrl, setExpectedState } from "./auth-callback-server.js"; import { clearCredentials, loadCredentials, saveCredentials } from "./token-store.js"; -export type InvalidationScope = "all" | "client" | "tokens" | "verifier"; +export type InvalidationScope = + | "all" + | "client" + | "tokens" + | "verifier" + | "discovery"; /** * Open `url` in the user's default browser. Used for the self-open sign-in @@ -40,8 +45,8 @@ export function openBrowser(url: string): void { } export class GleanOAuthClientProvider implements OAuthClientProvider { - private _clientInfo: OAuthClientInformationMixed | undefined; - private _tokens: OAuthTokens | undefined; + private _clientInfo: StoredOAuthClientInformation | undefined; + private _tokens: StoredOAuthTokens | undefined; private _codeVerifier = ""; private _pendingAuthCode: string | undefined; // True between issuing an authorize URL and either receiving tokens or @@ -57,13 +62,15 @@ export class GleanOAuthClientProvider implements OAuthClientProvider { * refresh failure). Used by the plugin to push a tools/list_changed * notification so the host re-fetches the dynamic tool surface. */ - onTokensChanged?: (tokens: OAuthTokens | undefined) => void; + onTokensChanged?: (tokens: StoredOAuthTokens | undefined) => void; constructor() { const stored = loadCredentials(); if (stored) { - this._tokens = stored.tokens as OAuthTokens | undefined; - this._clientInfo = stored.clientInfo as OAuthClientInformationMixed | undefined; + this._tokens = stored.tokens as StoredOAuthTokens | undefined; + this._clientInfo = stored.clientInfo as + | StoredOAuthClientInformation + | undefined; } } @@ -78,20 +85,20 @@ export class GleanOAuthClientProvider implements OAuthClientProvider { }; } - clientInformation(): OAuthClientInformationMixed | undefined { + clientInformation(): StoredOAuthClientInformation | undefined { return this._clientInfo; } - saveClientInformation(info: OAuthClientInformationMixed): void { + saveClientInformation(info: StoredOAuthClientInformation): void { this._clientInfo = info; saveCredentials(this._tokens, this._clientInfo); } - tokens(): OAuthTokens | undefined { + tokens(): StoredOAuthTokens | undefined { return this._tokens; } - saveTokens(tokens: OAuthTokens): void { + saveTokens(tokens: StoredOAuthTokens): void { this._tokens = tokens; this._authUrlPending = false; saveCredentials(this._tokens, this._clientInfo); @@ -120,6 +127,9 @@ export class GleanOAuthClientProvider implements OAuthClientProvider { case "verifier": this._codeVerifier = ""; break; + case "discovery": + // This provider does not persist discovery metadata. + break; } if ( (scope === "all" || scope === "tokens") && diff --git a/shared/glean/mcp/src/index.ts b/shared/glean/mcp/src/index.ts index 4d5255b..0b8ead2 100644 --- a/shared/glean/mcp/src/index.ts +++ b/shared/glean/mcp/src/index.ts @@ -175,7 +175,22 @@ function getOAuthProvider(): GleanOAuthClientProvider { } function getRemoteClientOpts(): RemoteClientOptions { - return { authProvider: getOAuthProvider() }; + const supportsElicitation = !!server.getClientCapabilities()?.elicitation; + return { + authProvider: getOAuthProvider(), + ...(supportsElicitation + ? { + elicitInput: (params, options) => + server.elicitInput( + { + message: params.message, + requestedSchema: params.requestedSchema, + }, + options, + ), + } + : {}), + }; } const FIND_SKILLS_TOOL: Tool = { diff --git a/shared/glean/mcp/src/remote-client.ts b/shared/glean/mcp/src/remote-client.ts index 9e60ed2..db991d8 100644 --- a/shared/glean/mcp/src/remote-client.ts +++ b/shared/glean/mcp/src/remote-client.ts @@ -1,6 +1,10 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; +import { + Client, + StreamableHTTPClientTransport, + UnauthorizedError, + type ElicitRequest, + type ElicitResult, +} from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { GleanOAuthClientProvider } from "./auth-provider.js"; import { pluginVersionString } from "./version.js"; @@ -104,6 +108,11 @@ function loggingFetch( export interface RemoteClientOptions { authProvider?: GleanOAuthClientProvider; + fetch?: typeof fetch; + elicitInput?: ( + params: Exclude, + options: { timeout: number }, + ) => Promise; } export class AuthRequiredError extends Error { @@ -154,7 +163,7 @@ function buildTransport( const transportOpts: ConstructorParameters[1] = { requestInit: { headers }, - fetch: loggingFetch, + fetch: opts.fetch ?? loggingFetch, }; if (opts.authProvider) { @@ -212,9 +221,30 @@ export async function createRemoteClient( const client = new Client( { name: "glean", version: pluginVersionString() }, - { capabilities: {} }, + { + // The remote may return an input_required result only when the client + // declares the matching capability. Mirror the local host's form + // elicitation support instead of claiming a UI the plugin does not own. + capabilities: opts.elicitInput ? { elicitation: {} } : {}, + // Probe for the 2026-07-28 protocol while retaining automatic fallback + // for older Glean deployments. + versionNegotiation: { mode: "auto" }, + }, ); + if (opts.elicitInput) { + client.setRequestHandler("elicitation/create", async (request) => { + // We advertise form mode only, so the SDK rejects URL-mode requests + // before dispatching them here. + if (request.params.mode === "url") { + throw new Error("URL-mode elicitation is not supported by the local host"); + } + return opts.elicitInput!(request.params, { + timeout: remoteToolTimeoutMs(), + }); + }); + } + const transport = buildTransport(serverUrl, opts, chatSessionId); try { @@ -240,7 +270,6 @@ export async function callRemoteTool( // long-running Glean tools are not capped at the SDK's 60s default. const result = await client.callTool( { name, arguments: args, ...negotiationMeta() }, - undefined, { timeout: remoteToolTimeoutMs() }, ); recordPolicyFromResult(result, `tools/call(${name})`); diff --git a/shared/glean/mcp/src/tools/find-skills.ts b/shared/glean/mcp/src/tools/find-skills.ts index 313b3bd..07ddd35 100644 --- a/shared/glean/mcp/src/tools/find-skills.ts +++ b/shared/glean/mcp/src/tools/find-skills.ts @@ -1,4 +1,4 @@ -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { callRemoteTool } from "../remote-client.js"; import { writeSkillsToDisk, formatAvailableSkillsPrompt } from "../skill-writer.js"; import type { SkillsMap } from "../types.js"; diff --git a/shared/glean/mcp/src/tools/remote-passthrough.ts b/shared/glean/mcp/src/tools/remote-passthrough.ts index e3adcf1..9f836c2 100644 --- a/shared/glean/mcp/src/tools/remote-passthrough.ts +++ b/shared/glean/mcp/src/tools/remote-passthrough.ts @@ -1,4 +1,4 @@ -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import { AuthRequiredError, diff --git a/shared/glean/mcp/src/tools/run-tool.ts b/shared/glean/mcp/src/tools/run-tool.ts index 2436b6a..f1e6799 100644 --- a/shared/glean/mcp/src/tools/run-tool.ts +++ b/shared/glean/mcp/src/tools/run-tool.ts @@ -1,4 +1,4 @@ -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js"; diff --git a/shared/glean/mcp/tests/find-skills.test.ts b/shared/glean/mcp/tests/find-skills.test.ts index 31c8182..f89cbdd 100644 --- a/shared/glean/mcp/tests/find-skills.test.ts +++ b/shared/glean/mcp/tests/find-skills.test.ts @@ -53,7 +53,6 @@ describe("handleFindSkills", () => { name: "find_skills", arguments: {}, }), - undefined, expect.objectContaining({ timeout: expect.any(Number) }), ); @@ -79,7 +78,6 @@ describe("handleFindSkills", () => { name: "find_skills", arguments: { queries: ["create a calendar event"] }, }), - undefined, expect.objectContaining({ timeout: expect.any(Number) }), ); }); @@ -96,7 +94,6 @@ describe("handleFindSkills", () => { name: "find_skills", arguments: { queries: ["search emails", "create calendar event"] }, }), - undefined, expect.objectContaining({ timeout: expect.any(Number) }), ); }); diff --git a/shared/glean/mcp/tests/remote-client.test.ts b/shared/glean/mcp/tests/remote-client.test.ts index 373bd42..c500bc2 100644 --- a/shared/glean/mcp/tests/remote-client.test.ts +++ b/shared/glean/mcp/tests/remote-client.test.ts @@ -1,8 +1,18 @@ -import { describe, it, expect, afterEach } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { Client } from "@modelcontextprotocol/client"; +import { + acceptedContent, + createMcpHandler, + inputRequired, + McpServer, + type CallToolResult as ModernCallToolResult, + type InputRequiredResult, +} from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; import { buildGatewayMetadataHeader, callRemoteTool, + createRemoteClient, remoteToolTimeoutMs, } from "../src/remote-client.js"; @@ -102,11 +112,10 @@ describe("callRemoteTool", () => { }); it("passes the configured timeout to client.callTool", async () => { - const calls: Array<{ params: unknown; schema: unknown; options: unknown }> = - []; + const calls: Array<{ params: unknown; options: unknown }> = []; const fakeClient = { - callTool: async (params: unknown, schema: unknown, options: unknown) => { - calls.push({ params, schema, options }); + callTool: async (params: unknown, options: unknown) => { + calls.push({ params, options }); return { content: [{ type: "text", text: "ok" }] }; }, } as unknown as Client; @@ -135,3 +144,70 @@ describe("callRemoteTool", () => { expect(result).toEqual({ content: [] }); }); }); + +describe("remote elicitation bridge", () => { + it("forwards input_required to the local host and resumes with requestState", async () => { + let invocations = 0; + const requestStates: Array = []; + const handler = createMcpHandler(() => { + const server = new McpServer({ name: "test", version: "1.0.0" }); + server.registerTool( + "approve", + { inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + invocations += 1; + requestStates.push(ctx.mcpReq.requestState()); + const answer = acceptedContent<{ approved: boolean }>( + ctx.mcpReq.inputResponses, + "approval", + ); + if (!answer?.approved) { + return inputRequired({ + inputRequests: { + approval: inputRequired.elicit({ + message: "Approve this action?", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean" } }, + required: ["approved"], + }, + }), + }, + requestState: "opaque-state", + }); + } + return { content: [{ type: "text", text: "executed" }] }; + }, + ); + return server; + }); + const elicitInput = vi.fn().mockResolvedValue({ + action: "accept", + content: { approved: true }, + }); + const client = await createRemoteClient("http://test.local/mcp", { + fetch: (url, init) => handler.fetch(new Request(url, init)), + elicitInput, + }); + + try { + expect(client.getProtocolEra()).toBe("modern"); + const result = await callRemoteTool(client, "approve", {}); + + expect(result.content).toEqual([{ type: "text", text: "executed" }]); + expect(elicitInput).toHaveBeenCalledTimes(1); + expect(elicitInput.mock.calls[0][0]).toMatchObject({ + message: "Approve this action?", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean" } }, + }, + }); + expect(invocations).toBe(2); + expect(requestStates).toEqual([undefined, "opaque-state"]); + } finally { + await client.close(); + await handler.close(); + } + }); +}); From e4722f2482a450720abe92d4a810c09e3d90c586 Mon Sep 17 00:00:00 2001 From: Nikhil Pitta Date: Thu, 10 Sep 2026 16:44:36 -0700 Subject: [PATCH 2/2] refactor(mcp): type persisted OAuth credentials --- shared/glean/mcp/src/auth-provider.ts | 6 ++---- shared/glean/mcp/src/token-store.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/shared/glean/mcp/src/auth-provider.ts b/shared/glean/mcp/src/auth-provider.ts index 4db4351..10ba626 100644 --- a/shared/glean/mcp/src/auth-provider.ts +++ b/shared/glean/mcp/src/auth-provider.ts @@ -67,10 +67,8 @@ export class GleanOAuthClientProvider implements OAuthClientProvider { constructor() { const stored = loadCredentials(); if (stored) { - this._tokens = stored.tokens as StoredOAuthTokens | undefined; - this._clientInfo = stored.clientInfo as - | StoredOAuthClientInformation - | undefined; + this._tokens = stored.tokens; + this._clientInfo = stored.clientInfo; } } diff --git a/shared/glean/mcp/src/token-store.ts b/shared/glean/mcp/src/token-store.ts index 1cad673..1331dc2 100644 --- a/shared/glean/mcp/src/token-store.ts +++ b/shared/glean/mcp/src/token-store.ts @@ -1,3 +1,7 @@ +import type { + StoredOAuthClientInformation, + StoredOAuthTokens, +} from "@modelcontextprotocol/client"; import fs from "node:fs"; import path from "node:path"; import { serverDataDir } from "./data-dir.js"; @@ -11,8 +15,8 @@ function credentialsFile(): string { } interface StoredCredentials { - tokens?: unknown; - clientInfo?: unknown; + tokens?: StoredOAuthTokens; + clientInfo?: StoredOAuthClientInformation; } export function loadCredentials(): StoredCredentials | undefined { @@ -24,7 +28,10 @@ export function loadCredentials(): StoredCredentials | undefined { } } -export function saveCredentials(tokens: unknown, clientInfo: unknown): void { +export function saveCredentials( + tokens: StoredOAuthTokens | undefined, + clientInfo: StoredOAuthClientInformation | undefined, +): void { try { const filePath = credentialsFile(); const dir = path.dirname(filePath);