diff --git a/plugins/glean/.claude-plugin/plugin.json b/plugins/glean/.claude-plugin/plugin.json index 1fee628..1ff6a83 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.40", + "version": "0.2.41", "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 16fb2f1..582cf9b 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.40", + "version": "0.2.41", "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 87422ec..c67bd88 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.40", + "version": "0.2.41", "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 c46e8fc..ee6b1c6 100644 --- a/plugins/glean/dist/index.js +++ b/plugins/glean/dist/index.js @@ -25962,7 +25962,7 @@ async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { throw err; } const hitlEnabled = process.env.ENABLE_HITL === "true"; - if (hitlEnabled && toolMeta?.requires_approval && mcpServer.getClientCapabilities()?.elicitation) { + if (hitlEnabled && toolMeta?.requires_approval && !isCursorClient(mcpServer) && mcpServer.getClientCapabilities()?.elicitation) { const bypass = await currentPermissionMode() === "bypassPermissions"; if (!bypass) { const message = await buildApprovalMessage( @@ -26017,8 +26017,8 @@ function buildRemoteArgs(serverId, toolName, resolvedArgs) { arguments: resolvedArgs }; } -function runToolAnnotations(enableHitl, clientSupportsElicitation) { - return enableHitl && clientSupportsElicitation ? { readOnlyHint: true } : void 0; +function runToolAnnotations(enableHitl, clientSupportsElicitation, isCursor) { + return enableHitl && clientSupportsElicitation && !isCursor ? { readOnlyHint: true } : void 0; } // src/url-config-store.ts @@ -26434,7 +26434,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { ...RUN_TOOL_TOOL, annotations: runToolAnnotations( process.env.ENABLE_HITL === "true", - !!server.getClientCapabilities()?.elicitation + !!server.getClientCapabilities()?.elicitation, + isCursorClient(server) ) }; const staticTools = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL]; diff --git a/src/index.ts b/src/index.ts index b5a639f..37f8f0e 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, + isCursorClient, + runToolAnnotations, +} from "./tools/run-tool.js"; import { evictStaleSkills } from "./skill-writer.js"; import { loadServerUrl, @@ -281,6 +285,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { annotations: runToolAnnotations( process.env.ENABLE_HITL === "true", !!server.getClientCapabilities()?.elicitation, + isCursorClient(server), ), }; const staticTools: Tool[] = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL]; diff --git a/src/tools/run-tool.ts b/src/tools/run-tool.ts index 7b62228..00c4f73 100644 --- a/src/tools/run-tool.ts +++ b/src/tools/run-tool.ts @@ -190,7 +190,7 @@ async function findToolJson( // A stdio server's only client signal is clientInfo.name. Cursor reports // "cursor-vscode" and already renders the tool name + arguments in its own // expandable UI, so its approval prompt only needs a one-line review ask. -function isCursorClient(mcpServer: Server): boolean { +export function isCursorClient(mcpServer: Server): boolean { return (mcpServer.getClientVersion()?.name ?? "") .toLowerCase() .startsWith("cursor"); @@ -329,9 +329,17 @@ export async function handleRunTool( } const hitlEnabled = process.env.ENABLE_HITL === "true"; + // Cursor is gated by its OWN native run-tool approval, not our elicitation. + // We omit run_tool's readOnlyHint for Cursor (see runToolAnnotations), so + // Cursor prompts the user before it executes run_tool. Firing our elicitation + // on top would be a redundant second gate — and worse, Cursor 3.12.x silently + // drops server-initiated elicitations on the auto-run lane, hanging for the + // full HITL timeout. So skip our gate for Cursor and let its native prompt + // (already shown before this call) be the single approval. if ( hitlEnabled && toolMeta?.requires_approval && + !isCursorClient(mcpServer) && mcpServer.getClientCapabilities()?.elicitation ) { // In bypassPermissions mode (`claude --dangerously-skip-permissions`) the @@ -423,12 +431,22 @@ export function buildRemoteArgs( * the tool `readOnlyHint` to suppress the client's native run-tool confirmation * and avoid a double prompt. Without HITL there is no gate of our own, so we * leave annotations unset and let the client decide. + * + * TEMP (Cursor): Cursor 3.12.x silently drops the server-initiated elicitation + * for a `run_tool` marked `readOnlyHint` (it lands on the auto-run lane), so the + * approval banner never shows and the call hangs to the HITL timeout. For Cursor + * we therefore flip the whole strategy: do NOT advertise `readOnlyHint` (so + * Cursor shows its OWN native run-tool approval before executing), and skip our + * elicitation entirely (see handleRunTool) so Cursor's native prompt is the + * single gate. Claude Code is unaffected: it keeps `readOnlyHint` (its native + * prompt stays suppressed) and our elicitation remains its gate. */ export function runToolAnnotations( enableHitl: boolean, clientSupportsElicitation: boolean, + isCursor: boolean, ): Tool["annotations"] { - return enableHitl && clientSupportsElicitation + return enableHitl && clientSupportsElicitation && !isCursor ? { readOnlyHint: true } : undefined; } diff --git a/tests/run-tool.test.ts b/tests/run-tool.test.ts index 91ae09e..b0361ab 100644 --- a/tests/run-tool.test.ts +++ b/tests/run-tool.test.ts @@ -340,6 +340,25 @@ describe("handleRunTool (HITL)", () => { expect(remote.callTool).toHaveBeenCalledTimes(1); }); + it("does NOT elicit for Cursor — its native prompt is the gate; executes directly", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn(); + const server = makeServer({ + elicitation: true, + clientName: "cursor-vscode", + elicit, + }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + // Cursor: readOnlyHint is omitted (native prompt shows), so our elicitation + // is skipped and the tool runs directly. + expect(elicit).not.toHaveBeenCalled(); + expect(remote.callTool).toHaveBeenCalledTimes(1); + }); + it("prompts with action name + arguments and forwards on accept", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); @@ -453,25 +472,6 @@ describe("handleRunTool (HITL)", () => { expect(text).toContain("NOT executed"); }); - it("gives Cursor a one-line prompt without an arguments block", async () => { - vi.stubEnv("ENABLE_HITL", "true"); - const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); - const server = makeServer({ - elicitation: true, - clientName: "cursor-vscode", - elicit, - }); - await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); - - await handleRunTool(remote, server, tmpDir, baseArgs); - - const message = elicit.mock.calls[0][0].message as string; - expect(message).toContain("Submit to allow and Cancel to deny"); - expect(message).not.toContain("Arguments:"); - expect(remote.callTool).toHaveBeenCalledTimes(1); - }); - it("spills large arguments to a file and keeps the prompt short", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); @@ -710,15 +710,21 @@ describe("formatArgumentsForFile", () => { }); describe("runToolAnnotations", () => { - it("marks run_tool read-only when HITL gates an elicitation-capable client", () => { - expect(runToolAnnotations(true, true)).toEqual({ readOnlyHint: true }); + it("marks run_tool read-only when HITL gates an elicitation-capable non-Cursor client", () => { + expect(runToolAnnotations(true, true, false)).toEqual({ + readOnlyHint: true, + }); }); it("leaves annotations unset when HITL is disabled", () => { - expect(runToolAnnotations(false, true)).toBeUndefined(); + expect(runToolAnnotations(false, true, false)).toBeUndefined(); }); it("leaves annotations unset when the client cannot elicit", () => { - expect(runToolAnnotations(true, false)).toBeUndefined(); + expect(runToolAnnotations(true, false, false)).toBeUndefined(); + }); + + it("does NOT advertise readOnlyHint to Cursor (its elicitation only renders on the attended lane)", () => { + expect(runToolAnnotations(true, true, true)).toBeUndefined(); }); });