Skip to content
Open
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
25 changes: 25 additions & 0 deletions packages/core/sdk/src/elicitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,35 @@ export type ElicitationHandler = (ctx: ElicitationContext) => Effect.Effect<Elic
* auto-accept every request (tests / non-interactive hosts). */
export type OnElicitation = ElicitationHandler | "accept-all";

/** A `notifications/progress` update from a long-running tool call, carried
* up to the caller that opted in via `InvokeOptions.onProgress`. */
export interface InvocationProgress {
/** Progress so far, in units defined by the server. */
readonly progress: number;
/** Expected total when the server declares one. */
readonly total?: number;
/** Optional human-readable status message. */
readonly message?: string;
}

/** Per-call options for `execute`. */
export interface InvokeOptions {
/** Override the executor-level handler for this single call. */
readonly onElicitation?: OnElicitation;
/** 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;
}

/** A tool was declined or cancelled during elicitation. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export {
type ElicitationContext,
type OnElicitation,
type InvokeOptions,
type InvocationProgress,
} from "./elicitation";

// Blob store — the plugin-facing contract (`BlobStore`/`PluginBlobStore`)
Expand Down
16 changes: 15 additions & 1 deletion packages/core/sdk/src/promise-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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> = T extends EffectInvokeOptions | undefined
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export {
type ElicitationHandler,
type OnElicitation,
type InvokeOptions,
type InvocationProgress,
} from "./elicitation";

// Tool-policy helpers + projections (pure functions / Schema).
Expand Down
86 changes: 86 additions & 0 deletions packages/plugins/mcp/src/sdk/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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* () {
Expand Down
52 changes: 49 additions & 3 deletions packages/plugins/mcp/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -28,6 +28,7 @@ import {
UrlElicitation,
type Elicit,
type ElicitationRequest,
type InvokeOptions,
} from "@executor-js/sdk/core";

import { McpConnectionError, McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";
Expand Down Expand Up @@ -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<string, unknown>,
elicit: Elicit,
onToolListChanged: (() => void) | undefined,
invokeOptions: InvokeOptions | undefined,
): Effect.Effect<unknown, McpInvocationError | McpOAuthReauthorizationRequired> =>
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({
Expand Down Expand Up @@ -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. */
Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1711,6 +1711,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
connector,
...(poolKey === undefined ? {} : { connectionPool, connectionPoolKey: poolKey }),
elicit,
...(invokeOptions === undefined ? {} : { invokeOptions }),
onToolListChanged: () => {
toolListChanged = true;
},
Expand Down
Loading