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..e8f7162 100644 --- a/plugins/glean/dist/index.js +++ b/plugins/glean/dist/index.js @@ -25769,6 +25769,24 @@ function buildApprovalMessage(mcpServer, toolName, args) { } return [`Action: ${toolName}`, "Arguments:", formatArguments(args)].join("\n"); } +var CONNECTOR_AUTH_SUFFIX = "Assistant: ask the user only to authorize using the link above. Do not mention Glean, connectors, setup, or any auth internals, and do not call the `setup` tool."; +function isConnectorAuth(result) { + if (!result.isError) return false; + const text = result.content?.find((c) => c.type === "text"); + if (!text || text.type !== "text") return false; + try { + const parsed = JSON.parse(text.text); + return !!parsed && typeof parsed === "object" && Array.isArray(parsed.authUrls) && parsed.authUrls.length > 0; + } catch { + return false; + } +} +function withConnectorAuthSuffix(result) { + return { + ...result, + content: [...result.content, { type: "text", text: CONNECTOR_AUTH_SUFFIX }] + }; +} async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { const serverId = args.server_id; const toolName = args.tool_name; @@ -25787,19 +25805,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 +25849,15 @@ async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { } throw err; } - return callRemoteTool( + const result = await callRemoteTool( remoteClient, "run_tool", buildRemoteArgs(serverId, toolName, resolvedArgs) ); + if (isConnectorAuth(result)) { + return withConnectorAuthSuffix(result); + } + return result; } function buildRemoteArgs(serverId, toolName, resolvedArgs) { return { diff --git a/src/tools/run-tool.ts b/src/tools/run-tool.ts index 59ac747..4558dce 100644 --- a/src/tools/run-tool.ts +++ b/src/tools/run-tool.ts @@ -170,6 +170,44 @@ function buildApprovalMessage( return [`Action: ${toolName}`, "Arguments:", formatArguments(args)].join("\n"); } +// Instruction to the ASSISTANT (not user-facing) appended to a connector +// AUTH_REQUIRED result: keep the user-facing ask minimal and don't leak auth +// internals, while still preventing a wrong `setup` call. +const CONNECTOR_AUTH_SUFFIX = + "Assistant: ask the user only to authorize using the link above. Do not " + + "mention Glean, connectors, setup, or any auth internals, and do not call " + + "the `setup` tool."; + +// Detect the gateway's connector AUTH_REQUIRED envelope: an error result whose +// first text content is JSON carrying a non-empty `authUrls` array. 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 isConnectorAuth(result: CallToolResult): boolean { + if (!result.isError) return false; + const text = result.content?.find((c) => c.type === "text"); + if (!text || text.type !== "text") return false; + try { + const parsed = JSON.parse(text.text); + return ( + !!parsed && + typeof parsed === "object" && + Array.isArray((parsed as { authUrls?: unknown }).authUrls) && + (parsed as { authUrls: unknown[] }).authUrls.length > 0 + ); + } catch { + return false; + } +} + +// Append the connector-auth clarification as an extra text block, leaving the +// gateway's original content (links and all) untouched. +function withConnectorAuthSuffix(result: CallToolResult): CallToolResult { + return { + ...result, + content: [...result.content, { type: "text", text: CONNECTOR_AUTH_SUFFIX }], + }; +} + export async function handleRunTool( remoteClient: Client, mcpServer: Server, @@ -250,11 +288,23 @@ 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`. Append a clarification so the model doesn't confuse it with the + // plugin's own [SETUP_REQUIRED] and wrongly call `setup`. The gateway's + // original content (including the link) is left untouched. + if (isConnectorAuth(result)) { + return withConnectorAuthSuffix(result); + } + + return result; } /** diff --git a/tests/run-tool.test.ts b/tests/run-tool.test.ts index 57a24af..9f242cf 100644 --- a/tests/run-tool.test.ts +++ b/tests/run-tool.test.ts @@ -342,6 +342,91 @@ describe("handleRunTool (HITL)", () => { }); }); +describe("handleRunTool (connector auth suffix)", () => { + 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...", + authUrls: ["https://connect.example.com/jira"], + }), + }, + ], + }; + + function remoteReturning(result: unknown) { + return { + callTool: vi.fn().mockResolvedValue(result), + close: vi.fn(), + } as any; + } + + function lastText(result: { + content: Array<{ type: string; text?: string }>; + }): string { + const block = result.content[result.content.length - 1]; + return block.type === "text" ? block.text ?? "" : ""; + } + + 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("appends a minimal authorize instruction on connector AUTH_REQUIRED, leaving original content intact", async () => { + const remote = remoteReturning(authResult); + const server = makeServer({ elicitation: true }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs); + + // Original envelope preserved (links untouched) + suffix appended. + expect(result.content).toHaveLength(2); + expect((result.content[0] as { text: string }).text).toContain("authUrls"); + expect(lastText(result)).toContain("authorize"); + expect(lastText(result)).toContain("setup"); + expect(result.isError).toBe(true); + // Suffix only — no dialog. + expect(server.elicitInput).not.toHaveBeenCalled(); + }); + + 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(lastText(result)).not.toContain("SETUP_REQUIRED"); + }); +}); + describe("runToolAnnotations", () => { it("marks run_tool read-only when HITL gates an elicitation-capable client", () => { expect(runToolAnnotations(true, true)).toEqual({ readOnlyHint: true });