Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
52 changes: 48 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.

90 changes: 89 additions & 1 deletion src/tools/run-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,66 @@ function buildApprovalMessage(
return [`Action: ${toolName}`, "Arguments:", formatArguments(args)].join("\n");
}

// Appended to a connector AUTH_REQUIRED result so the model surfaces the
// connector sign-in and does not confuse it with the plugin's own
// [SETUP_REQUIRED] (Glean sign-in). Plain text — clients don't reliably render
// markdown here.
const CONNECTOR_AUTH_SUFFIX =
"NOTE: This is NOT [SETUP_REQUIRED]. Glean itself is already authenticated " +
"— only this downstream tool/connector needs authorization. Do NOT call " +
"`setup`. Show the link(s) above, have the user authorize, then retry this " +
"tool.";

// 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;
}

// Plain-text heads-up shown to the user when a connector needs authorization.
// No clickable URLs (clients don't render markdown in elicitation; the spec
// discourages URLs in form fields) — the links come from the result text.
function connectorAuthPrompt(toolName: string): string {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This is separate from Glean setup; Glean is already connected.)
This part is not required

return (
`"${toolName}" needs you to authorize its connector before it can run. ` +
`The assistant will show the sign-in link(s) — open them, then ask it to ` +
`retry. (This is separate from Glean setup; Glean is already connected.)`
);
}

// Returns a copy of `result` with the disambiguation note appended as an extra
// text content block (the original envelope, including authUrls, stays intact).
function withConnectorAuthSuffix(result: CallToolResult): CallToolResult {
return {
...result,
content: [...result.content, { type: "text", text: CONNECTOR_AUTH_SUFFIX }],
};
}

export async function handleRunTool(
remoteClient: Client,
mcpServer: Server,
Expand Down Expand Up @@ -250,11 +310,39 @@ 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`. Make it explicit via a best-effort dialog and append a note so
// the model doesn't confuse it with the plugin's own [SETUP_REQUIRED] and
// wrongly call `setup`. Always-on (not gated by ENABLE_HITL) — this is
// surfacing, not an approval gate.
if (parseConnectorAuth(result)) {
if (mcpServer.getClientCapabilities()?.elicitation) {
try {
await mcpServer.elicitInput(
{
message: connectorAuthPrompt(toolName),
requestedSchema: { type: "object", properties: {} } as any,
},
{ timeout: hitlTimeoutMs() },
);
} catch {
// Informational only: a declined/cancelled/timed-out dialog must not
// change the outcome — the suffixed result still carries the links and
// the retry instruction for the model.
}
}
return withConnectorAuthSuffix(result);
}

return result;
}

/**
Expand Down
118 changes: 118 additions & 0 deletions tests/run-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,124 @@ describe("handleRunTool (HITL)", () => {
});
});

describe("handleRunTool (connector auth)", () => {
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. Show the user each URL...",
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("elicits (best-effort) and appends the disambiguation suffix on connector AUTH_REQUIRED", async () => {
const remote = remoteReturning(authResult);
const elicit = vi.fn().mockResolvedValue({ action: "accept" });
const server = makeServer({ elicitation: true, elicit });

const result = await handleRunTool(remote, server, tmpDir, baseArgs);

// Informational dialog fired (not gated by ENABLE_HITL, which is unset).
expect(elicit).toHaveBeenCalledTimes(1);
expect(elicit.mock.calls[0][0].message).toContain("authorize its connector");
expect(elicit.mock.calls[0][0].message).toContain("separate from Glean setup");

// Original envelope (authUrls) preserved + disambiguation note appended.
expect((result.content[0] as { text: string }).text).toContain("authUrls");
expect(lastText(result)).toContain("NOT [SETUP_REQUIRED]");
expect(lastText(result)).toContain("Do NOT call");
expect(result.isError).toBe(true);
});

it("appends the suffix without eliciting when the client cannot elicit", async () => {
const remote = remoteReturning(authResult);
const server = makeServer({ elicitation: false });

const result = await handleRunTool(remote, server, tmpDir, baseArgs);

expect(server.elicitInput).not.toHaveBeenCalled();
expect(lastText(result)).toContain("NOT [SETUP_REQUIRED]");
});

it("still appends the suffix when the dialog is declined or errors", async () => {
const elicits = [
vi.fn().mockResolvedValue({ action: "decline" }),
vi.fn().mockRejectedValue(new Error("timed out")),
];
for (const elicit of elicits) {
const remote = remoteReturning(authResult);
const server = makeServer({ elicitation: true, elicit });

const result = await handleRunTool(remote, server, tmpDir, baseArgs);

expect(lastText(result)).toContain("NOT [SETUP_REQUIRED]");
expect(result.isError).toBe(true);
}
});

it("passes a normal (non-error) result through unchanged", async () => {
const remote = remoteReturning({ content: [{ type: "text", text: "ok" }] });
const elicit = vi.fn();
const server = makeServer({ elicitation: true, elicit });

const result = await handleRunTool(remote, server, tmpDir, baseArgs);

expect(elicit).not.toHaveBeenCalled();
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 elicit = vi.fn();
const server = makeServer({ elicitation: true, elicit });

const result = await handleRunTool(remote, server, tmpDir, baseArgs);

expect(elicit).not.toHaveBeenCalled();
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 });
Expand Down
Loading