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.26",
"version": "0.2.27",
"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.26",
"version": "0.2.27",
"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.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"
Expand Down
96 changes: 91 additions & 5 deletions plugins/glean/dist/index.js

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

39 changes: 37 additions & 2 deletions 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,
handleRequestAuthConfirmation,
runToolAnnotations,
} from "./tools/run-tool.js";
import { evictStaleSkills } from "./skill-writer.js";
import {
loadServerUrl,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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}` }],
Expand Down
127 changes: 126 additions & 1 deletion src/tools/run-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
): Promise<CallToolResult> {
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,
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading
Loading