From 14f33fbf91aca34209bccd5bcc616dd8c44202f3 Mon Sep 17 00:00:00 2001 From: kelvin lee wei sern Date: Mon, 14 Sep 2026 16:28:18 +0800 Subject: [PATCH] mcp: expose request options through InvokeOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP tool calls are capped at the client SDK's 60s default request timeout with no way for a caller to extend it. Long-running tools — deployments, builds, batch operations — fail at the one-minute mark. Add four fields to InvokeOptions (timeoutMs, maxTotalTimeoutMs, resetTimeoutOnProgress, onProgress) and map them onto the MCP SDK's per-request RequestOptions inside the mcp plugin's callTool hop. InvokeOptions already threads from executor.execute() through plugin.invokeTool, so this stays additive: callers that set nothing keep the SDK defaults, and other plugins ignore fields they don't consume. --- packages/core/sdk/src/elicitation.ts | 25 ++++++ packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/promise-executor.ts | 16 +++- packages/core/sdk/src/promise.ts | 1 + packages/core/sdk/src/shared.ts | 1 + packages/plugins/mcp/src/sdk/invoke.test.ts | 86 +++++++++++++++++++++ packages/plugins/mcp/src/sdk/invoke.ts | 52 ++++++++++++- packages/plugins/mcp/src/sdk/plugin.ts | 3 +- 8 files changed, 180 insertions(+), 5 deletions(-) diff --git a/packages/core/sdk/src/elicitation.ts b/packages/core/sdk/src/elicitation.ts index 290349e3cd..6a2dd2e42a 100644 --- a/packages/core/sdk/src/elicitation.ts +++ b/packages/core/sdk/src/elicitation.ts @@ -62,10 +62,35 @@ export type ElicitationHandler = (ctx: ElicitationContext) => Effect.Effect void; } /** A tool was declined or cancelled during elicitation. */ diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index d8ac973134..f694e115b4 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -233,6 +233,7 @@ export { type ElicitationContext, type OnElicitation, type InvokeOptions, + type InvocationProgress, } from "./elicitation"; // Blob store — the plugin-facing contract (`BlobStore`/`PluginBlobStore`) diff --git a/packages/core/sdk/src/promise-executor.ts b/packages/core/sdk/src/promise-executor.ts index 0c59874e9f..4309d64605 100644 --- a/packages/core/sdk/src/promise-executor.ts +++ b/packages/core/sdk/src/promise-executor.ts @@ -21,7 +21,7 @@ import { type InvokeOptions as EffectInvokeOptions, type OnElicitation, } from "./executor"; -import type { ElicitationContext, ElicitationResponse } from "./elicitation"; +import type { ElicitationContext, ElicitationResponse, InvocationProgress } from "./elicitation"; import type { FumaDb, FumaTables } from "./fuma-runtime"; import { Subject, Tenant } from "./ids"; import type { AnyPlugin } from "./plugin"; @@ -63,6 +63,20 @@ export type PromiseOnElicitation = export interface PromiseInvokeOptions { readonly onElicitation?: PromiseOnElicitation; + /** Per-request timeout in milliseconds for transports that support it + * (MCP). Omit to keep the transport default — the MCP SDK's is 60s. */ + readonly timeoutMs?: number; + /** Hard cap in milliseconds on the whole request, including progress- + * extended time. Bounds `resetTimeoutOnProgress` so a chatty server + * cannot keep a call alive forever. MCP only. */ + readonly maxTotalTimeoutMs?: number; + /** Reset the request timeout each time a progress notification arrives — + * keeps long-running tools alive as long as they keep reporting. + * Pair with `maxTotalTimeoutMs` for an absolute ceiling. MCP only. */ + readonly resetTimeoutOnProgress?: boolean; + /** Called for each progress notification the server sends during the + * call. Supplying it also requests progress from the server. MCP only. */ + readonly onProgress?: (progress: InvocationProgress) => void; } type PromisifiedArg = T extends EffectInvokeOptions | undefined diff --git a/packages/core/sdk/src/promise.ts b/packages/core/sdk/src/promise.ts index d48d106071..48dfa6480e 100644 --- a/packages/core/sdk/src/promise.ts +++ b/packages/core/sdk/src/promise.ts @@ -62,6 +62,7 @@ export { type ElicitationRequest, type ElicitationContext, type ElicitationHandler, + type InvocationProgress, } from "./elicitation"; // File-config helper for the CLI. Plain typed-object factory with no diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 391c12e3fa..493667b260 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -85,6 +85,7 @@ export { type ElicitationHandler, type OnElicitation, type InvokeOptions, + type InvocationProgress, } from "./elicitation"; // Tool-policy helpers + projections (pure functions / Schema). diff --git a/packages/plugins/mcp/src/sdk/invoke.test.ts b/packages/plugins/mcp/src/sdk/invoke.test.ts index ee9100fb6b..b80c1cb928 100644 --- a/packages/plugins/mcp/src/sdk/invoke.test.ts +++ b/packages/plugins/mcp/src/sdk/invoke.test.ts @@ -34,6 +34,26 @@ const rejectingConnector = (cause: unknown): McpConnector => close: () => Promise.resolve(), }); +// Resolves like a real callTool, recording the params and request options it +// was invoked with so tests can assert the InvokeOptions → RequestOptions +// mapping without standing up a server. +const recordingConnector = () => { + const calls: { params: unknown; options: unknown }[] = []; + const connector: McpConnector = Effect.succeed({ + // oxlint-disable-next-line executor/no-double-cast -- boundary: minimal fake MCP client implements only the methods invokeMcpTool calls + client: { + setRequestHandler: () => undefined, + setNotificationHandler: () => undefined, + callTool: (params: unknown, options: unknown) => { + calls.push({ params, options }); + return Promise.resolve({ content: [], isError: false }); + }, + } as unknown as McpConnection["client"], + close: () => Promise.resolve(), + }); + return { calls, connector }; +}; + const reauthorizationProvider: OAuthClientProvider = { get redirectUrl() { return "http://localhost/oauth/callback"; @@ -194,6 +214,72 @@ describe("invokeMcpTool", () => { }), ); + it.effect("maps InvokeOptions onto callTool RequestOptions", () => + Effect.gen(function* () { + const { calls, connector } = recordingConnector(); + yield* invokeMcpTool({ + toolId: "slow", + toolName: "slow", + args: {}, + transport: "streamable-http", + connector, + elicit: acceptAll, + invokeOptions: { + timeoutMs: 300_000, + maxTotalTimeoutMs: 900_000, + resetTimeoutOnProgress: true, + }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]!.options).toEqual({ + timeout: 300_000, + maxTotalTimeout: 900_000, + resetTimeoutOnProgress: true, + }); + }), + ); + + it.effect("routes server progress notifications to InvokeOptions.onProgress", () => + Effect.gen(function* () { + const { calls, connector } = recordingConnector(); + const seen: { progress: number; total?: number; message?: string }[] = []; + yield* invokeMcpTool({ + toolId: "slow", + toolName: "slow", + args: {}, + transport: "streamable-http", + connector, + elicit: acceptAll, + invokeOptions: { onProgress: (p) => void seen.push(p) }, + }); + + const options = calls[0]!.options as { + onprogress: (p: { progress: number; total?: number; message?: string }) => void; + }; + expect(typeof options.onprogress).toBe("function"); + options.onprogress({ progress: 3, total: 10, message: "working" }); + options.onprogress({ progress: 4 }); + expect(seen).toEqual([{ progress: 3, total: 10, message: "working" }, { progress: 4 }]); + }), + ); + + it.effect("passes no RequestOptions when invokeOptions is omitted", () => + Effect.gen(function* () { + const { calls, connector } = recordingConnector(); + yield* invokeMcpTool({ + toolId: "fast", + toolName: "fast", + args: {}, + transport: "streamable-http", + connector, + elicit: acceptAll, + }); + + expect(calls[0]!.options).toBeUndefined(); + }), + ); + it.effect("preserves OAuth reauthorization required during auto connection setup", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 4b7a433c7c..a2fb815b29 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -16,7 +16,7 @@ import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; -import type { ProtocolError } from "@modelcontextprotocol/client"; +import type { ProtocolError, RequestOptions } from "@modelcontextprotocol/client"; // SDK error classes come through the lazy loader; by the time a tool call can // fail, the connect path has always loaded the module (see client-module.ts). @@ -28,6 +28,7 @@ import { UrlElicitation, type Elicit, type ElicitationRequest, + type InvokeOptions, } from "@executor-js/sdk/core"; import { McpConnectionError, McpInvocationError, McpOAuthReauthorizationRequired } from "./errors"; @@ -210,18 +211,52 @@ const installToolListChangedHandler = ( // Single tool call — install handlers, callTool, return raw result // --------------------------------------------------------------------------- +// Map caller InvokeOptions onto the MCP SDK's per-request RequestOptions. +// `signal` is deliberately not exposed: Effect interruption already abandons +// the fiber, and a caller-held AbortSignal would have to outlive the pooled +// connection lease. `undefined` stays `undefined` so the SDK's own defaults +// (60s request timeout, no progress reset) apply untouched. +const requestOptions = (options: InvokeOptions | undefined): RequestOptions | undefined => { + if (options === undefined) return undefined; + const requestOptions: RequestOptions = { + ...(options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }), + ...(options.maxTotalTimeoutMs === undefined + ? {} + : { maxTotalTimeout: options.maxTotalTimeoutMs }), + ...(options.resetTimeoutOnProgress === undefined + ? {} + : { resetTimeoutOnProgress: options.resetTimeoutOnProgress }), + ...(options.onProgress === undefined + ? {} + : { + onprogress: (progress) => + options.onProgress?.({ + progress: progress.progress, + ...(progress.total === undefined ? {} : { total: progress.total }), + ...(progress.message === undefined ? {} : { message: progress.message }), + }), + }), + }; + return Object.keys(requestOptions).length > 0 ? requestOptions : undefined; +}; + const useConnection = ( connection: McpConnection, toolName: string, args: Record, elicit: Elicit, onToolListChanged: (() => void) | undefined, + invokeOptions: InvokeOptions | undefined, ): Effect.Effect => Effect.gen(function* () { installElicitationHandler(connection.client, elicit); installToolListChangedHandler(connection.client, onToolListChanged); return yield* Effect.tryPromise({ - try: () => connection.client.callTool({ name: toolName, arguments: args }), + try: () => + connection.client.callTool( + { name: toolName, arguments: args }, + requestOptions(invokeOptions), + ), catch: (cause) => { if (Predicate.isTagged(cause, "McpOAuthReauthorizationRequired")) { return new McpOAuthReauthorizationRequired({ @@ -277,6 +312,10 @@ export interface InvokeMcpToolInput { readonly connectionPool?: McpConnectionPool; readonly connectionPoolKey?: string; readonly elicit: Elicit; + /** Caller-supplied per-call options. `timeoutMs` / `maxTotalTimeoutMs` / + * `resetTimeoutOnProgress` / `onProgress` map onto the MCP SDK's + * RequestOptions; omit to keep the SDK defaults (60s timeout). */ + readonly invokeOptions?: InvokeOptions; /** Fired when the server sends `notifications/tools/list_changed` during * the call window. Synchronous and non-throwing by contract; the caller * uses it to mark the persisted catalog stale. */ @@ -292,7 +331,14 @@ export const invokeMcpTool = ( Effect.gen(function* () { const args = argsRecord(input.args); const use = (connection: McpConnection) => - useConnection(connection, input.toolName, args, input.elicit, input.onToolListChanged); + useConnection( + connection, + input.toolName, + args, + input.elicit, + input.onToolListChanged, + input.invokeOptions, + ); if (input.connectionPool && input.connectionPoolKey) { return yield* input.connectionPool.withConnection( diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 4af0c4bf58..1f9c0e27f2 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1629,7 +1629,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { StorageFailure >, - invokeTool: ({ ctx, toolRow, credential, args, elicit }) => + invokeTool: ({ ctx, toolRow, credential, args, elicit, invokeOptions }) => Effect.gen(function* () { const parsed = parseMcpIntegrationConfig(credential.config); if (!parsed) { @@ -1711,6 +1711,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { connector, ...(poolKey === undefined ? {} : { connectionPool, connectionPoolKey: poolKey }), elicit, + ...(invokeOptions === undefined ? {} : { invokeOptions }), onToolListChanged: () => { toolListChanged = true; },