diff --git a/plugins/glean/.claude-plugin/plugin.json b/plugins/glean/.claude-plugin/plugin.json index 66a02ad..5b5ffa1 100644 --- a/plugins/glean/.claude-plugin/plugin.json +++ b/plugins/glean/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glean-vnext", - "version": "0.2.26", + "version": "0.2.27", "description": "Glean plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/.codex-plugin/plugin.json b/plugins/glean/.codex-plugin/plugin.json index f2ae95e..18d7b9d 100644 --- a/plugins/glean/.codex-plugin/plugin.json +++ b/plugins/glean/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glean-vnext", - "version": "0.2.26", + "version": "0.2.27", "description": "Glean Codex plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/.cursor-plugin/plugin.json b/plugins/glean/.cursor-plugin/plugin.json index cc12e91..5bd0562 100644 --- a/plugins/glean/.cursor-plugin/plugin.json +++ b/plugins/glean/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "glean-vnext", "displayName": "Glean vNext", - "version": "0.2.26", + "version": "0.2.27", "description": "Search and act across your company's apps — Jira, Slack, Salesforce, Google Workspace, and more — without leaving Cursor.", "author": { "name": "Glean" diff --git a/plugins/glean/dist/index.js b/plugins/glean/dist/index.js index 3ae54b0..75cab83 100644 --- a/plugins/glean/dist/index.js +++ b/plugins/glean/dist/index.js @@ -25769,6 +25769,66 @@ function buildApprovalMessage(mcpServer, toolName, args) { } return [`Action: ${toolName}`, "Arguments:", formatArguments(args)].join("\n"); } +function parseConnectorAuth(result) { + if (!result.isError) return null; + const text = result.content?.find((c) => c.type === "text"); + if (!text || text.type !== "text") return null; + let parsed; + try { + parsed = JSON.parse(text.text); + } catch { + return null; + } + if (parsed && typeof parsed === "object" && Array.isArray(parsed.authUrls)) { + const authUrls = parsed.authUrls.filter( + (u) => typeof u === "string" + ); + if (authUrls.length > 0) return { authUrls }; + } + return null; +} +function buildConnectorAuthResult(result, authUrls) { + const links = authUrls.map((u) => `- [Authorize access](${u})`).join("\n"); + const text = "This tool's connector needs authorization before it can run. Glean itself is already authenticated \u2014 this is NOT [SETUP_REQUIRED], so do NOT call `setup`.\n\nShow the user this as a clickable Markdown link:\n\n" + links + "\n\nThen call the `request_auth_confirmation` tool so the user can confirm once they've signed in, and retry this tool after they confirm."; + return { ...result, content: [{ type: "text", text }] }; +} +async function handleRequestAuthConfirmation(mcpServer, args) { + if (!mcpServer.getClientCapabilities()?.elicitation) { + return { + content: [ + { + type: "text", + text: "This client can't show a confirmation dialog. Ask the user in chat whether they've finished authorizing using the link above, then retry the original tool once they confirm." + } + ] + }; + } + const message = typeof args.message === "string" && args.message.trim() ? args.message : "Please authenticate using the link above to proceed."; + try { + const res = await mcpServer.elicitInput( + { message, requestedSchema: { type: "object", properties: {} } }, + { timeout: hitlTimeoutMs() } + ); + return { + content: [ + { + type: "text", + text: res.action === "accept" ? "The user confirmed they have authorized the connector. Retry the original tool now." : "The user has not confirmed authorization yet. Wait until they have authorized using the link, then retry the original tool." + } + ] + }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text", + text: `The confirmation dialog could not be shown (${detail}). Ask the user in chat to confirm they've authorized, then retry the tool.` + } + ] + }; + } +} async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { const serverId = args.server_id; const toolName = args.tool_name; @@ -25787,19 +25847,19 @@ async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { const message = buildApprovalMessage(mcpServer, toolName, args.arguments); const timeout = hitlTimeoutMs(); try { - const result = await mcpServer.elicitInput( + const result2 = await mcpServer.elicitInput( { message, requestedSchema: { type: "object", properties: {} } }, { timeout } ); - if (result.action !== "accept") { + if (result2.action !== "accept") { return { content: [ { type: "text", - text: `Action ${toolName} was ${result.action === "decline" ? "declined" : "cancelled"} by the user.` + text: `Action ${toolName} was ${result2.action === "decline" ? "declined" : "cancelled"} by the user.` } ] }; @@ -25831,11 +25891,16 @@ async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { } throw err; } - return callRemoteTool( + const result = await callRemoteTool( remoteClient, "run_tool", buildRemoteArgs(serverId, toolName, resolvedArgs) ); + const connectorAuth = parseConnectorAuth(result); + if (connectorAuth) { + return buildConnectorAuthResult(result, connectorAuth.authUrls); + } + return result; } function buildRemoteArgs(serverId, toolName, resolvedArgs) { return { @@ -26209,6 +26274,20 @@ var SETUP_TOOL = { required: [] } }; +var REQUEST_AUTH_CONFIRMATION_TOOL = { + name: "request_auth_confirmation", + description: "After you have shown the user a downstream connector authorization link (from a run_tool result that asked for connector authorization), call this to display a confirmation dialog so the user can confirm once they have signed in. When it returns confirmation, retry the original run_tool call. This is ONLY for downstream connector auth \u2014 it is unrelated to `setup` / Glean sign-in.", + inputSchema: { + type: "object", + properties: { + message: { + type: "string", + description: "Optional dialog text. Defaults to asking the user to authenticate using the link shown above." + } + }, + required: [] + } +}; server.setRequestHandler(ListToolsRequestSchema, async () => { const runTool = { ...RUN_TOOL_TOOL, @@ -26217,7 +26296,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { !!server.getClientCapabilities()?.elicitation ) }; - const tools = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL]; + const tools = [ + FIND_SKILLS_TOOL, + runTool, + SETUP_TOOL, + REQUEST_AUTH_CONFIRMATION_TOOL + ]; const serverUrl = resolveServerUrl(); if (!serverUrl) { return { tools: [...tools, ...cachedRemoteTools] }; @@ -26606,6 +26690,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } return await advanceSetup(); } + case "request_auth_confirmation": + return await handleRequestAuthConfirmation(server, args); default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], diff --git a/src/index.ts b/src/index.ts index b32255b..3220a10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,11 @@ import { closeCallbackServer, } from "./auth-callback-server.js"; import { handleFindSkills } from "./tools/find-skills.js"; -import { handleRunTool, runToolAnnotations } from "./tools/run-tool.js"; +import { + handleRunTool, + handleRequestAuthConfirmation, + runToolAnnotations, +} from "./tools/run-tool.js"; import { evictStaleSkills } from "./skill-writer.js"; import { loadServerUrl, @@ -254,6 +258,29 @@ const SETUP_TOOL: Tool = { }, }; +const REQUEST_AUTH_CONFIRMATION_TOOL: Tool = { + name: "request_auth_confirmation", + description: + "After you have shown the user a downstream connector authorization link " + + "(from a run_tool result that asked for connector authorization), call " + + "this to display a confirmation dialog so the user can confirm once they " + + "have signed in. When it returns confirmation, retry the original run_tool " + + "call. This is ONLY for downstream connector auth — it is unrelated to " + + "`setup` / Glean sign-in.", + inputSchema: { + type: "object" as const, + properties: { + message: { + type: "string", + description: + "Optional dialog text. Defaults to asking the user to authenticate " + + "using the link shown above.", + }, + }, + required: [], + }, +}; + server.setRequestHandler(ListToolsRequestSchema, async () => { const runTool: Tool = { ...RUN_TOOL_TOOL, @@ -262,7 +289,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { !!server.getClientCapabilities()?.elicitation, ), }; - const tools: Tool[] = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL]; + const tools: Tool[] = [ + FIND_SKILLS_TOOL, + runTool, + SETUP_TOOL, + REQUEST_AUTH_CONFIRMATION_TOOL, + ]; // Pre-auth gate: tokens() is sync. When unauthenticated (or unconfigured) // skip the remote round-trip — but keep surfacing whatever we successfully @@ -749,6 +781,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return await advanceSetup(); } + case "request_auth_confirmation": + return await handleRequestAuthConfirmation(server, args); + default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], diff --git a/src/tools/run-tool.ts b/src/tools/run-tool.ts index 59ac747..a6bd5ad 100644 --- a/src/tools/run-tool.ts +++ b/src/tools/run-tool.ts @@ -170,6 +170,116 @@ function buildApprovalMessage( return [`Action: ${toolName}`, "Arguments:", formatArguments(args)].join("\n"); } +// Detect the gateway's connector AUTH_REQUIRED envelope: an error result whose +// first text content is JSON carrying a non-empty `authUrls` array. Returns the +// parsed auth URLs when matched, else null. This is third-party connector auth +// (the user authorizing e.g. Jira/Slack) — distinct from the plugin's own +// [SETUP_REQUIRED] Glean sign-in. +function parseConnectorAuth( + result: CallToolResult, +): { authUrls: string[] } | null { + if (!result.isError) return null; + const text = result.content?.find((c) => c.type === "text"); + if (!text || text.type !== "text") return null; + let parsed: unknown; + try { + parsed = JSON.parse(text.text); + } catch { + return null; + } + if ( + parsed && + typeof parsed === "object" && + Array.isArray((parsed as { authUrls?: unknown }).authUrls) + ) { + const authUrls = (parsed as { authUrls: unknown[] }).authUrls.filter( + (u): u is string => typeof u === "string", + ); + if (authUrls.length > 0) return { authUrls }; + } + return null; +} + +// Rebuild a connector AUTH_REQUIRED result into a render-friendly message: the +// auth URL(s) as clickable Markdown links, plus the flow to follow (render the +// link → call request_auth_confirmation → retry). Replaces the backend's raw +// JSON `{error, authUrls}` envelope (whose generic wording rendered as a +// non-clickable link) while keeping `isError` and `_meta`. Explicitly NOT the +// plugin's own [SETUP_REQUIRED] Glean sign-in. +function buildConnectorAuthResult( + result: CallToolResult, + authUrls: string[], +): CallToolResult { + const links = authUrls.map((u) => `- [Authorize access](${u})`).join("\n"); + const text = + "This tool's connector needs authorization before it can run. Glean itself " + + "is already authenticated — this is NOT [SETUP_REQUIRED], so do NOT call " + + "`setup`.\n\n" + + "Show the user this as a clickable Markdown link:\n\n" + + links + + "\n\nThen call the `request_auth_confirmation` tool so the user can confirm " + + "once they've signed in, and retry this tool after they confirm."; + return { ...result, content: [{ type: "text", text }] }; +} + +// Confirmation dialog the LLM invokes (via the `request_auth_confirmation` +// tool) AFTER it has shown the connector authorization link. Shows a confirm +// dialog; `accept` ("Done") means the user has authorized and the original +// tool should be retried. No Glean call — purely a local prompt. +export async function handleRequestAuthConfirmation( + mcpServer: Server, + args: Record, +): Promise { + if (!mcpServer.getClientCapabilities()?.elicitation) { + return { + content: [ + { + type: "text", + text: + "This client can't show a confirmation dialog. Ask the user in chat " + + "whether they've finished authorizing using the link above, then " + + "retry the original tool once they confirm.", + }, + ], + }; + } + + const message = + typeof args.message === "string" && args.message.trim() + ? args.message + : "Please authenticate using the link above to proceed."; + + try { + const res = await mcpServer.elicitInput( + { message, requestedSchema: { type: "object", properties: {} } as any }, + { timeout: hitlTimeoutMs() }, + ); + return { + content: [ + { + type: "text", + text: + res.action === "accept" + ? "The user confirmed they have authorized the connector. Retry the original tool now." + : "The user has not confirmed authorization yet. Wait until they have authorized using the link, then retry the original tool.", + }, + ], + }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text", + text: + `The confirmation dialog could not be shown (${detail}). Ask the ` + + `user in chat to confirm they've authorized, then retry the tool.`, + }, + ], + }; + } +} + export async function handleRunTool( remoteClient: Client, mcpServer: Server, @@ -250,11 +360,26 @@ export async function handleRunTool( throw err; } - return callRemoteTool( + const result = await callRemoteTool( remoteClient, "run_tool", buildRemoteArgs(serverId, toolName, resolvedArgs), ); + + // A downstream connector (Jira/Slack/...) can require the user to authorize + // their account even though Glean itself is authenticated. The gateway + // surfaces that as an error result whose text is a JSON envelope with + // `authUrls`. Rewrite it into a clickable Markdown link plus a clear retry + // path (render link → request_auth_confirmation → retry) so the model + // surfaces it and doesn't confuse it with the plugin's own [SETUP_REQUIRED]. + // We deliberately do NOT elicit here — the confirm dialog comes later, after + // the link is shown, via the request_auth_confirmation tool. + const connectorAuth = parseConnectorAuth(result); + if (connectorAuth) { + return buildConnectorAuthResult(result, connectorAuth.authUrls); + } + + return result; } /** diff --git a/tests/run-tool.test.ts b/tests/run-tool.test.ts index 57a24af..f105fbc 100644 --- a/tests/run-tool.test.ts +++ b/tests/run-tool.test.ts @@ -7,6 +7,7 @@ import { buildRemoteArgs, FileArgsError, handleRunTool, + handleRequestAuthConfirmation, runToolAnnotations, } from "../src/tools/run-tool.js"; @@ -342,6 +343,174 @@ describe("handleRunTool (HITL)", () => { }); }); +describe("handleRunTool (connector auth)", () => { + let tmpDir: string; + const baseArgs = { + server_id: "composio/jira-pack", + tool_name: "jirasearch", + arguments: { project: "ABC" }, + }; + // What the gateway returns when a downstream connector needs the user to + // authorize their account (Glean itself is already authenticated). + const authResult = { + isError: true, + content: [ + { + type: "text", + text: JSON.stringify({ + error: "This tool requires authentication. Show the user each URL...", + authUrls: ["https://connect.example.com/jira"], + }), + }, + ], + }; + + function remoteReturning(result: unknown) { + return { + callTool: vi.fn().mockResolvedValue(result), + close: vi.fn(), + } as any; + } + + function onlyText(result: { + content: Array<{ type: string; text?: string }>; + }): string { + return result.content + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join("\n"); + } + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "run-tool-connauth-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it("rewrites a connector AUTH_REQUIRED result into a Markdown link + retry path (no dialog)", async () => { + const remote = remoteReturning(authResult); + const elicit = vi.fn(); + const server = makeServer({ elicitation: true, elicit }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs); + + // No dialog fires inside run_tool — that happens later via + // request_auth_confirmation, after the link is shown. + expect(elicit).not.toHaveBeenCalled(); + + // Content is replaced with one clean block: a clickable Markdown link, the + // request_auth_confirmation flow, and the [SETUP_REQUIRED] disambiguation. + expect(result.content).toHaveLength(1); + const text = onlyText(result); + expect(text).toContain( + "[Authorize access](https://connect.example.com/jira)", + ); + expect(text).toContain("request_auth_confirmation"); + expect(text).toContain("NOT [SETUP_REQUIRED]"); + expect(text).not.toContain("authUrls"); // raw JSON envelope dropped + expect(result.isError).toBe(true); + }); + + it("rewrites regardless of elicitation capability", async () => { + const remote = remoteReturning(authResult); + const server = makeServer({ elicitation: false }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(onlyText(result)).toContain( + "[Authorize access](https://connect.example.com/jira)", + ); + }); + + it("passes a normal (non-error) result through unchanged", async () => { + const remote = remoteReturning({ content: [{ type: "text", text: "ok" }] }); + const server = makeServer({ elicitation: true }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(result.content).toHaveLength(1); + expect((result.content[0] as { text: string }).text).toBe("ok"); + }); + + it("passes an ordinary (non-JSON) error through unchanged", async () => { + const remote = remoteReturning({ + isError: true, + content: [{ type: "text", text: "Backend exploded" }], + }); + const server = makeServer({ elicitation: true }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(result.content).toHaveLength(1); + expect(onlyText(result)).toBe("Backend exploded"); + }); +}); + +describe("handleRequestAuthConfirmation", () => { + it("returns a retry instruction when the user accepts (Done)", async () => { + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + + const result = await handleRequestAuthConfirmation(server, {}); + + expect(elicit).toHaveBeenCalledTimes(1); + expect(elicit.mock.calls[0][0].message).toContain( + "authenticate using the link", + ); + expect((result.content[0] as { text: string }).text).toContain( + "Retry the original tool", + ); + }); + + it("uses a custom message when provided", async () => { + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + + await handleRequestAuthConfirmation(server, { message: "Custom prompt" }); + + expect(elicit.mock.calls[0][0].message).toBe("Custom prompt"); + }); + + it("tells the model to wait when the user declines or cancels", async () => { + for (const action of ["decline", "cancel"]) { + const elicit = vi.fn().mockResolvedValue({ action }); + const server = makeServer({ elicitation: true, elicit }); + + const result = await handleRequestAuthConfirmation(server, {}); + + expect((result.content[0] as { text: string }).text).toContain( + "not confirmed", + ); + } + }); + + it("falls back to a chat prompt when the client cannot elicit", async () => { + const server = makeServer({ elicitation: false }); + + const result = await handleRequestAuthConfirmation(server, {}); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect((result.content[0] as { text: string }).text).toContain( + "can't show a confirmation dialog", + ); + }); + + it("falls back to chat guidance when the dialog errors", async () => { + const elicit = vi.fn().mockRejectedValue(new Error("timed out")); + const server = makeServer({ elicitation: true, elicit }); + + const result = await handleRequestAuthConfirmation(server, {}); + + expect((result.content[0] as { text: string }).text).toContain( + "could not be shown", + ); + }); +}); + describe("runToolAnnotations", () => { it("marks run_tool read-only when HITL gates an elicitation-capable client", () => { expect(runToolAnnotations(true, true)).toEqual({ readOnlyHint: true });