Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/glean/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/glean/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/glean/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
9 changes: 5 additions & 4 deletions plugins/glean/dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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];
Expand Down
22 changes: 20 additions & 2 deletions src/tools/run-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
52 changes: 29 additions & 23 deletions tests/run-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
});
Loading