diff --git a/README.md b/README.md index 16c1bb9..f320098 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/plugins/glean/.claude-plugin/plugin.json b/plugins/glean/.claude-plugin/plugin.json index c2700e0..4befbfc 100644 --- a/plugins/glean/.claude-plugin/plugin.json +++ b/plugins/glean/.claude-plugin/plugin.json @@ -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" diff --git a/plugins/glean/.codex-plugin/plugin.json b/plugins/glean/.codex-plugin/plugin.json index 6fdd874..e1ec975 100644 --- a/plugins/glean/.codex-plugin/plugin.json +++ b/plugins/glean/.codex-plugin/plugin.json @@ -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" diff --git a/plugins/glean/.cursor-plugin/plugin.json b/plugins/glean/.cursor-plugin/plugin.json index 788f628..931b215 100644 --- a/plugins/glean/.cursor-plugin/plugin.json +++ b/plugins/glean/.cursor-plugin/plugin.json @@ -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" diff --git a/plugins/glean/dist/index.js b/plugins/glean/dist/index.js index d3189b5..11913da 100644 --- a/plugins/glean/dist/index.js +++ b/plugins/glean/dist/index.js @@ -25129,6 +25129,13 @@ var StreamableHTTPClientTransport = class { // src/remote-client.ts var GLEAN_PLUGIN = "GLEAN_PLUGIN"; +var DEFAULT_REMOTE_TOOL_TIMEOUT_MS = 3e5; +function remoteToolTimeoutMs() { + 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) { const bytes = []; while (value > 127) { @@ -25250,7 +25257,9 @@ async function createRemoteClient(serverUrl, opts, chatSessionId) { return client; } async function callRemoteTool(client, name, args) { - const result = await client.callTool({ name, arguments: args }); + const result = await client.callTool({ name, arguments: args }, void 0, { + timeout: remoteToolTimeoutMs() + }); if (!("content" in result)) { return { content: [] }; } diff --git a/src/remote-client.ts b/src/remote-client.ts index b019955..954cf52 100644 --- a/src/remote-client.ts +++ b/src/remote-client.ts @@ -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) { @@ -193,7 +209,11 @@ export async function callRemoteTool( name: string, args: Record, ): Promise { - 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: [] }; } diff --git a/tests/find-skills.test.ts b/tests/find-skills.test.ts index b1f6194..4c9de80 100644 --- a/tests/find-skills.test.ts +++ b/tests/find-skills.test.ts @@ -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(""); expect(result).toContain('name="search-jira"'); @@ -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 () => { @@ -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 () => { diff --git a/tests/remote-client.test.ts b/tests/remote-client.test.ts index 9c8bbda..2975974 100644 --- a/tests/remote-client.test.ts +++ b/tests/remote-client.test.ts @@ -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", () => { @@ -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: [] }); + }); +});