Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ HITL ones — or in your shell:
| `GLEAN_MCP_SERVER_URL` | Overrides the Glean server URL captured by `setup`. | URL saved by `setup` |
| `ENABLE_HITL` | Human-in-the-loop confirmation before `run_tool` runs a downstream tool. Active only when set to exactly `true`. | `true` (set in the shipped `.mcp.json`) |
| `HITL_TIMEOUT_MS` | Timeout in milliseconds for a HITL confirmation prompt. Positive integer. | `300000` (5 min), set in `.mcp.json` |
| `GLEAN_REMOTE_TOOL_TIMEOUT_MS` | Timeout in milliseconds for a downstream tool call made via `run_tool` (overrides the MCP SDK's 60s default). Positive integer. | `300000` (5 min), bundle default |
| `GLEAN_FILE_ARG_MAX_BYTES` | Maximum size in bytes of each file read via `run_tool`'s `file_args`. Positive integer. | `1048576` (1 MiB), bundle default |
| `USE_CLAUDE_PROJECT_DIR` | Set to `1` to route the skills cache under the launch project's `.claude/tmp/`, so the `glean_run` skill's `Read` glob can match cache files. | unset |

Expand Down
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.38",
"version": "0.2.39",
"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.38",
"version": "0.2.39",
"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.38",
"version": "0.2.39",
"description": "Search and act across your company's apps — Jira, Slack, Salesforce, Google Workspace, and more — without leaving Cursor.",
"author": {
"name": "Glean"
Expand Down
11 changes: 10 additions & 1 deletion plugins/glean/dist/index.js

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

22 changes: 21 additions & 1 deletion src/remote-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ import type { GleanOAuthClientProvider } from "./auth-provider.js";

const GLEAN_PLUGIN = "GLEAN_PLUGIN";

// The MCP SDK applies DEFAULT_REQUEST_TIMEOUT_MSEC (60s) to every request when
// callTool is invoked without an explicit timeout. Downstream Glean tools can
// legitimately take longer than 60s, so we override the per-call timeout.
// Configurable via GLEAN_REMOTE_TOOL_TIMEOUT_MS; falls back to a generous
// default that mirrors HITL_TIMEOUT_MS.
const DEFAULT_REMOTE_TOOL_TIMEOUT_MS = 300_000;

export function remoteToolTimeoutMs(): number {
const raw = process.env.GLEAN_REMOTE_TOOL_TIMEOUT_MS;
if (!raw) return DEFAULT_REMOTE_TOOL_TIMEOUT_MS;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_REMOTE_TOOL_TIMEOUT_MS;
}

function encodeVarint(value: number): number[] {
const bytes: number[] = [];
while (value > 0x7f) {
Expand Down Expand Up @@ -193,7 +209,11 @@ export async function callRemoteTool(
name: string,
args: Record<string, unknown>,
): Promise<CallToolResult> {
const result = await client.callTool({ name, arguments: args });
// Pass an explicit timeout so the call isn't capped at the SDK's 60s default.
// `undefined` for resultSchema keeps the SDK's CallToolResultSchema default.
const result = await client.callTool({ name, arguments: args }, undefined, {
timeout: remoteToolTimeoutMs(),
});
if (!("content" in result)) {
return { content: [] };
}
Expand Down
36 changes: 24 additions & 12 deletions tests/find-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,14 @@ describe("handleFindSkills", () => {

const result = await handleFindSkills(mockClient, tmpDir, {});

expect(mockClient.callTool).toHaveBeenCalledWith({
name: "find_skills",
arguments: {},
});
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: "find_skills",
arguments: {},
},
undefined,
expect.objectContaining({ timeout: expect.any(Number) }),
);

expect(result).toContain("<available_skills>");
expect(result).toContain('name="search-jira"');
Expand All @@ -70,10 +74,14 @@ describe("handleFindSkills", () => {
query: "create a calendar event",
});

expect(mockClient.callTool).toHaveBeenCalledWith({
name: "find_skills",
arguments: { queries: ["create a calendar event"] },
});
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: "find_skills",
arguments: { queries: ["create a calendar event"] },
},
undefined,
expect.objectContaining({ timeout: expect.any(Number) }),
);
});

it("passes queries array when provided", async () => {
Expand All @@ -83,10 +91,14 @@ describe("handleFindSkills", () => {
queries: ["search emails", "create calendar event"],
});

expect(mockClient.callTool).toHaveBeenCalledWith({
name: "find_skills",
arguments: { queries: ["search emails", "create calendar event"] },
});
expect(mockClient.callTool).toHaveBeenCalledWith(
{
name: "find_skills",
arguments: { queries: ["search emails", "create calendar event"] },
},
undefined,
expect.objectContaining({ timeout: expect.any(Number) }),
);
});

it("returns empty XML when response has no skills field", async () => {
Expand Down
82 changes: 80 additions & 2 deletions tests/remote-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { buildGatewayMetadataHeader } from "../src/remote-client.js";
import { describe, it, expect, afterEach } from "vitest";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
buildGatewayMetadataHeader,
callRemoteTool,
remoteToolTimeoutMs,
} from "../src/remote-client.js";

describe("buildGatewayMetadataHeader", () => {
it("produces stable output without session ID", () => {
Expand Down Expand Up @@ -54,3 +59,76 @@ describe("buildGatewayMetadataHeader", () => {
expect(content).toContain("my-session-42");
});
});

describe("remoteToolTimeoutMs", () => {
const KEY = "GLEAN_REMOTE_TOOL_TIMEOUT_MS";
const original = process.env[KEY];

afterEach(() => {
if (original === undefined) delete process.env[KEY];
else process.env[KEY] = original;
});

it("defaults to 300000ms when unset", () => {
delete process.env[KEY];
expect(remoteToolTimeoutMs()).toBe(300_000);
});

it("honors a valid positive override", () => {
process.env[KEY] = "120000";
expect(remoteToolTimeoutMs()).toBe(120_000);
});

it("falls back to default on a non-numeric value", () => {
process.env[KEY] = "not-a-number";
expect(remoteToolTimeoutMs()).toBe(300_000);
});

it("falls back to default on zero or negative values", () => {
process.env[KEY] = "0";
expect(remoteToolTimeoutMs()).toBe(300_000);
process.env[KEY] = "-5";
expect(remoteToolTimeoutMs()).toBe(300_000);
});
});

describe("callRemoteTool", () => {
const KEY = "GLEAN_REMOTE_TOOL_TIMEOUT_MS";
const original = process.env[KEY];

afterEach(() => {
if (original === undefined) delete process.env[KEY];
else process.env[KEY] = original;
});

it("passes the configured timeout to client.callTool", async () => {
const calls: Array<{ params: unknown; schema: unknown; options: unknown }> =
[];
const fakeClient = {
callTool: async (params: unknown, schema: unknown, options: unknown) => {
calls.push({ params, schema, options });
return { content: [{ type: "text", text: "ok" }] };
},
} as unknown as Client;

process.env[KEY] = "12345";
const result = await callRemoteTool(fakeClient, "some_tool", { a: 1 });

expect(calls).toHaveLength(1);
expect(calls[0].params).toEqual({
name: "some_tool",
arguments: { a: 1 },
});
expect(calls[0].options).toEqual({ timeout: 12345 });
expect(result.content).toEqual([{ type: "text", text: "ok" }]);
});

it("normalizes a result with no content field", async () => {
const fakeClient = {
callTool: async () => ({}),
} as unknown as Client;

const result = await callRemoteTool(fakeClient, "some_tool", {});
expect(result).toEqual({ content: [] });
});
});
Loading