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
30 changes: 26 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.

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

// Instruction to the ASSISTANT (not user-facing) appended to a connector
// AUTH_REQUIRED result: keep the user-facing ask minimal and don't leak auth
// internals, while still preventing a wrong `setup` call.
const CONNECTOR_AUTH_SUFFIX =
"Assistant: ask the user only to authorize using the link above. Do not " +

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.

Without giving a prompt like this, the LLM keeps saying stuff like
I won't call setup,
We already authenticated to Glean, now connectors
etc.

"mention Glean, connectors, setup, or any auth internals, and do not call " +
"the `setup` tool.";

// Detect the gateway's connector AUTH_REQUIRED envelope: an error result whose
// first text content is JSON carrying a non-empty `authUrls` array. 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 isConnectorAuth(result: CallToolResult): boolean {
if (!result.isError) return false;
const text = result.content?.find((c) => c.type === "text");
if (!text || text.type !== "text") return false;
try {
const parsed = JSON.parse(text.text);
return (
!!parsed &&
typeof parsed === "object" &&
Array.isArray((parsed as { authUrls?: unknown }).authUrls) &&
(parsed as { authUrls: unknown[] }).authUrls.length > 0
);
} catch {
return false;
}
}

// Append the connector-auth clarification as an extra text block, leaving the
// gateway's original content (links and all) untouched.
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 +288,23 @@ 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`. Append a clarification so the model doesn't confuse it with the
// plugin's own [SETUP_REQUIRED] and wrongly call `setup`. The gateway's
// original content (including the link) is left untouched.
if (isConnectorAuth(result)) {
return withConnectorAuthSuffix(result);
}

return result;
}

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

describe("handleRunTool (connector auth suffix)", () => {
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...",
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("appends a minimal authorize instruction on connector AUTH_REQUIRED, leaving original content intact", async () => {
const remote = remoteReturning(authResult);
const server = makeServer({ elicitation: true });

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

// Original envelope preserved (links untouched) + suffix appended.
expect(result.content).toHaveLength(2);
expect((result.content[0] as { text: string }).text).toContain("authUrls");
expect(lastText(result)).toContain("authorize");
expect(lastText(result)).toContain("setup");
expect(result.isError).toBe(true);
// Suffix only — no dialog.
expect(server.elicitInput).not.toHaveBeenCalled();
});

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

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

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 server = makeServer({ elicitation: true });

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

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