Skip to content
Draft
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
60 changes: 56 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.

95 changes: 91 additions & 4 deletions src/tools/run-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
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";
import { appendFileSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -205,7 +206,7 @@ async function buildApprovalMessage(
args: unknown,
): Promise<string> {
if (isCursorClient(mcpServer)) {
return `Review the tool and arguments shown above, click on Submit to allow and Cancel to deny.`;
return `Review the tool and arguments shown above, then choose Approve to run it or Deny to block it, and submit.`;
}

const { lines, needsFile } = buildCompactArgs(args);
Expand All @@ -231,6 +232,69 @@ async function buildApprovalMessage(
return message.join("\n");
}

// The elicitation schema for the approval prompt.
//
// Claude Code advertises bare `elicitation: {}` and renders the `message` text
// with Accept/Decline buttons regardless of the schema, so an empty object is
// enough there. Cursor advertises a FORM-based variant (`elicitation:
// {form:{}}`) and builds the banner FROM the schema's `properties` — an empty
// `properties` renders no fields, so Cursor shows no visible approval banner
// and the gate is silently skipped. Cursor therefore gets one required
// Approve/Deny field: it guarantees the form renders and forces an explicit
// choice. Keep the non-Cursor path empty so Claude Code's working behavior is
// untouched.
export function approvalRequestedSchema(
isCursor: boolean,
): Record<string, unknown> {
if (!isCursor) {
return { type: "object", properties: {} };
}
return {
type: "object",
properties: {
decision: {
type: "string",
title: "Approve this action?",
description: "Approve to run the tool, or Deny to block it.",
enum: ["Approve", "Deny"],
},
},
required: ["decision"],
};
}

// Whether an elicitation result approves the action. Non-Cursor clients gate
// purely on the action (the Accept button). Cursor submits a form, so a
// submitted "Deny" comes back as action "accept" with decision "Deny" — that is
// a rejection, not an approval, so Cursor approval also requires decision
// "Approve".
export function isApproved(
isCursor: boolean,
result: { action: string; content?: Record<string, unknown> },
): boolean {
if (result.action !== "accept") return false;
if (!isCursor) return true;
return result.content?.decision === "Approve";
}

// Structural HITL telemetry appended to the shared server log. Mirrors
// index.ts's logLine (private to that module) and additionally stamps the
// pid: multiple plugin instances can share one log file (a marketplace
// install next to a local checkout), and the pid ties each line to a
// specific process. Logs tool names and gate decisions only — never
// argument values, which can carry PII/secrets.
function hitlLog(label: string, detail: Record<string, unknown>): void {
const base =
process.env.PLUGIN_DATA_DIR || path.join(os.homedir(), ".glean");
const line = `${new Date().toISOString()} ${label} ${JSON.stringify({ pid: process.pid, ...detail })}\n`;
try {
appendFileSync(path.join(base, "glean-server.log"), line, { mode: 0o600 });
} catch {
/* best-effort */
}
console.error(line.trimEnd());
}

// A WeakSet so a short-lived server in tests doesn't leak,
// and so the burn happens exactly once per server instance.
const elicitationIdPrimed = new WeakSet<object>();
Expand Down Expand Up @@ -329,6 +393,13 @@ export async function handleRunTool(
}

const hitlEnabled = process.env.ENABLE_HITL === "true";
hitlLog("hitl.gate", {
tool: toolName,
hitlEnabled,
requiresApproval: !!toolMeta?.requires_approval,
elicitationCap: mcpServer.getClientCapabilities()?.elicitation ?? null,
client: mcpServer.getClientVersion()?.name ?? null,
});
if (
hitlEnabled &&
toolMeta?.requires_approval &&
Expand All @@ -349,25 +420,40 @@ export async function handleRunTool(
resolvedArgs,
);
const timeout = hitlTimeoutMs();
const cursor = isCursorClient(mcpServer);

// Make a dummy empty request to burn JSON-RPC request id 0
primeElicitationCancellation(mcpServer);

hitlLog("hitl.elicit-sent", {
tool: toolName,
schema: cursor ? "cursor-form" : "empty",
timeout,
});

try {
const result = await mcpServer.elicitInput(
{
message,
requestedSchema: { type: "object", properties: {} } as any,
requestedSchema: approvalRequestedSchema(cursor) as any,
},
{ timeout },
);

if (result.action !== "accept") {
hitlLog("hitl.elicit-result", {
tool: toolName,
action: result.action,
decision:
(result.content as Record<string, unknown> | undefined)
?.decision ?? null,
});

if (!isApproved(cursor, result)) {
return {
content: [
{
type: "text",
text: `Action ${toolName} was ${result.action === "decline" ? "declined" : "cancelled"} by the user.`,
text: `Action ${toolName} was ${result.action === "cancel" ? "cancelled" : "declined"} by the user.`,
},
],
};
Expand All @@ -377,6 +463,7 @@ export async function handleRunTool(
// prompt times out or errors defeats its own purpose — and the SDK
// rejects elicitInput precisely on request timeout.
const detail = err instanceof Error ? err.message : String(err);
hitlLog("hitl.elicit-error", { tool: toolName, msg: detail });
return {
content: [
{
Expand Down
Loading
Loading