From 28e58ac8f08308be13dda8b18d260776366d11b7 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Mon, 26 Oct 2026 17:43:35 -0700 Subject: [PATCH 01/16] fix(openai-agents): send MCP tool call _meta from the plugin's MCP servers The agent builds each MCP tool call's `_meta` with the server's `toolMetaResolver` and passes it to `callTool`. The plugin's references dropped that argument, and the Worker-side server's resolver never ran, so MCP servers got no `_meta`. - `statelessMcpServer()` and `statefulMcpServer()` take a new `toolMetaResolver` option that the agent runs in the Workflow with the run context, as for a native server. - The references pass the `_meta` to the call-tool Activity, which passes it to the server's `callTool`. A `toolMetaResolver` on the factory's server needs the run context, so the Worker skips it and logs a warning. --- typescript/openai-agents/README.md | 12 ++++ .../src/__tests__/test-openai-agents.ts | 71 +++++++++++++++++++ .../src/__tests__/workflows/openai-agents.ts | 25 ++++++- .../openai-agents/src/worker/mcp-provider.ts | 24 ++++++- .../src/worker/stateful-mcp-provider.ts | 5 +- .../openai-agents/src/workflow/mcp-client.ts | 11 ++- .../src/workflow/stateful-mcp-client.ts | 20 +++++- 7 files changed, 161 insertions(+), 7 deletions(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index 5a1f0d2e..254ef674 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -511,6 +511,18 @@ export async function statefulMcpWorkflow(prompt: string): Promise { Dedicated Worker startup and heartbeat failures surface as `ApplicationFailure` type (exported as `DEDICATED_WORKER_FAILURE_TYPE`). +### MCP request metadata + +A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker skips a `toolMetaResolver` on the factory's server and logs a warning. + +```ts +const server = statelessMcpServer('tickets', { + toolMetaResolver: ({ runContext }) => ({ tenant: (runContext.context as { tenant: string }).tenant }), +}); +``` + +The `_meta` values are recorded in the call-tool Activity input in Workflow History, so don't put secrets in them. + ## Sandbox agents > **Experimental** — sandbox support is subject to change prior to General Availability. diff --git a/typescript/openai-agents/src/__tests__/test-openai-agents.ts b/typescript/openai-agents/src/__tests__/test-openai-agents.ts index a105ba67..3e6f4639 100644 --- a/typescript/openai-agents/src/__tests__/test-openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/test-openai-agents.ts @@ -16,6 +16,7 @@ import { import { localActivityAgentWorkflow, mcpFactoryArgWorkflow, + mcpToolMetaWorkflow, summaryOverrideStringWorkflow, statefulMcpNotConnectedWorkflow, statefulMcpIsolationWorkflow, @@ -206,6 +207,76 @@ test('factoryArgument is passed through to MCP activities', async (t) => { }); }); +function metaTestServer(name: string, metas: unknown[]): MCPServer { + return { + cacheToolsList: false, + name, + async connect() {}, + async close() {}, + async listTools() { + return [ + { + name: 'echo_meta', + description: 'Records the request _meta', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[], + additionalProperties: false, + }, + }, + ]; + }, + async callTool(_toolName: string, _args: Record | null, meta?: Record | null) { + metas.push(meta); + return [{ type: 'text', text: 'ok' }]; + }, + async invalidateToolsCache() {}, + }; +} + +test('MCP toolMetaResolver sends _meta on stateless and stateful servers', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + + const metas: unknown[] = []; + const worker = await createWorker({ + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new FakeModelProvider([ + toolCallResponse('echo_meta', {}), + textResponse('stateless done'), + toolCallResponse('echo_meta', {}), + textResponse('stateful done'), + ]), + mcpServerProviders: [ + new StatelessMCPServerProvider('metaStateless', () => metaTestServer('metaStateless', metas)), + new StatefulMCPServerProvider( + 'metaStateful', + () => metaTestServer('metaStateful', metas), + t.context.env.nativeConnection + ), + ], + }), + ], + }); + + await worker.runUntil(async () => { + t.is( + await executeWorkflow(mcpToolMetaWorkflow, { args: [false], workflowExecutionTimeout: '30 seconds' }), + 'stateless done' + ); + t.is( + await executeWorkflow(mcpToolMetaWorkflow, { args: [true], workflowExecutionTimeout: '30 seconds' }), + 'stateful done' + ); + }); + + t.deepEqual(metas, [ + { tenant: 'tenant-7', tool: 'echo_meta' }, + { tenant: 'tenant-7', tool: 'echo_meta' }, + ]); +}); + test('summary string is passed through to model activity', async (t) => { const { createWorker, taskQueue } = helpers(t); diff --git a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts index b739c0d1..502f077f 100644 --- a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts @@ -1,4 +1,4 @@ -import { Agent } from '@openai/agents-core'; +import { Agent, type MCPToolMetaContext } from '@openai/agents-core'; import { ApplicationFailure } from '@temporalio/workflow'; import { WorkflowStream } from '@temporalio/workflow-streams/workflow'; import { TemporalOpenAIRunner, statelessMcpServer, statefulMcpServer } from '../../workflow'; @@ -99,6 +99,29 @@ export async function mcpFactoryArgWorkflow(prompt: string): Promise { return result.finalOutput ?? ''; } +export async function mcpToolMetaWorkflow(stateful: boolean): Promise { + const toolMetaResolver = ({ runContext, toolName }: MCPToolMetaContext) => ({ + tenant: (runContext.context as { tenant: string }).tenant, + tool: toolName, + }); + const server = stateful + ? statefulMcpServer('metaStateful', { toolMetaResolver }) + : statelessMcpServer('metaStateless', { toolMetaResolver }); + await server.connect(); + try { + const agent = new Agent({ + name: 'McpToolMetaAgent', + instructions: 'You have access to MCP tools.', + model: 'gpt-4o-mini', + mcpServers: [server], + }); + const result = await new TemporalOpenAIRunner().run(agent, 'Hello', { context: { tenant: 'tenant-7' } }); + return result.finalOutput ?? ''; + } finally { + await server.close(); + } +} + export async function summaryOverrideStringWorkflow(prompt: string): Promise { const agent = new Agent({ name: 'SummaryAgent', diff --git a/typescript/openai-agents/src/worker/mcp-provider.ts b/typescript/openai-agents/src/worker/mcp-provider.ts index 859b1544..7da1122e 100644 --- a/typescript/openai-agents/src/worker/mcp-provider.ts +++ b/typescript/openai-agents/src/worker/mcp-provider.ts @@ -1,6 +1,27 @@ import type { MCPServer } from '@openai/agents-core'; +import { log } from '@temporalio/activity'; import { MCP_CALL_TOOL_SUFFIX, MCP_LIST_TOOLS_SUFFIX, MCP_STATELESS_SUFFIX } from '../common/mcp-types'; +/** + * Calls a tool with the `_meta` the Workflow built, as the agent does for a native server. A + * `toolMetaResolver` on the Worker-side server needs the run context, which only the Workflow + * has, so it is skipped with a warning. + */ +export async function callToolWithMeta( + server: MCPServer, + toolName: string, + args: Record | null, + meta: Record | undefined +): ReturnType { + if (server.toolMetaResolver) { + log.warn( + `MCP server '${server.name}' has a toolMetaResolver, which the Worker cannot run. ` + + 'Pass it as the toolMetaResolver option of statelessMcpServer() or statefulMcpServer() in the Workflow.' + ); + } + return meta === undefined ? server.callTool(toolName, args) : server.callTool(toolName, args, meta); +} + export class StatelessMCPServerProvider { private readonly _name: string; @@ -25,12 +46,13 @@ export class StatelessMCPServerProvider { [`${this._name}${MCP_CALL_TOOL_SUFFIX}`]: async (input: { toolName: string; args: Record | null; + meta?: Record; factoryArgument?: unknown; }) => { const s = this.serverFactory(input.factoryArgument); try { await s.connect(); - return await s.callTool(input.toolName, input.args); + return await callToolWithMeta(s, input.toolName, input.args, input.meta); } finally { await s.close(); } diff --git a/typescript/openai-agents/src/worker/stateful-mcp-provider.ts b/typescript/openai-agents/src/worker/stateful-mcp-provider.ts index c2072ece..e8a091e5 100644 --- a/typescript/openai-agents/src/worker/stateful-mcp-provider.ts +++ b/typescript/openai-agents/src/worker/stateful-mcp-provider.ts @@ -10,6 +10,7 @@ import { type StatefulMcpServerSessionArgs, } from '../common/mcp-types'; import { startAdaptiveHeartbeat } from './heartbeat'; +import { callToolWithMeta } from './mcp-provider'; export function registerCancelShutdown(ctx: Context, dedicatedWorker: { shutdown: () => void }): void { ctx.cancelled.catch(() => { @@ -20,6 +21,7 @@ export function registerCancelShutdown(ctx: Context, dedicatedWorker: { shutdown interface CallToolArgs { toolName: string; args: Record | null; + meta?: Record; } /** @@ -78,7 +80,8 @@ export class StatefulMCPServerProvider { // Operation Activities — registered on the per-run Task Queue. const listTools = async () => getServerOrThrow().listTools(); - const callTool = async (input: CallToolArgs) => getServerOrThrow().callTool(input.toolName, input.args); + const callTool = async (input: CallToolArgs) => + callToolWithMeta(getServerOrThrow(), input.toolName, input.args, input.meta); // Long-running session Activity on the MAIN Worker's Task Queue. Creates // a server, connects it, spins up a dedicated per-run Worker, then awaits diff --git a/typescript/openai-agents/src/workflow/mcp-client.ts b/typescript/openai-agents/src/workflow/mcp-client.ts index f944b46d..d85f71d9 100644 --- a/typescript/openai-agents/src/workflow/mcp-client.ts +++ b/typescript/openai-agents/src/workflow/mcp-client.ts @@ -1,4 +1,4 @@ -import type { MCPServer } from '@openai/agents-core'; +import type { MCPServer, MCPToolMetaResolver } from '@openai/agents-core'; import type { Duration, RetryPolicy } from '@temporalio/common'; import { proxyActivities } from '@temporalio/workflow'; import { MCP_CALL_TOOL_SUFFIX, MCP_LIST_TOOLS_SUFFIX, MCP_STATELESS_SUFFIX } from '../common/mcp-types'; @@ -10,6 +10,11 @@ export interface StatelessMcpServerOptions { taskQueue?: string; retryPolicy?: RetryPolicy; factoryArgument?: unknown; + /** + * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the + * run context and must be deterministic. + */ + toolMetaResolver?: MCPToolMetaResolver; } export function statelessMcpServer(name: string, options?: StatelessMcpServerOptions): MCPServer { @@ -30,6 +35,7 @@ export function statelessMcpServer(name: string, options?: StatelessMcpServerOpt return { cacheToolsList: shouldCache, + toolMetaResolver: options?.toolMetaResolver, get name() { return name; }, @@ -49,10 +55,11 @@ export function statelessMcpServer(name: string, options?: StatelessMcpServerOpt } return tools; }, - async callTool(toolName: string, args: Record | null) { + async callTool(toolName: string, args: Record | null, meta?: Record | null) { return activities[callToolActivityName]!({ toolName, args, + ...(meta != null ? { meta } : {}), ...(factoryArg !== undefined ? { factoryArgument: factoryArg } : {}), }); }, diff --git a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts index e23e6592..8abdca99 100644 --- a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts +++ b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts @@ -1,3 +1,4 @@ +import type { MCPToolMetaResolver } from '@openai/agents-core'; import { ActivityFailure, ApplicationFailure, @@ -45,6 +46,11 @@ export interface StatefulMcpServerOptions { * mid-run. */ cacheToolsList?: boolean; + /** + * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the + * run context and must be deterministic. + */ + toolMetaResolver?: MCPToolMetaResolver; } const DEDICATED_WORKER_SCHEDULE_FAILURE_MESSAGE = @@ -91,6 +97,7 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { private _sessionScope: CancellationScope | undefined; private _sessionPromise: Promise | undefined; private _connected = false; + toolMetaResolver?: MCPToolMetaResolver; constructor(name: string, options?: StatefulMcpServerOptions) { this._name = `${name}${MCP_STATEFUL_SUFFIX}`; @@ -107,6 +114,7 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { }; this._factoryArgument = options?.factoryArgument; this._cacheToolsList = options?.cacheToolsList ?? true; + this.toolMetaResolver = options?.toolMetaResolver; } get name(): string { @@ -178,10 +186,18 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { return handleWorkerFailure(() => scheduleActivity(this._name + MCP_LIST_TOOLS_SUFFIX, [], this._operationConfig)); } - async callTool(toolName: string, args: Record | null): Promise { + async callTool( + toolName: string, + args: Record | null, + meta?: Record | null + ): Promise { this.assertConnected(); return handleWorkerFailure(() => - scheduleActivity(this._name + MCP_CALL_TOOL_SUFFIX, [{ toolName, args }], this._operationConfig) + scheduleActivity( + this._name + MCP_CALL_TOOL_SUFFIX, + [{ toolName, args, ...(meta != null ? { meta } : {}) }], + this._operationConfig + ) ); } From e94b114113800e00bcb02f9db634bac583cd6761 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Thu, 29 Oct 2026 13:34:42 -0700 Subject: [PATCH 02/16] Address review findings --- typescript/openai-agents/README.md | 2 ++ typescript/openai-agents/src/workflow/mcp-client.ts | 3 ++- typescript/openai-agents/src/workflow/stateful-mcp-client.ts | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index 254ef674..b3ab0347 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -523,6 +523,8 @@ const server = statelessMcpServer('tickets', { The `_meta` values are recorded in the call-tool Activity input in Workflow History, so don't put secrets in them. +Plugin versions before this option ignore it. A resolver that returns a value without scheduling Workflow work (Activities, timers, child Workflows) replays the same on those versions. A resolver that schedules Workflow work, throws, or returns a value that is not an object or `null` makes a History that older versions can't replay: they fail it with a nondeterminism error (`TMPRL1100`). Don't roll Workers back to an older plugin version while such Workflows are open, or keep the resolver free of Workflow work and failures. + ## Sandbox agents > **Experimental** — sandbox support is subject to change prior to General Availability. diff --git a/typescript/openai-agents/src/workflow/mcp-client.ts b/typescript/openai-agents/src/workflow/mcp-client.ts index d85f71d9..c680fd0f 100644 --- a/typescript/openai-agents/src/workflow/mcp-client.ts +++ b/typescript/openai-agents/src/workflow/mcp-client.ts @@ -12,7 +12,8 @@ export interface StatelessMcpServerOptions { factoryArgument?: unknown; /** * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the - * run context and must be deterministic. + * run context and must be deterministic. Older plugin versions ignore this option, so they + * can't replay a History in which the resolver scheduled Workflow work or failed. */ toolMetaResolver?: MCPToolMetaResolver; } diff --git a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts index 8abdca99..5cde006d 100644 --- a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts +++ b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts @@ -48,7 +48,8 @@ export interface StatefulMcpServerOptions { cacheToolsList?: boolean; /** * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the - * run context and must be deterministic. + * run context and must be deterministic. Older plugin versions ignore this option, so they + * can't replay a History in which the resolver scheduled Workflow work or failed. */ toolMetaResolver?: MCPToolMetaResolver; } From 5d55b699b1f6c57c49501bfed95d1db0956b7978 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Thu, 29 Oct 2026 15:04:52 -0700 Subject: [PATCH 03/16] Address review findings --- typescript/openai-agents/README.md | 2 +- typescript/openai-agents/src/workflow/mcp-client.ts | 5 +++-- typescript/openai-agents/src/workflow/stateful-mcp-client.ts | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index b3ab0347..3ed5bb99 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -523,7 +523,7 @@ const server = statelessMcpServer('tickets', { The `_meta` values are recorded in the call-tool Activity input in Workflow History, so don't put secrets in them. -Plugin versions before this option ignore it. A resolver that returns a value without scheduling Workflow work (Activities, timers, child Workflows) replays the same on those versions. A resolver that schedules Workflow work, throws, or returns a value that is not an object or `null` makes a History that older versions can't replay: they fail it with a nondeterminism error (`TMPRL1100`). Don't roll Workers back to an older plugin version while such Workflows are open, or keep the resolver free of Workflow work and failures. +Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, so this option doesn't change their commands. ## Sandbox agents diff --git a/typescript/openai-agents/src/workflow/mcp-client.ts b/typescript/openai-agents/src/workflow/mcp-client.ts index c680fd0f..3be6e888 100644 --- a/typescript/openai-agents/src/workflow/mcp-client.ts +++ b/typescript/openai-agents/src/workflow/mcp-client.ts @@ -12,8 +12,9 @@ export interface StatelessMcpServerOptions { factoryArgument?: unknown; /** * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the - * run context and must be deterministic. Older plugin versions ignore this option, so they - * can't replay a History in which the resolver scheduled Workflow work or failed. + * run context and must be deterministic. Older plugin versions ignore this option and may not + * replay a History in which it ran, because running it can change the Workflow's commands or + * their order. */ toolMetaResolver?: MCPToolMetaResolver; } diff --git a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts index 5cde006d..0097a192 100644 --- a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts +++ b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts @@ -48,8 +48,9 @@ export interface StatefulMcpServerOptions { cacheToolsList?: boolean; /** * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the - * run context and must be deterministic. Older plugin versions ignore this option, so they - * can't replay a History in which the resolver scheduled Workflow work or failed. + * run context and must be deterministic. Older plugin versions ignore this option and may not + * replay a History in which it ran, because running it can change the Workflow's commands or + * their order. */ toolMetaResolver?: MCPToolMetaResolver; } From 5894e859b6740f586ec68c7c9a64e1b4dae98913 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Thu, 29 Oct 2026 23:42:02 -0700 Subject: [PATCH 04/16] Address review: stateful serverName, quieter warning, mixed fleets - The stateful handle gives the toolMetaResolver option the name passed to statefulMcpServer(), not the internal suffixed one, as the stateless handle does. - The Worker warns about a toolMetaResolver on the factory's server only when the Workflow sent no _meta, and once per server. - The factoryArgument test checks that callTool gets no meta argument when there is no resolver. - README: older Workers drop _meta during an upgrade. --- typescript/openai-agents/README.md | 2 ++ .../openai-agents/src/__tests__/test-openai-agents.ts | 7 ++++--- .../openai-agents/src/__tests__/workflows/openai-agents.ts | 3 ++- typescript/openai-agents/src/worker/mcp-provider.ts | 7 +++++-- .../openai-agents/src/workflow/stateful-mcp-client.ts | 4 +++- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index 3ed5bb99..48d30ea1 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -523,6 +523,8 @@ const server = statelessMcpServer('tickets', { The `_meta` values are recorded in the call-tool Activity input in Workflow History, so don't put secrets in them. +A call-tool Activity that runs on a Worker with an older plugin version drops the `_meta`, and nothing fails. Upgrade every Worker on the Task Queue before Workflows rely on `_meta`, or use Worker Versioning. + Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, so this option doesn't change their commands. ## Sandbox agents diff --git a/typescript/openai-agents/src/__tests__/test-openai-agents.ts b/typescript/openai-agents/src/__tests__/test-openai-agents.ts index 3e6f4639..7b0b1c9c 100644 --- a/typescript/openai-agents/src/__tests__/test-openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/test-openai-agents.ts @@ -178,8 +178,9 @@ test('factoryArgument is passed through to MCP activities', async (t) => { }, ]; }, - async callTool(_toolName: string, _args: Record | null) { + async callTool(...callArgs: unknown[]) { t.deepEqual(factoryArgument, { tenantId: 'tenant-42' }, 'factoryArgument should be passed to callTool'); + t.is(callArgs.length, 2, 'callTool should get no meta argument when there is no toolMetaResolver'); return [{ type: 'text', text: '2026-01-01' }]; }, async invalidateToolsCache() {}, @@ -272,8 +273,8 @@ test('MCP toolMetaResolver sends _meta on stateless and stateful servers', async }); t.deepEqual(metas, [ - { tenant: 'tenant-7', tool: 'echo_meta' }, - { tenant: 'tenant-7', tool: 'echo_meta' }, + { tenant: 'tenant-7', tool: 'echo_meta', server: 'metaStateless' }, + { tenant: 'tenant-7', tool: 'echo_meta', server: 'metaStateful' }, ]); }); diff --git a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts index 502f077f..6de44476 100644 --- a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts @@ -100,9 +100,10 @@ export async function mcpFactoryArgWorkflow(prompt: string): Promise { } export async function mcpToolMetaWorkflow(stateful: boolean): Promise { - const toolMetaResolver = ({ runContext, toolName }: MCPToolMetaContext) => ({ + const toolMetaResolver = ({ runContext, toolName, serverName }: MCPToolMetaContext) => ({ tenant: (runContext.context as { tenant: string }).tenant, tool: toolName, + server: serverName, }); const server = stateful ? statefulMcpServer('metaStateful', { toolMetaResolver }) diff --git a/typescript/openai-agents/src/worker/mcp-provider.ts b/typescript/openai-agents/src/worker/mcp-provider.ts index 7da1122e..abfa392d 100644 --- a/typescript/openai-agents/src/worker/mcp-provider.ts +++ b/typescript/openai-agents/src/worker/mcp-provider.ts @@ -2,10 +2,12 @@ import type { MCPServer } from '@openai/agents-core'; import { log } from '@temporalio/activity'; import { MCP_CALL_TOOL_SUFFIX, MCP_LIST_TOOLS_SUFFIX, MCP_STATELESS_SUFFIX } from '../common/mcp-types'; +const warnedToolMetaResolvers = new Set(); + /** * Calls a tool with the `_meta` the Workflow built, as the agent does for a native server. A * `toolMetaResolver` on the Worker-side server needs the run context, which only the Workflow - * has, so it is skipped with a warning. + * has, so it is skipped. When the Workflow sent no `_meta`, it logs a warning once per server. */ export async function callToolWithMeta( server: MCPServer, @@ -13,7 +15,8 @@ export async function callToolWithMeta( args: Record | null, meta: Record | undefined ): ReturnType { - if (server.toolMetaResolver) { + if (meta === undefined && server.toolMetaResolver && !warnedToolMetaResolvers.has(server.name)) { + warnedToolMetaResolvers.add(server.name); log.warn( `MCP server '${server.name}' has a toolMetaResolver, which the Worker cannot run. ` + 'Pass it as the toolMetaResolver option of statelessMcpServer() or statefulMcpServer() in the Workflow.' diff --git a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts index 0097a192..32ad370a 100644 --- a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts +++ b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts @@ -116,7 +116,9 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { }; this._factoryArgument = options?.factoryArgument; this._cacheToolsList = options?.cacheToolsList ?? true; - this.toolMetaResolver = options?.toolMetaResolver; + // Give the resolver the name the caller passed, as statelessMcpServer() does, not the suffixed one. + const toolMetaResolver = options?.toolMetaResolver; + this.toolMetaResolver = toolMetaResolver && ((context) => toolMetaResolver({ ...context, serverName: name })); } get name(): string { From e8a3cefca3e65d9f28c98e46e47a88f840b48c0f Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 30 Oct 2026 00:12:15 -0700 Subject: [PATCH 05/16] Share the MCP handle options in one interface Both handle option types extend McpServerHandleOptions, so the toolMetaResolver option and its doc comment are written once. #209 adds toolFilter to the same interface. --- .../openai-agents/src/common/mcp-types.ts | 13 ++++++++++++- .../openai-agents/src/workflow/mcp-client.ts | 18 ++++++++---------- .../src/workflow/stateful-mcp-client.ts | 10 ++-------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/typescript/openai-agents/src/common/mcp-types.ts b/typescript/openai-agents/src/common/mcp-types.ts index c872916e..edf83092 100644 --- a/typescript/openai-agents/src/common/mcp-types.ts +++ b/typescript/openai-agents/src/common/mcp-types.ts @@ -1,4 +1,4 @@ -import type { MCPServer } from '@openai/agents-core'; +import type { MCPServer, MCPToolMetaResolver } from '@openai/agents-core'; export const MCP_LIST_TOOLS_SUFFIX = '-list-tools'; export const MCP_CALL_TOOL_SUFFIX = '-call-tool'; @@ -17,6 +17,17 @@ export interface StatefulTemporalMCPServer extends MCPServer { cleanup(): Promise; } +/** Options both MCP handles take. The agent reads them from the handle in the Workflow, as from a native MCP server. */ +export interface McpServerHandleOptions { + /** + * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the + * run context and must be deterministic. Older plugin versions ignore this option and may not + * replay a History in which it ran, because running it can change the Workflow's commands or + * their order. + */ + toolMetaResolver?: MCPToolMetaResolver; +} + // Forwarded per-call (not stored on the provider) so different plugins sharing // a provider each get their own per-run config. export interface StatefulMcpActivityInterceptorOptions { diff --git a/typescript/openai-agents/src/workflow/mcp-client.ts b/typescript/openai-agents/src/workflow/mcp-client.ts index 3be6e888..b46132a1 100644 --- a/typescript/openai-agents/src/workflow/mcp-client.ts +++ b/typescript/openai-agents/src/workflow/mcp-client.ts @@ -1,22 +1,20 @@ -import type { MCPServer, MCPToolMetaResolver } from '@openai/agents-core'; +import type { MCPServer } from '@openai/agents-core'; import type { Duration, RetryPolicy } from '@temporalio/common'; import { proxyActivities } from '@temporalio/workflow'; -import { MCP_CALL_TOOL_SUFFIX, MCP_LIST_TOOLS_SUFFIX, MCP_STATELESS_SUFFIX } from '../common/mcp-types'; +import { + MCP_CALL_TOOL_SUFFIX, + MCP_LIST_TOOLS_SUFFIX, + MCP_STATELESS_SUFFIX, + type McpServerHandleOptions, +} from '../common/mcp-types'; -export interface StatelessMcpServerOptions { +export interface StatelessMcpServerOptions extends McpServerHandleOptions { cacheToolsList?: boolean; startToCloseTimeout?: Duration; heartbeatTimeout?: Duration; taskQueue?: string; retryPolicy?: RetryPolicy; factoryArgument?: unknown; - /** - * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the - * run context and must be deterministic. Older plugin versions ignore this option and may not - * replay a History in which it ran, because running it can change the Workflow's commands or - * their order. - */ - toolMetaResolver?: MCPToolMetaResolver; } export function statelessMcpServer(name: string, options?: StatelessMcpServerOptions): MCPServer { diff --git a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts index 32ad370a..6e1feeb3 100644 --- a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts +++ b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts @@ -20,12 +20,13 @@ import { MCP_LIST_TOOLS_SUFFIX, MCP_SERVER_SESSION_SUFFIX, MCP_STATEFUL_SUFFIX, + type McpServerHandleOptions, type StatefulMcpServerSessionArgs, type StatefulTemporalMCPServer, } from '../common/mcp-types'; import { getCurrentPluginConfig } from './plugin-config-store'; -export interface StatefulMcpServerOptions { +export interface StatefulMcpServerOptions extends McpServerHandleOptions { /** Activity options for tool operation Activities on the dedicated Worker. */ config?: { startToCloseTimeout?: Duration; @@ -46,13 +47,6 @@ export interface StatefulMcpServerOptions { * mid-run. */ cacheToolsList?: boolean; - /** - * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the - * run context and must be deterministic. Older plugin versions ignore this option and may not - * replay a History in which it ran, because running it can change the Workflow's commands or - * their order. - */ - toolMetaResolver?: MCPToolMetaResolver; } const DEDICATED_WORKER_SCHEDULE_FAILURE_MESSAGE = From ed77bd82f07adcd01a9213dd5e937dd2b8e93d3a Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 30 Oct 2026 04:13:56 -0700 Subject: [PATCH 06/16] Refuse a toolMetaResolver on the factory's server The Worker can't run a resolver on the server the factory returns, since it needs the run context. Instead of skipping it with a warning, every MCP Activity for that server now fails with a non-retryable ApplicationFailure of type MCPToolMetaResolverUnsupported, as #209 does for a callable toolFilter. --- typescript/openai-agents/README.md | 2 +- .../src/__tests__/mcp-provider.test.ts | 34 +++++++++++++++++++ .../openai-agents/src/worker/mcp-provider.ts | 32 ++++++++++------- .../src/worker/stateful-mcp-provider.ts | 3 +- 4 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 typescript/openai-agents/src/__tests__/mcp-provider.test.ts diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index 48d30ea1..2fc8f5a0 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -513,7 +513,7 @@ Dedicated Worker startup and heartbeat failures surface as `ApplicationFailure` ### MCP request metadata -A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker skips a `toolMetaResolver` on the factory's server and logs a warning. +A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported`. ```ts const server = statelessMcpServer('tickets', { diff --git a/typescript/openai-agents/src/__tests__/mcp-provider.test.ts b/typescript/openai-agents/src/__tests__/mcp-provider.test.ts new file mode 100644 index 00000000..ed69b06f --- /dev/null +++ b/typescript/openai-agents/src/__tests__/mcp-provider.test.ts @@ -0,0 +1,34 @@ +import test from 'ava'; +import type { MCPServer } from '@openai/agents-core'; +import { ApplicationFailure } from '@temporalio/common'; +import { StatelessMCPServerProvider } from '../worker/mcp-provider'; + +test('StatelessMCPServerProvider refuses a toolMetaResolver without connecting', async (t) => { + let connected = false; + const server: MCPServer = { + cacheToolsList: false, + name: 'tickets', + toolMetaResolver: () => ({ tenant: 'tenant-7' }), + async connect() { + connected = true; + }, + async close() {}, + async listTools() { + return []; + }, + async callTool() { + return []; + }, + async invalidateToolsCache() {}, + }; + const activities = new StatelessMCPServerProvider('tickets', () => server)._getActivities(); + + for (const name of ['tickets-stateless-list-tools', 'tickets-stateless-call-tool']) { + const err = await t.throwsAsync(activities[name]!({ toolName: 'echo', args: {} }), { + instanceOf: ApplicationFailure, + }); + t.is(err?.type, 'MCPToolMetaResolverUnsupported'); + t.true(err?.nonRetryable); + } + t.false(connected); +}); diff --git a/typescript/openai-agents/src/worker/mcp-provider.ts b/typescript/openai-agents/src/worker/mcp-provider.ts index abfa392d..e1b0b1ae 100644 --- a/typescript/openai-agents/src/worker/mcp-provider.ts +++ b/typescript/openai-agents/src/worker/mcp-provider.ts @@ -1,27 +1,31 @@ import type { MCPServer } from '@openai/agents-core'; -import { log } from '@temporalio/activity'; +import { ApplicationFailure } from '@temporalio/common'; import { MCP_CALL_TOOL_SUFFIX, MCP_LIST_TOOLS_SUFFIX, MCP_STATELESS_SUFFIX } from '../common/mcp-types'; -const warnedToolMetaResolvers = new Set(); - /** - * Calls a tool with the `_meta` the Workflow built, as the agent does for a native server. A - * `toolMetaResolver` on the Worker-side server needs the run context, which only the Workflow - * has, so it is skipped. When the Workflow sent no `_meta`, it logs a warning once per server. + * Fails on a `toolMetaResolver` on the server the factory returns. It needs the run context, which + * exists only in the Workflow, so the Worker cannot run it. */ +export function rejectToolMetaResolver(server: MCPServer): void { + if (server.toolMetaResolver) { + throw ApplicationFailure.create({ + message: + `MCP server '${server.name}' has a toolMetaResolver, which the Worker cannot run because the run ` + + 'context it gets exists only in the Workflow. Pass it as the toolMetaResolver option of ' + + 'statelessMcpServer() or statefulMcpServer() instead.', + type: 'MCPToolMetaResolverUnsupported', + nonRetryable: true, + }); + } +} + +/** Calls a tool with the `_meta` the Workflow built, as the agent does for a native server. */ export async function callToolWithMeta( server: MCPServer, toolName: string, args: Record | null, meta: Record | undefined ): ReturnType { - if (meta === undefined && server.toolMetaResolver && !warnedToolMetaResolvers.has(server.name)) { - warnedToolMetaResolvers.add(server.name); - log.warn( - `MCP server '${server.name}' has a toolMetaResolver, which the Worker cannot run. ` + - 'Pass it as the toolMetaResolver option of statelessMcpServer() or statefulMcpServer() in the Workflow.' - ); - } return meta === undefined ? server.callTool(toolName, args) : server.callTool(toolName, args, meta); } @@ -39,6 +43,7 @@ export class StatelessMCPServerProvider { return { [`${this._name}${MCP_LIST_TOOLS_SUFFIX}`]: async (input?: { factoryArgument?: unknown }) => { const s = this.serverFactory(input?.factoryArgument); + rejectToolMetaResolver(s); try { await s.connect(); return await s.listTools(); @@ -53,6 +58,7 @@ export class StatelessMCPServerProvider { factoryArgument?: unknown; }) => { const s = this.serverFactory(input.factoryArgument); + rejectToolMetaResolver(s); try { await s.connect(); return await callToolWithMeta(s, input.toolName, input.args, input.meta); diff --git a/typescript/openai-agents/src/worker/stateful-mcp-provider.ts b/typescript/openai-agents/src/worker/stateful-mcp-provider.ts index e8a091e5..893f7e54 100644 --- a/typescript/openai-agents/src/worker/stateful-mcp-provider.ts +++ b/typescript/openai-agents/src/worker/stateful-mcp-provider.ts @@ -10,7 +10,7 @@ import { type StatefulMcpServerSessionArgs, } from '../common/mcp-types'; import { startAdaptiveHeartbeat } from './heartbeat'; -import { callToolWithMeta } from './mcp-provider'; +import { callToolWithMeta, rejectToolMetaResolver } from './mcp-provider'; export function registerCancelShutdown(ctx: Context, dedicatedWorker: { shutdown: () => void }): void { ctx.cancelled.catch(() => { @@ -74,6 +74,7 @@ export class StatefulMCPServerProvider { nonRetryable: true, }); } + rejectToolMetaResolver(server); return server; }; From c69bccbb29d862c6dde8b4dc13a8c8fc8ea147d6 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 30 Oct 2026 05:01:01 -0700 Subject: [PATCH 07/16] Address review findings --- typescript/openai-agents/README.md | 2 +- .../src/__tests__/mcp-provider.test.ts | 38 ++++++++++++++++--- .../src/__tests__/test-openai-agents.ts | 36 ++++++++++++++++++ .../src/__tests__/workflows/openai-agents.ts | 29 +++++++++++++- .../openai-agents/src/common/mcp-types.ts | 3 ++ .../openai-agents/src/worker/mcp-provider.ts | 9 ++++- .../src/worker/stateful-mcp-provider.ts | 3 +- .../src/workflow/stateful-mcp-client.ts | 28 +++++++++++++- 8 files changed, 135 insertions(+), 13 deletions(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index 2fc8f5a0..96b3ea89 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -513,7 +513,7 @@ Dedicated Worker startup and heartbeat failures surface as `ApplicationFailure` ### MCP request metadata -A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported`. +A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one before connecting: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported`. For `statefulMcpServer()`, the server-session Activity fails, and the handle's `listTools()` and `callTool()` throw that failure. ```ts const server = statelessMcpServer('tickets', { diff --git a/typescript/openai-agents/src/__tests__/mcp-provider.test.ts b/typescript/openai-agents/src/__tests__/mcp-provider.test.ts index ed69b06f..cb001f42 100644 --- a/typescript/openai-agents/src/__tests__/mcp-provider.test.ts +++ b/typescript/openai-agents/src/__tests__/mcp-provider.test.ts @@ -1,16 +1,19 @@ import test from 'ava'; import type { MCPServer } from '@openai/agents-core'; import { ApplicationFailure } from '@temporalio/common'; +import type { NativeConnection } from '@temporalio/worker'; +import { MockActivityEnvironment } from '@temporalio/testing'; import { StatelessMCPServerProvider } from '../worker/mcp-provider'; +import { StatefulMCPServerProvider } from '../worker/stateful-mcp-provider'; -test('StatelessMCPServerProvider refuses a toolMetaResolver without connecting', async (t) => { - let connected = false; - const server: MCPServer = { +function serverWithResolver(onConnect: () => void): MCPServer { + return { cacheToolsList: false, name: 'tickets', toolMetaResolver: () => ({ tenant: 'tenant-7' }), async connect() { - connected = true; + onConnect(); + throw new Error('connection refused'); }, async close() {}, async listTools() { @@ -21,7 +24,13 @@ test('StatelessMCPServerProvider refuses a toolMetaResolver without connecting', }, async invalidateToolsCache() {}, }; - const activities = new StatelessMCPServerProvider('tickets', () => server)._getActivities(); +} + +test('StatelessMCPServerProvider refuses a toolMetaResolver without connecting', async (t) => { + let connects = 0; + const activities = new StatelessMCPServerProvider('tickets', () => + serverWithResolver(() => connects++) + )._getActivities(); for (const name of ['tickets-stateless-list-tools', 'tickets-stateless-call-tool']) { const err = await t.throwsAsync(activities[name]!({ toolName: 'echo', args: {} }), { @@ -30,5 +39,22 @@ test('StatelessMCPServerProvider refuses a toolMetaResolver without connecting', t.is(err?.type, 'MCPToolMetaResolverUnsupported'); t.true(err?.nonRetryable); } - t.false(connected); + t.is(connects, 0); +}); + +test('StatefulMCPServerProvider refuses a toolMetaResolver at session start without connecting', async (t) => { + let connects = 0; + const activities = new StatefulMCPServerProvider( + 'tickets', + () => serverWithResolver(() => connects++), + {} as NativeConnection + )._getActivities(); + + const err = await t.throwsAsync( + new MockActivityEnvironment().run(activities['tickets-stateful-server-session']!, {}), + { instanceOf: ApplicationFailure } + ); + t.is(err?.type, 'MCPToolMetaResolverUnsupported'); + t.true(err?.nonRetryable); + t.is(connects, 0); }); diff --git a/typescript/openai-agents/src/__tests__/test-openai-agents.ts b/typescript/openai-agents/src/__tests__/test-openai-agents.ts index 7b0b1c9c..7af945bd 100644 --- a/typescript/openai-agents/src/__tests__/test-openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/test-openai-agents.ts @@ -17,6 +17,7 @@ import { localActivityAgentWorkflow, mcpFactoryArgWorkflow, mcpToolMetaWorkflow, + statefulMcpFactoryResolverWorkflow, summaryOverrideStringWorkflow, statefulMcpNotConnectedWorkflow, statefulMcpIsolationWorkflow, @@ -278,6 +279,41 @@ test('MCP toolMetaResolver sends _meta on stateless and stateful servers', async ]); }); +test('Stateful MCP: a factory toolMetaResolver fails the operations without connecting', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + + let connects = 0; + const worker = await createWorker({ + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new FakeModelProvider([]), + mcpServerProviders: [ + new StatefulMCPServerProvider( + 'factoryResolver', + () => ({ + ...metaTestServer('factoryResolver', []), + toolMetaResolver: () => ({ tenant: 'tenant-7' }), + async connect() { + connects++; + throw new Error('connection refused'); + }, + }), + t.context.env.nativeConnection + ), + ], + }), + ], + }); + + await worker.runUntil(async () => { + t.deepEqual( + await executeWorkflow(statefulMcpFactoryResolverWorkflow, { workflowExecutionTimeout: '20 seconds' }), + Array(3).fill('MCPToolMetaResolverUnsupported:true') + ); + }); + t.is(connects, 0); +}); + test('summary string is passed through to model activity', async (t) => { const { createWorker, taskQueue } = helpers(t); diff --git a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts index 6de44476..02cb8b19 100644 --- a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts @@ -1,5 +1,5 @@ import { Agent, type MCPToolMetaContext } from '@openai/agents-core'; -import { ApplicationFailure } from '@temporalio/workflow'; +import { ActivityFailure, ApplicationFailure } from '@temporalio/workflow'; import { WorkflowStream } from '@temporalio/workflow-streams/workflow'; import { TemporalOpenAIRunner, statelessMcpServer, statefulMcpServer } from '../../workflow'; @@ -123,6 +123,33 @@ export async function mcpToolMetaWorkflow(stateful: boolean): Promise { } } +function failureType(err: unknown): string { + const failure = err instanceof ActivityFailure ? err.cause : err; + return failure instanceof ApplicationFailure ? `${failure.type}:${failure.nonRetryable}` : String(err); +} + +export async function statefulMcpFactoryResolverWorkflow(): Promise { + // Longer than the test's execution timeout, so the operations fail only through the session failure. + const server = statefulMcpServer('factoryResolver', { config: { scheduleToStartTimeout: '1 minute' } }); + await server.connect(); + const results: string[] = []; + for (const operation of [() => server.listTools(), () => server.callTool('echo', {})]) { + try { + await operation(); + results.push('unexpected-success'); + } catch (err: unknown) { + results.push(failureType(err)); + } + } + try { + await server.cleanup(); + results.push('cleanup-success'); + } catch (err: unknown) { + results.push(failureType(err)); + } + return results; +} + export async function summaryOverrideStringWorkflow(prompt: string): Promise { const agent = new Agent({ name: 'SummaryAgent', diff --git a/typescript/openai-agents/src/common/mcp-types.ts b/typescript/openai-agents/src/common/mcp-types.ts index edf83092..e596066c 100644 --- a/typescript/openai-agents/src/common/mcp-types.ts +++ b/typescript/openai-agents/src/common/mcp-types.ts @@ -9,6 +9,9 @@ export const MCP_STATELESS_SUFFIX = '-stateless'; /** Error type for dedicated-Worker scheduling and heartbeat failures. */ export const DEDICATED_WORKER_FAILURE_TYPE = 'DedicatedWorkerFailure'; +/** Error type for a `toolMetaResolver` on the server the factory returns, which the Worker refuses. */ +export const TOOL_META_RESOLVER_UNSUPPORTED_TYPE = 'MCPToolMetaResolverUnsupported'; + /** * Stateful variant of `MCPServer`. Adds `cleanup()` — the documented * lifecycle method users invoke from `finally` blocks after `connect()`. diff --git a/typescript/openai-agents/src/worker/mcp-provider.ts b/typescript/openai-agents/src/worker/mcp-provider.ts index e1b0b1ae..4b88a652 100644 --- a/typescript/openai-agents/src/worker/mcp-provider.ts +++ b/typescript/openai-agents/src/worker/mcp-provider.ts @@ -1,6 +1,11 @@ import type { MCPServer } from '@openai/agents-core'; import { ApplicationFailure } from '@temporalio/common'; -import { MCP_CALL_TOOL_SUFFIX, MCP_LIST_TOOLS_SUFFIX, MCP_STATELESS_SUFFIX } from '../common/mcp-types'; +import { + MCP_CALL_TOOL_SUFFIX, + MCP_LIST_TOOLS_SUFFIX, + MCP_STATELESS_SUFFIX, + TOOL_META_RESOLVER_UNSUPPORTED_TYPE, +} from '../common/mcp-types'; /** * Fails on a `toolMetaResolver` on the server the factory returns. It needs the run context, which @@ -13,7 +18,7 @@ export function rejectToolMetaResolver(server: MCPServer): void { `MCP server '${server.name}' has a toolMetaResolver, which the Worker cannot run because the run ` + 'context it gets exists only in the Workflow. Pass it as the toolMetaResolver option of ' + 'statelessMcpServer() or statefulMcpServer() instead.', - type: 'MCPToolMetaResolverUnsupported', + type: TOOL_META_RESOLVER_UNSUPPORTED_TYPE, nonRetryable: true, }); } diff --git a/typescript/openai-agents/src/worker/stateful-mcp-provider.ts b/typescript/openai-agents/src/worker/stateful-mcp-provider.ts index 893f7e54..329683ba 100644 --- a/typescript/openai-agents/src/worker/stateful-mcp-provider.ts +++ b/typescript/openai-agents/src/worker/stateful-mcp-provider.ts @@ -74,7 +74,6 @@ export class StatefulMCPServerProvider { nonRetryable: true, }); } - rejectToolMetaResolver(server); return server; }; @@ -105,6 +104,8 @@ export class StatefulMCPServerProvider { } const server = this._serverFactory(input?.factoryArgument); + // Refuse before connecting, so the session fails at once instead of retrying the connection. + rejectToolMetaResolver(server); try { this._servers.set(sid, server); diff --git a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts index 6e1feeb3..623efe43 100644 --- a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts +++ b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts @@ -23,6 +23,7 @@ import { type McpServerHandleOptions, type StatefulMcpServerSessionArgs, type StatefulTemporalMCPServer, + TOOL_META_RESOLVER_UNSUPPORTED_TYPE, } from '../common/mcp-types'; import { getCurrentPluginConfig } from './plugin-config-store'; @@ -179,9 +180,32 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { } } + /** + * Runs a tool operation Activity. If the session Activity fails because the Worker refused a + * `toolMetaResolver` on the factory's server, the dedicated Worker never starts, so this cancels + * the operation and throws that failure instead of waiting for the schedule-to-start timeout. + */ + private async runOperation(fn: () => Promise): Promise { + const scope = new CancellationScope(); + const operation = scope.run(() => handleWorkerFailure(fn)); + const refused = new Promise((_resolve, reject) => { + this._sessionPromise?.catch((err: unknown) => { + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === TOOL_META_RESOLVER_UNSUPPORTED_TYPE + ) { + scope.cancel(); + reject(err); + } + }); + }); + return Promise.race([operation, refused]); + } + async listTools(): Promise { this.assertConnected(); - return handleWorkerFailure(() => scheduleActivity(this._name + MCP_LIST_TOOLS_SUFFIX, [], this._operationConfig)); + return this.runOperation(() => scheduleActivity(this._name + MCP_LIST_TOOLS_SUFFIX, [], this._operationConfig)); } async callTool( @@ -190,7 +214,7 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { meta?: Record | null ): Promise { this.assertConnected(); - return handleWorkerFailure(() => + return this.runOperation(() => scheduleActivity( this._name + MCP_CALL_TOOL_SUFFIX, [{ toolName, args, ...(meta != null ? { meta } : {}) }], From bfc90d694aeb00143dd60a435f136b18afec785a Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 30 Oct 2026 05:34:28 -0700 Subject: [PATCH 08/16] Address review findings --- typescript/openai-agents/README.md | 4 +- .../histories/stateful-mcp-ordering-main.json | 794 ++++++++++++++++++ .../src/__tests__/test-openai-agents.ts | 28 +- .../src/__tests__/workflows/openai-agents.ts | 41 +- .../src/workflow/stateful-mcp-client.ts | 76 +- 5 files changed, 901 insertions(+), 42 deletions(-) create mode 100644 typescript/openai-agents/src/__tests__/histories/stateful-mcp-ordering-main.json diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index 96b3ea89..bd2dad15 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -513,7 +513,7 @@ Dedicated Worker startup and heartbeat failures surface as `ApplicationFailure` ### MCP request metadata -A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one before connecting: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported`. For `statefulMcpServer()`, the server-session Activity fails, and the handle's `listTools()` and `callTool()` throw that failure. +A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one before connecting: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported`. For `statefulMcpServer()`, the server-session Activity fails. The handle's `listTools()` and `callTool()` throw that failure at once when called after it, and at their schedule-to-start timeout (`config.scheduleToStartTimeout`) when already waiting for the dedicated Worker. ```ts const server = statelessMcpServer('tickets', { @@ -525,7 +525,7 @@ The `_meta` values are recorded in the call-tool Activity input in Workflow Hist A call-tool Activity that runs on a Worker with an older plugin version drops the `_meta`, and nothing fails. Upgrade every Worker on the Task Queue before Workflows rely on `_meta`, or use Worker Versioning. -Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, so this option doesn't change their commands. +Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, and the handles' `listTools()` and `callTool()` take the same promise steps as before, so their commands don't change when you upgrade or roll back. ## Sandbox agents diff --git a/typescript/openai-agents/src/__tests__/histories/stateful-mcp-ordering-main.json b/typescript/openai-agents/src/__tests__/histories/stateful-mcp-ordering-main.json new file mode 100644 index 00000000..bb00e2b5 --- /dev/null +++ b/typescript/openai-agents/src/__tests__/histories/stateful-mcp-ordering-main.json @@ -0,0 +1,794 @@ +{ + "events": [ + { + "eventId": "1", + "eventTime": "2026-10-30T12:20:30.619293764Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "taskId": "1048587", + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "statefulMcpOrderingWorkflow" + }, + "taskQueue": { + "name": "record-or-replay-ordering", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "MTIwMDAwMDAwMA==" + } + ] + }, + "workflowExecutionTimeout": "60s", + "workflowRunTimeout": "60s", + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "01a18cc1-251b-7471-8a29-cf0e56a7888f", + "identity": "1100@d0a5644da69b", + "firstExecutionRunId": "01a18cc1-251b-7471-8a29-cf0e56a7888f", + "attempt": 1, + "workflowExecutionExpirationTime": "2026-10-30T12:21:30.616Z", + "firstWorkflowTaskBackoff": "0s", + "header": {}, + "workflowId": "29ff8fb2-333b-47d3-a3e8-cfee26d997e1" + } + }, + { + "eventId": "2", + "eventTime": "2026-10-30T12:20:30.626594847Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048588", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "record-or-replay-ordering", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "3", + "eventTime": "2026-10-30T12:20:30.747128430Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048594", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "2", + "identity": "1100@d0a5644da69b", + "requestId": "fd86e16f-23dc-40d2-98dc-6770941e3ee4", + "historySizeBytes": "364", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "4", + "eventTime": "2026-10-30T12:20:30.978013389Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048599", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "2", + "startedEventId": "3", + "identity": "1100@d0a5644da69b", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + }, + "sdkMetadata": { + "coreUsedFlags": [ + 3, + 2, + 1 + ], + "sdkName": "temporal-typescript", + "sdkVersion": "1.24.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "5", + "eventTime": "2026-10-30T12:20:30.978538347Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048600", + "activityTaskScheduledEventAttributes": { + "activityId": "1", + "activityType": { + "name": "ordering-stateful-server-session" + }, + "taskQueue": { + "name": "record-or-replay-ordering", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" + } + } + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "e30=" + } + ] + }, + "scheduleToCloseTimeout": "60s", + "scheduleToStartTimeout": "60s", + "startToCloseTimeout": "60s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "4", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "6", + "eventTime": "2026-10-30T12:20:30.979631055Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048601", + "activityTaskScheduledEventAttributes": { + "activityId": "2", + "activityType": { + "name": "ordering-stateful-list-tools" + }, + "taskQueue": { + "name": "ordering-stateful@01a18cc1-251b-7471-8a29-cf0e56a7888f", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" + } + } + }, + "scheduleToCloseTimeout": "60s", + "scheduleToStartTimeout": "30s", + "startToCloseTimeout": "60s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "4", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + } + } + }, + { + "eventId": "7", + "eventTime": "2026-10-30T12:20:31.006875597Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048608", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "6", + "identity": "1100@d0a5644da69b", + "requestId": "b5c6e701-1432-4915-902b-92e65f2cbbe1", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0" + } + } + }, + { + "eventId": "8", + "eventTime": "2026-10-30T12:20:31.015518430Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048609", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "W10=" + } + ] + }, + "scheduledEventId": "6", + "startedEventId": "7", + "identity": "1100@d0a5644da69b" + } + }, + { + "eventId": "9", + "eventTime": "2026-10-30T12:20:31.015720597Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048610", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "1100@d0a5644da69b-a0e6d857a7e643fdbedc795a2d158303", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "record-or-replay-ordering" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "10", + "eventTime": "2026-10-30T12:20:31.018376722Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048614", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "9", + "identity": "1100@d0a5644da69b", + "requestId": "b1724442-125a-4b7a-857f-d5352ba2449e", + "historySizeBytes": "1583", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "11", + "eventTime": "2026-10-30T12:20:31.038039430Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048620", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "9", + "startedEventId": "10", + "identity": "1100@d0a5644da69b", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "12", + "eventTime": "2026-10-30T12:20:31.038094014Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048621", + "activityTaskScheduledEventAttributes": { + "activityId": "3", + "activityType": { + "name": "ordering-stateful-call-tool" + }, + "taskQueue": { + "name": "ordering-stateful@01a18cc1-251b-7471-8a29-cf0e56a7888f", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" + } + } + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0b29sTmFtZSI6InNsb3ciLCJhcmdzIjp7fX0=" + } + ] + }, + "scheduleToCloseTimeout": "60s", + "scheduleToStartTimeout": "30s", + "startToCloseTimeout": "60s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "11", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + } + } + }, + { + "eventId": "13", + "eventTime": "2026-10-30T12:20:31.038134847Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048622", + "activityTaskScheduledEventAttributes": { + "activityId": "4", + "activityType": { + "name": "orderingGate" + }, + "taskQueue": { + "name": "record-or-replay-ordering", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" + } + } + }, + "scheduleToCloseTimeout": "60s", + "scheduleToStartTimeout": "60s", + "startToCloseTimeout": "10s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "11", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "14", + "eventTime": "2026-10-30T12:20:31.038145847Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048623", + "activityTaskScheduledEventAttributes": { + "activityId": "5", + "activityType": { + "name": "orderingKick" + }, + "taskQueue": { + "name": "record-or-replay-ordering", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" + } + } + }, + "scheduleToCloseTimeout": "60s", + "scheduleToStartTimeout": "60s", + "startToCloseTimeout": "10s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "11", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "15", + "eventTime": "2026-10-30T12:20:31.038167722Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048629", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "14", + "identity": "1100@d0a5644da69b", + "requestId": "15ba8124-161b-490e-a7e0-15ef5522f1da", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "16", + "eventTime": "2026-10-30T12:20:31.042907972Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048630", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "14", + "startedEventId": "15", + "identity": "1100@d0a5644da69b" + } + }, + { + "eventId": "17", + "eventTime": "2026-10-30T12:20:31.042932347Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048631", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "1100@d0a5644da69b-a0e6d857a7e643fdbedc795a2d158303", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "record-or-replay-ordering" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "18", + "eventTime": "2026-10-30T12:20:31.044909639Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048635", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "17", + "identity": "1100@d0a5644da69b", + "requestId": "d207509b-e657-44ed-8119-70461e57ecde", + "historySizeBytes": "3055", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "19", + "eventTime": "2026-10-30T12:20:31.681749083Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048641", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "17", + "startedEventId": "18", + "identity": "1100@d0a5644da69b", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "20", + "eventTime": "2026-10-30T12:20:31.038158139Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048642", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "13", + "identity": "1100@d0a5644da69b", + "requestId": "b78fbf72-3fd3-4cda-a30c-31a26817f90c", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "21", + "eventTime": "2026-10-30T12:20:31.040382472Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048643", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "12", + "identity": "1100@d0a5644da69b", + "requestId": "9b0aa94f-9e49-4635-9be6-6c6ffd2ea0ad", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0" + } + } + }, + { + "eventId": "22", + "eventTime": "2026-10-30T12:20:31.242200375Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048644", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "13", + "startedEventId": "20", + "identity": "1100@d0a5644da69b" + } + }, + { + "eventId": "23", + "eventTime": "2026-10-30T12:20:31.243983333Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048645", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "W3sidHlwZSI6InRleHQiLCJ0ZXh0Ijoib2sifV0=" + } + ] + }, + "scheduledEventId": "12", + "startedEventId": "21", + "identity": "1100@d0a5644da69b" + } + }, + { + "eventId": "24", + "eventTime": "2026-10-30T12:20:31.681832750Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048646", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "1100@d0a5644da69b-a0e6d857a7e643fdbedc795a2d158303", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "record-or-replay-ordering" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "25", + "eventTime": "2026-10-30T12:20:31.681839750Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048647", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "24", + "identity": "1100@d0a5644da69b", + "requestId": "request-from-RespondWorkflowTaskCompleted", + "historySizeBytes": "3238", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "26", + "eventTime": "2026-10-30T12:20:31.697161958Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048652", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "24", + "startedEventId": "25", + "identity": "1100@d0a5644da69b", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "27", + "eventTime": "2026-10-30T12:20:31.697225541Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048653", + "activityTaskScheduledEventAttributes": { + "activityId": "6", + "activityType": { + "name": "orderingAfterMcp" + }, + "taskQueue": { + "name": "record-or-replay-ordering", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" + } + } + }, + "scheduleToCloseTimeout": "60s", + "scheduleToStartTimeout": "60s", + "startToCloseTimeout": "10s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "26", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "28", + "eventTime": "2026-10-30T12:20:31.697270708Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048654", + "activityTaskScheduledEventAttributes": { + "activityId": "7", + "activityType": { + "name": "orderingAfterGate" + }, + "taskQueue": { + "name": "record-or-replay-ordering", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": { + "fields": { + "__openai_span": { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" + } + } + }, + "scheduleToCloseTimeout": "60s", + "scheduleToStartTimeout": "60s", + "startToCloseTimeout": "10s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "26", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s" + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "29", + "eventTime": "2026-10-30T12:20:31.697293583Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048657", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "27", + "identity": "1100@d0a5644da69b", + "requestId": "5c4efdf1-7c10-4fb3-a081-d414f5539a85", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "30", + "eventTime": "2026-10-30T12:20:31.701433500Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048658", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "27", + "startedEventId": "29", + "identity": "1100@d0a5644da69b" + } + }, + { + "eventId": "31", + "eventTime": "2026-10-30T12:20:31.701455875Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048659", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "1100@d0a5644da69b-a0e6d857a7e643fdbedc795a2d158303", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "record-or-replay-ordering" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "32", + "eventTime": "2026-10-30T12:20:31.697302375Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048664", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "28", + "identity": "1100@d0a5644da69b", + "requestId": "09176c83-7c6c-4892-9df5-c9c08bab207e", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "33", + "eventTime": "2026-10-30T12:20:31.703821333Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048665", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "28", + "startedEventId": "32", + "identity": "1100@d0a5644da69b" + } + }, + { + "eventId": "34", + "eventTime": "2026-10-30T12:20:31.704900166Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048667", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "31", + "identity": "1100@d0a5644da69b", + "requestId": "a4df2540-639b-4572-902c-53576230024c", + "historySizeBytes": "5419", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + } + } + }, + { + "eventId": "35", + "eventTime": "2026-10-30T12:20:31.731858708Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048671", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "31", + "startedEventId": "34", + "identity": "1100@d0a5644da69b", + "workerVersion": { + "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "36", + "eventTime": "2026-10-30T12:20:31.731992541Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_CANCEL_REQUESTED", + "taskId": "1048672", + "activityTaskCancelRequestedEventAttributes": { + "scheduledEventId": "5", + "workflowTaskCompletedEventId": "35" + } + }, + { + "eventId": "37", + "eventTime": "2026-10-30T12:20:31.732378750Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", + "taskId": "1048673", + "workflowExecutionCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "workflowTaskCompletedEventId": "35" + } + } + ] +} diff --git a/typescript/openai-agents/src/__tests__/test-openai-agents.ts b/typescript/openai-agents/src/__tests__/test-openai-agents.ts index 7af945bd..3364704b 100644 --- a/typescript/openai-agents/src/__tests__/test-openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/test-openai-agents.ts @@ -1,11 +1,13 @@ // Integration tests that need a real Worker. Pure-function tests live in // the sibling `*.test.ts` files. +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; import { setTracingDisabled } from '@openai/agents-core'; import type { MCPServer } from '@openai/agents-core'; import { WorkflowClient } from '@temporalio/client'; import { temporal } from '@temporalio/proto'; import { helpers } from '@temporalio/test-helpers'; -import { bundleWorkflowCode } from '@temporalio/worker'; +import { bundleWorkflowCode, Worker } from '@temporalio/worker'; import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; import { OpenAIAgentsPlugin, @@ -280,7 +282,7 @@ test('MCP toolMetaResolver sends _meta on stateless and stateful servers', async }); test('Stateful MCP: a factory toolMetaResolver fails the operations without connecting', async (t) => { - const { createWorker, executeWorkflow } = helpers(t); + const { createWorker, startWorkflow } = helpers(t); let connects = 0; const worker = await createWorker({ @@ -306,14 +308,28 @@ test('Stateful MCP: a factory toolMetaResolver fails the operations without conn }); await worker.runUntil(async () => { - t.deepEqual( - await executeWorkflow(statefulMcpFactoryResolverWorkflow, { workflowExecutionTimeout: '20 seconds' }), - Array(3).fill('MCPToolMetaResolverUnsupported:true') - ); + const handle = await startWorkflow(statefulMcpFactoryResolverWorkflow, { workflowExecutionTimeout: '20 seconds' }); + t.deepEqual(await handle.result(), Array(3).fill('MCPToolMetaResolverUnsupported:true')); + const { events } = await handle.fetchHistory(); + const scheduled = (events ?? []) + .filter((e) => e.eventType === EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED) + .map((e) => e.activityTaskScheduledEventAttributes?.activityType?.name); + // callTool() runs after the refusal, so it schedules nothing. + t.deepEqual(scheduled, ['factoryResolver-stateful-server-session', 'factoryResolver-stateful-list-tools']); }); t.is(connects, 0); }); +test('Stateful MCP: operations keep the promise steps of histories recorded before toolMetaResolver', async (t) => { + // statefulMcpOrderingWorkflow, recorded with main's (1e8ae93) stateful-mcp-client.ts: the callTool() and + // gate results arrive in one Workflow Task, and orderingAfterMcp is scheduled before orderingAfterGate. + // Extra promise steps in callTool() reverse them and fail this replay with a nondeterminism error. + const history = JSON.parse( + readFileSync(path.resolve(__dirname, '../../src/__tests__/histories/stateful-mcp-ordering-main.json'), 'utf8') + ); + await t.notThrowsAsync(Worker.runReplayHistory({ workflowBundle: t.context.workflowBundle }, history)); +}); + test('summary string is passed through to model activity', async (t) => { const { createWorker, taskQueue } = helpers(t); diff --git a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts index 02cb8b19..8bb76b1d 100644 --- a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts @@ -1,5 +1,5 @@ import { Agent, type MCPToolMetaContext } from '@openai/agents-core'; -import { ActivityFailure, ApplicationFailure } from '@temporalio/workflow'; +import { ActivityFailure, ApplicationFailure, proxyActivities } from '@temporalio/workflow'; import { WorkflowStream } from '@temporalio/workflow-streams/workflow'; import { TemporalOpenAIRunner, statelessMcpServer, statefulMcpServer } from '../../workflow'; @@ -123,14 +123,49 @@ export async function mcpToolMetaWorkflow(stateful: boolean): Promise { } } +const { orderingGate, orderingKick, orderingAfterMcp, orderingAfterGate } = proxyActivities< + Record<'orderingGate' | 'orderingKick' | 'orderingAfterMcp' | 'orderingAfterGate', () => Promise> +>({ startToCloseTimeout: '10 seconds' }); + +/** + * A stateful callTool() and a gate Activity complete while a Workflow Task spins, so both + * results reach the same activation. The order of the follow-up Activities depends on how many + * promise steps callTool() takes after its Activity completes. + */ +export async function statefulMcpOrderingWorkflow(spin: number): Promise { + const server = statefulMcpServer('ordering'); + await server.connect(); + await server.listTools(); + const mcp = async () => { + await server.callTool('slow', {}); + await orderingAfterMcp(); + }; + const gate = async () => { + await orderingGate(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await orderingAfterGate(); + }; + const kick = async () => { + await orderingKick(); + let x = 0; + for (let i = 0; i < spin; i++) x += i; + return x; + }; + await Promise.all([mcp(), gate(), kick()]); + await server.cleanup(); +} + function failureType(err: unknown): string { const failure = err instanceof ActivityFailure ? err.cause : err; return failure instanceof ApplicationFailure ? `${failure.type}:${failure.nonRetryable}` : String(err); } export async function statefulMcpFactoryResolverWorkflow(): Promise { - // Longer than the test's execution timeout, so the operations fail only through the session failure. - const server = statefulMcpServer('factoryResolver', { config: { scheduleToStartTimeout: '1 minute' } }); + // listTools() is scheduled before the session fails, so it gets the refusal at its schedule-to-start timeout; + // callTool() starts after the session failed, so it throws the refusal without scheduling an Activity. + const server = statefulMcpServer('factoryResolver', { config: { scheduleToStartTimeout: '3 seconds' } }); await server.connect(); const results: string[] = []; for (const operation of [() => server.listTools(), () => server.callTool('echo', {})]) { diff --git a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts index 623efe43..2e3db642 100644 --- a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts +++ b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts @@ -56,12 +56,29 @@ const DEDICATED_WORKER_SCHEDULE_FAILURE_MESSAGE = const DEDICATED_WORKER_HEARTBEAT_FAILURE_MESSAGE = 'MCP Stateful Server Worker failed to heartbeat. Check that a worker is polling the dedicated task queue for this MCP run.'; -async function handleWorkerFailure(fn: () => Promise): Promise { +function isToolMetaResolverRefusal(err: unknown): err is ActivityFailure { + return ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === TOOL_META_RESOLVER_UNSUPPORTED_TYPE + ); +} + +/** + * Translates dedicated-Worker timeouts. `sessionRefusal` returns the session's failure if the Worker refused a + * `toolMetaResolver` on the factory's server: then the dedicated Worker never started, so that failure is the + * cause of a schedule-to-start timeout. + */ +async function handleWorkerFailure(fn: () => Promise, sessionRefusal: () => unknown): Promise { try { return await fn(); } catch (err: unknown) { if (err instanceof ActivityFailure && err.cause instanceof TimeoutFailure) { if (err.cause.timeoutType === TimeoutType.SCHEDULE_TO_START) { + const refusal = sessionRefusal(); + if (refusal) { + throw refusal; + } throw ApplicationFailure.create({ message: DEDICATED_WORKER_SCHEDULE_FAILURE_MESSAGE, type: DEDICATED_WORKER_FAILURE_TYPE, @@ -94,6 +111,7 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { private _sessionScope: CancellationScope | undefined; private _sessionPromise: Promise | undefined; private _connected = false; + private _sessionRefusal: ActivityFailure | undefined; toolMetaResolver?: MCPToolMetaResolver; constructor(name: string, options?: StatefulMcpServerOptions) { @@ -141,12 +159,17 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { activityInterceptorOptions: pluginConfig ? { addTemporalSpans: pluginConfig.addTemporalSpans } : undefined, }; + this._sessionRefusal = undefined; this._sessionScope = new CancellationScope(); this._sessionPromise = this._sessionScope.run(() => scheduleActivity(sessionActivityName, [args], this._sessionConfig) ); - // Errors are observed during cleanup, not here. - this._sessionPromise.catch(() => {}); + // Errors are observed during cleanup, not here, except a refused toolMetaResolver, which operations throw. + this._sessionPromise.catch((err: unknown) => { + if (isToolMetaResolverRefusal(err)) { + this._sessionRefusal = err; + } + }); this._connected = true; } @@ -163,6 +186,7 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { } this._sessionScope = undefined; this._sessionPromise = undefined; + this._sessionRefusal = undefined; this._connected = false; } } @@ -180,32 +204,19 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { } } - /** - * Runs a tool operation Activity. If the session Activity fails because the Worker refused a - * `toolMetaResolver` on the factory's server, the dedicated Worker never starts, so this cancels - * the operation and throws that failure instead of waiting for the schedule-to-start timeout. - */ - private async runOperation(fn: () => Promise): Promise { - const scope = new CancellationScope(); - const operation = scope.run(() => handleWorkerFailure(fn)); - const refused = new Promise((_resolve, reject) => { - this._sessionPromise?.catch((err: unknown) => { - if ( - err instanceof ActivityFailure && - err.cause instanceof ApplicationFailure && - err.cause.type === TOOL_META_RESOLVER_UNSUPPORTED_TYPE - ) { - scope.cancel(); - reject(err); - } - }); - }); - return Promise.race([operation, refused]); + private assertNotRefused(): void { + if (this._sessionRefusal) { + throw this._sessionRefusal; + } } async listTools(): Promise { this.assertConnected(); - return this.runOperation(() => scheduleActivity(this._name + MCP_LIST_TOOLS_SUFFIX, [], this._operationConfig)); + this.assertNotRefused(); + return handleWorkerFailure( + () => scheduleActivity(this._name + MCP_LIST_TOOLS_SUFFIX, [], this._operationConfig), + () => this._sessionRefusal + ); } async callTool( @@ -214,12 +225,15 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { meta?: Record | null ): Promise { this.assertConnected(); - return this.runOperation(() => - scheduleActivity( - this._name + MCP_CALL_TOOL_SUFFIX, - [{ toolName, args, ...(meta != null ? { meta } : {}) }], - this._operationConfig - ) + this.assertNotRefused(); + return handleWorkerFailure( + () => + scheduleActivity( + this._name + MCP_CALL_TOOL_SUFFIX, + [{ toolName, args, ...(meta != null ? { meta } : {}) }], + this._operationConfig + ), + () => this._sessionRefusal ); } From 602f1d16add6de4ffb56efd654a832d0f45bf514 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 30 Oct 2026 12:36:29 -0700 Subject: [PATCH 09/16] Refuse a stateful factory resolver in the operation Activities, as #209 does Move the stateful refusal back to getServerOrThrow(), where #209 refuses a callable toolFilter. The session connects and every operation fails at once, with no Workflow-side change, so drop the handle's session-refusal handling, the ordering replay fixture and its Workflow. --- typescript/openai-agents/README.md | 4 +- .../histories/stateful-mcp-ordering-main.json | 794 ------------------ .../src/__tests__/mcp-provider.test.ts | 20 - .../src/__tests__/test-openai-agents.ts | 39 +- .../src/__tests__/workflows/openai-agents.ts | 40 +- .../src/worker/stateful-mcp-provider.ts | 3 +- .../src/workflow/stateful-mcp-client.ts | 58 +- 7 files changed, 23 insertions(+), 935 deletions(-) delete mode 100644 typescript/openai-agents/src/__tests__/histories/stateful-mcp-ordering-main.json diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index dc30fc23..cfa67b81 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -513,7 +513,7 @@ Dedicated Worker startup and heartbeat failures surface as `ApplicationFailure` ### MCP request metadata -A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one before connecting: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported`. For `statefulMcpServer()`, the server-session Activity fails. The handle's `listTools()` and `callTool()` throw that failure at once when called after it, and at their schedule-to-start timeout (`config.scheduleToStartTimeout`) when already waiting for the dedicated Worker. +A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported`. ```ts const server = statelessMcpServer('tickets', { @@ -525,7 +525,7 @@ The `_meta` values are recorded in the call-tool Activity input in Workflow Hist A call-tool Activity that runs on a Worker with an older plugin version drops the `_meta`, and nothing fails. Upgrade every Worker on the Task Queue before Workflows rely on `_meta`, or use Worker Versioning. -Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, and the handles' `listTools()` and `callTool()` take the same promise steps as before, so their commands don't change when you upgrade or roll back. +Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, so this option doesn't change their commands. ## Sandbox agents diff --git a/typescript/openai-agents/src/__tests__/histories/stateful-mcp-ordering-main.json b/typescript/openai-agents/src/__tests__/histories/stateful-mcp-ordering-main.json deleted file mode 100644 index bb00e2b5..00000000 --- a/typescript/openai-agents/src/__tests__/histories/stateful-mcp-ordering-main.json +++ /dev/null @@ -1,794 +0,0 @@ -{ - "events": [ - { - "eventId": "1", - "eventTime": "2026-10-30T12:20:30.619293764Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", - "taskId": "1048587", - "workflowExecutionStartedEventAttributes": { - "workflowType": { - "name": "statefulMcpOrderingWorkflow" - }, - "taskQueue": { - "name": "record-or-replay-ordering", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "input": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "MTIwMDAwMDAwMA==" - } - ] - }, - "workflowExecutionTimeout": "60s", - "workflowRunTimeout": "60s", - "workflowTaskTimeout": "10s", - "originalExecutionRunId": "01a18cc1-251b-7471-8a29-cf0e56a7888f", - "identity": "1100@d0a5644da69b", - "firstExecutionRunId": "01a18cc1-251b-7471-8a29-cf0e56a7888f", - "attempt": 1, - "workflowExecutionExpirationTime": "2026-10-30T12:21:30.616Z", - "firstWorkflowTaskBackoff": "0s", - "header": {}, - "workflowId": "29ff8fb2-333b-47d3-a3e8-cfee26d997e1" - } - }, - { - "eventId": "2", - "eventTime": "2026-10-30T12:20:30.626594847Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1048588", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "record-or-replay-ordering", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "3", - "eventTime": "2026-10-30T12:20:30.747128430Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1048594", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "2", - "identity": "1100@d0a5644da69b", - "requestId": "fd86e16f-23dc-40d2-98dc-6770941e3ee4", - "historySizeBytes": "364", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "4", - "eventTime": "2026-10-30T12:20:30.978013389Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1048599", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "2", - "startedEventId": "3", - "identity": "1100@d0a5644da69b", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - }, - "sdkMetadata": { - "coreUsedFlags": [ - 3, - 2, - 1 - ], - "sdkName": "temporal-typescript", - "sdkVersion": "1.24.0" - }, - "meteringMetadata": {} - } - }, - { - "eventId": "5", - "eventTime": "2026-10-30T12:20:30.978538347Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1048600", - "activityTaskScheduledEventAttributes": { - "activityId": "1", - "activityType": { - "name": "ordering-stateful-server-session" - }, - "taskQueue": { - "name": "record-or-replay-ordering", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": { - "fields": { - "__openai_span": { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" - } - } - }, - "input": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "e30=" - } - ] - }, - "scheduleToCloseTimeout": "60s", - "scheduleToStartTimeout": "60s", - "startToCloseTimeout": "60s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "4", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true - } - }, - { - "eventId": "6", - "eventTime": "2026-10-30T12:20:30.979631055Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1048601", - "activityTaskScheduledEventAttributes": { - "activityId": "2", - "activityType": { - "name": "ordering-stateful-list-tools" - }, - "taskQueue": { - "name": "ordering-stateful@01a18cc1-251b-7471-8a29-cf0e56a7888f", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": { - "fields": { - "__openai_span": { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" - } - } - }, - "scheduleToCloseTimeout": "60s", - "scheduleToStartTimeout": "30s", - "startToCloseTimeout": "60s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "4", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2, - "maximumInterval": "100s" - } - } - }, - { - "eventId": "7", - "eventTime": "2026-10-30T12:20:31.006875597Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1048608", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "6", - "identity": "1100@d0a5644da69b", - "requestId": "b5c6e701-1432-4915-902b-92e65f2cbbe1", - "attempt": 1, - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0" - } - } - }, - { - "eventId": "8", - "eventTime": "2026-10-30T12:20:31.015518430Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1048609", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "W10=" - } - ] - }, - "scheduledEventId": "6", - "startedEventId": "7", - "identity": "1100@d0a5644da69b" - } - }, - { - "eventId": "9", - "eventTime": "2026-10-30T12:20:31.015720597Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1048610", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "1100@d0a5644da69b-a0e6d857a7e643fdbedc795a2d158303", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "record-or-replay-ordering" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "10", - "eventTime": "2026-10-30T12:20:31.018376722Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1048614", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "9", - "identity": "1100@d0a5644da69b", - "requestId": "b1724442-125a-4b7a-857f-d5352ba2449e", - "historySizeBytes": "1583", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "11", - "eventTime": "2026-10-30T12:20:31.038039430Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1048620", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "9", - "startedEventId": "10", - "identity": "1100@d0a5644da69b", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "12", - "eventTime": "2026-10-30T12:20:31.038094014Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1048621", - "activityTaskScheduledEventAttributes": { - "activityId": "3", - "activityType": { - "name": "ordering-stateful-call-tool" - }, - "taskQueue": { - "name": "ordering-stateful@01a18cc1-251b-7471-8a29-cf0e56a7888f", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": { - "fields": { - "__openai_span": { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" - } - } - }, - "input": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJ0b29sTmFtZSI6InNsb3ciLCJhcmdzIjp7fX0=" - } - ] - }, - "scheduleToCloseTimeout": "60s", - "scheduleToStartTimeout": "30s", - "startToCloseTimeout": "60s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "11", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2, - "maximumInterval": "100s" - } - } - }, - { - "eventId": "13", - "eventTime": "2026-10-30T12:20:31.038134847Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1048622", - "activityTaskScheduledEventAttributes": { - "activityId": "4", - "activityType": { - "name": "orderingGate" - }, - "taskQueue": { - "name": "record-or-replay-ordering", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": { - "fields": { - "__openai_span": { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" - } - } - }, - "scheduleToCloseTimeout": "60s", - "scheduleToStartTimeout": "60s", - "startToCloseTimeout": "10s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "11", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true - } - }, - { - "eventId": "14", - "eventTime": "2026-10-30T12:20:31.038145847Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1048623", - "activityTaskScheduledEventAttributes": { - "activityId": "5", - "activityType": { - "name": "orderingKick" - }, - "taskQueue": { - "name": "record-or-replay-ordering", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": { - "fields": { - "__openai_span": { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" - } - } - }, - "scheduleToCloseTimeout": "60s", - "scheduleToStartTimeout": "60s", - "startToCloseTimeout": "10s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "11", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true - } - }, - { - "eventId": "15", - "eventTime": "2026-10-30T12:20:31.038167722Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1048629", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "14", - "identity": "1100@d0a5644da69b", - "requestId": "15ba8124-161b-490e-a7e0-15ef5522f1da", - "attempt": 1, - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "16", - "eventTime": "2026-10-30T12:20:31.042907972Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1048630", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L251bGw=" - } - } - ] - }, - "scheduledEventId": "14", - "startedEventId": "15", - "identity": "1100@d0a5644da69b" - } - }, - { - "eventId": "17", - "eventTime": "2026-10-30T12:20:31.042932347Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1048631", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "1100@d0a5644da69b-a0e6d857a7e643fdbedc795a2d158303", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "record-or-replay-ordering" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "18", - "eventTime": "2026-10-30T12:20:31.044909639Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1048635", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "17", - "identity": "1100@d0a5644da69b", - "requestId": "d207509b-e657-44ed-8119-70461e57ecde", - "historySizeBytes": "3055", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "19", - "eventTime": "2026-10-30T12:20:31.681749083Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1048641", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "17", - "startedEventId": "18", - "identity": "1100@d0a5644da69b", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "20", - "eventTime": "2026-10-30T12:20:31.038158139Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1048642", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "13", - "identity": "1100@d0a5644da69b", - "requestId": "b78fbf72-3fd3-4cda-a30c-31a26817f90c", - "attempt": 1, - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "21", - "eventTime": "2026-10-30T12:20:31.040382472Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1048643", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "12", - "identity": "1100@d0a5644da69b", - "requestId": "9b0aa94f-9e49-4635-9be6-6c6ffd2ea0ad", - "attempt": 1, - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0" - } - } - }, - { - "eventId": "22", - "eventTime": "2026-10-30T12:20:31.242200375Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1048644", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L251bGw=" - } - } - ] - }, - "scheduledEventId": "13", - "startedEventId": "20", - "identity": "1100@d0a5644da69b" - } - }, - { - "eventId": "23", - "eventTime": "2026-10-30T12:20:31.243983333Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1048645", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "W3sidHlwZSI6InRleHQiLCJ0ZXh0Ijoib2sifV0=" - } - ] - }, - "scheduledEventId": "12", - "startedEventId": "21", - "identity": "1100@d0a5644da69b" - } - }, - { - "eventId": "24", - "eventTime": "2026-10-30T12:20:31.681832750Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1048646", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "1100@d0a5644da69b-a0e6d857a7e643fdbedc795a2d158303", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "record-or-replay-ordering" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "25", - "eventTime": "2026-10-30T12:20:31.681839750Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1048647", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "24", - "identity": "1100@d0a5644da69b", - "requestId": "request-from-RespondWorkflowTaskCompleted", - "historySizeBytes": "3238", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "26", - "eventTime": "2026-10-30T12:20:31.697161958Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1048652", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "24", - "startedEventId": "25", - "identity": "1100@d0a5644da69b", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "27", - "eventTime": "2026-10-30T12:20:31.697225541Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1048653", - "activityTaskScheduledEventAttributes": { - "activityId": "6", - "activityType": { - "name": "orderingAfterMcp" - }, - "taskQueue": { - "name": "record-or-replay-ordering", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": { - "fields": { - "__openai_span": { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" - } - } - }, - "scheduleToCloseTimeout": "60s", - "scheduleToStartTimeout": "60s", - "startToCloseTimeout": "10s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "26", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true - } - }, - { - "eventId": "28", - "eventTime": "2026-10-30T12:20:31.697270708Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", - "taskId": "1048654", - "activityTaskScheduledEventAttributes": { - "activityId": "7", - "activityType": { - "name": "orderingAfterGate" - }, - "taskQueue": { - "name": "record-or-replay-ordering", - "kind": "TASK_QUEUE_KIND_NORMAL" - }, - "header": { - "fields": { - "__openai_span": { - "metadata": { - "encoding": "anNvbi9wbGFpbg==" - }, - "data": "eyJ0cmFjZU5hbWUiOiJVbmtub3duIFdvcmtmbG93Iiwic3BhbklkIjpudWxsLCJ0cmFjZUlkIjpudWxsfQ==" - } - } - }, - "scheduleToCloseTimeout": "60s", - "scheduleToStartTimeout": "60s", - "startToCloseTimeout": "10s", - "heartbeatTimeout": "0s", - "workflowTaskCompletedEventId": "26", - "retryPolicy": { - "initialInterval": "1s", - "backoffCoefficient": 2, - "maximumInterval": "100s" - }, - "useWorkflowBuildId": true - } - }, - { - "eventId": "29", - "eventTime": "2026-10-30T12:20:31.697293583Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1048657", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "27", - "identity": "1100@d0a5644da69b", - "requestId": "5c4efdf1-7c10-4fb3-a081-d414f5539a85", - "attempt": 1, - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "30", - "eventTime": "2026-10-30T12:20:31.701433500Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1048658", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L251bGw=" - } - } - ] - }, - "scheduledEventId": "27", - "startedEventId": "29", - "identity": "1100@d0a5644da69b" - } - }, - { - "eventId": "31", - "eventTime": "2026-10-30T12:20:31.701455875Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", - "taskId": "1048659", - "workflowTaskScheduledEventAttributes": { - "taskQueue": { - "name": "1100@d0a5644da69b-a0e6d857a7e643fdbedc795a2d158303", - "kind": "TASK_QUEUE_KIND_STICKY", - "normalName": "record-or-replay-ordering" - }, - "startToCloseTimeout": "10s", - "attempt": 1 - } - }, - { - "eventId": "32", - "eventTime": "2026-10-30T12:20:31.697302375Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", - "taskId": "1048664", - "activityTaskStartedEventAttributes": { - "scheduledEventId": "28", - "identity": "1100@d0a5644da69b", - "requestId": "09176c83-7c6c-4892-9df5-c9c08bab207e", - "attempt": 1, - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "33", - "eventTime": "2026-10-30T12:20:31.703821333Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", - "taskId": "1048665", - "activityTaskCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L251bGw=" - } - } - ] - }, - "scheduledEventId": "28", - "startedEventId": "32", - "identity": "1100@d0a5644da69b" - } - }, - { - "eventId": "34", - "eventTime": "2026-10-30T12:20:31.704900166Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", - "taskId": "1048667", - "workflowTaskStartedEventAttributes": { - "scheduledEventId": "31", - "identity": "1100@d0a5644da69b", - "requestId": "a4df2540-639b-4572-902c-53576230024c", - "historySizeBytes": "5419", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - } - } - }, - { - "eventId": "35", - "eventTime": "2026-10-30T12:20:31.731858708Z", - "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", - "taskId": "1048671", - "workflowTaskCompletedEventAttributes": { - "scheduledEventId": "31", - "startedEventId": "34", - "identity": "1100@d0a5644da69b", - "workerVersion": { - "buildId": "@temporalio/worker@1.24.0+9641d95d35f7488697a6063bd1accb9967ee61228eb73b80fe593d50ac2e8387" - }, - "sdkMetadata": {}, - "meteringMetadata": {} - } - }, - { - "eventId": "36", - "eventTime": "2026-10-30T12:20:31.731992541Z", - "eventType": "EVENT_TYPE_ACTIVITY_TASK_CANCEL_REQUESTED", - "taskId": "1048672", - "activityTaskCancelRequestedEventAttributes": { - "scheduledEventId": "5", - "workflowTaskCompletedEventId": "35" - } - }, - { - "eventId": "37", - "eventTime": "2026-10-30T12:20:31.732378750Z", - "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", - "taskId": "1048673", - "workflowExecutionCompletedEventAttributes": { - "result": { - "payloads": [ - { - "metadata": { - "encoding": "YmluYXJ5L251bGw=" - } - } - ] - }, - "workflowTaskCompletedEventId": "35" - } - } - ] -} diff --git a/typescript/openai-agents/src/__tests__/mcp-provider.test.ts b/typescript/openai-agents/src/__tests__/mcp-provider.test.ts index cb001f42..a5828770 100644 --- a/typescript/openai-agents/src/__tests__/mcp-provider.test.ts +++ b/typescript/openai-agents/src/__tests__/mcp-provider.test.ts @@ -1,10 +1,7 @@ import test from 'ava'; import type { MCPServer } from '@openai/agents-core'; import { ApplicationFailure } from '@temporalio/common'; -import type { NativeConnection } from '@temporalio/worker'; -import { MockActivityEnvironment } from '@temporalio/testing'; import { StatelessMCPServerProvider } from '../worker/mcp-provider'; -import { StatefulMCPServerProvider } from '../worker/stateful-mcp-provider'; function serverWithResolver(onConnect: () => void): MCPServer { return { @@ -41,20 +38,3 @@ test('StatelessMCPServerProvider refuses a toolMetaResolver without connecting', } t.is(connects, 0); }); - -test('StatefulMCPServerProvider refuses a toolMetaResolver at session start without connecting', async (t) => { - let connects = 0; - const activities = new StatefulMCPServerProvider( - 'tickets', - () => serverWithResolver(() => connects++), - {} as NativeConnection - )._getActivities(); - - const err = await t.throwsAsync( - new MockActivityEnvironment().run(activities['tickets-stateful-server-session']!, {}), - { instanceOf: ApplicationFailure } - ); - t.is(err?.type, 'MCPToolMetaResolverUnsupported'); - t.true(err?.nonRetryable); - t.is(connects, 0); -}); diff --git a/typescript/openai-agents/src/__tests__/test-openai-agents.ts b/typescript/openai-agents/src/__tests__/test-openai-agents.ts index 35491cb3..9dfafb01 100644 --- a/typescript/openai-agents/src/__tests__/test-openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/test-openai-agents.ts @@ -1,7 +1,5 @@ // Integration tests that need a real Worker. Pure-function tests live in // the sibling `*.test.ts` files. -import { readFileSync } from 'node:fs'; -import * as path from 'node:path'; import { addTraceProcessor, getCurrentTrace, @@ -14,7 +12,7 @@ import type { MCPServer, Model, ModelProvider, Span, SpanData, TracingProcessor import { WorkflowClient } from '@temporalio/client'; import { temporal } from '@temporalio/proto'; import { helpers } from '@temporalio/test-helpers'; -import { bundleWorkflowCode, Worker } from '@temporalio/worker'; +import { bundleWorkflowCode } from '@temporalio/worker'; import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; import { OpenAIAgentsPlugin, @@ -290,10 +288,9 @@ test('MCP toolMetaResolver sends _meta on stateless and stateful servers', async ]); }); -test('Stateful MCP: a factory toolMetaResolver fails the operations without connecting', async (t) => { +test('Stateful MCP: a factory toolMetaResolver fails the operations', async (t) => { const { createWorker, startWorkflow } = helpers(t); - let connects = 0; const worker = await createWorker({ plugins: [ new OpenAIAgentsPlugin({ @@ -301,14 +298,7 @@ test('Stateful MCP: a factory toolMetaResolver fails the operations without conn mcpServerProviders: [ new StatefulMCPServerProvider( 'factoryResolver', - () => ({ - ...metaTestServer('factoryResolver', []), - toolMetaResolver: () => ({ tenant: 'tenant-7' }), - async connect() { - connects++; - throw new Error('connection refused'); - }, - }), + () => ({ ...metaTestServer('factoryResolver', []), toolMetaResolver: () => ({ tenant: 'tenant-7' }) }), t.context.env.nativeConnection ), ], @@ -318,25 +308,12 @@ test('Stateful MCP: a factory toolMetaResolver fails the operations without conn await worker.runUntil(async () => { const handle = await startWorkflow(statefulMcpFactoryResolverWorkflow, { workflowExecutionTimeout: '20 seconds' }); - t.deepEqual(await handle.result(), Array(3).fill('MCPToolMetaResolverUnsupported:true')); - const { events } = await handle.fetchHistory(); - const scheduled = (events ?? []) - .filter((e) => e.eventType === EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED) - .map((e) => e.activityTaskScheduledEventAttributes?.activityType?.name); - // callTool() runs after the refusal, so it schedules nothing. - t.deepEqual(scheduled, ['factoryResolver-stateful-server-session', 'factoryResolver-stateful-list-tools']); + t.deepEqual(await handle.result(), [ + 'MCPToolMetaResolverUnsupported:true', + 'MCPToolMetaResolverUnsupported:true', + 'cleanup-success', + ]); }); - t.is(connects, 0); -}); - -test('Stateful MCP: operations keep the promise steps of histories recorded before toolMetaResolver', async (t) => { - // statefulMcpOrderingWorkflow, recorded with main's (1e8ae93) stateful-mcp-client.ts: the callTool() and - // gate results arrive in one Workflow Task, and orderingAfterMcp is scheduled before orderingAfterGate. - // Extra promise steps in callTool() reverse them and fail this replay with a nondeterminism error. - const history = JSON.parse( - readFileSync(path.resolve(__dirname, '../../src/__tests__/histories/stateful-mcp-ordering-main.json'), 'utf8') - ); - await t.notThrowsAsync(Worker.runReplayHistory({ workflowBundle: t.context.workflowBundle }, history)); }); test('summary string is passed through to model activity', async (t) => { diff --git a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts index 53eb1fa2..8908916b 100644 --- a/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts +++ b/typescript/openai-agents/src/__tests__/workflows/openai-agents.ts @@ -1,5 +1,5 @@ import { Agent, type MCPToolMetaContext } from '@openai/agents-core'; -import { ActivityFailure, ApplicationFailure, proxyActivities } from '@temporalio/workflow'; +import { ActivityFailure, ApplicationFailure } from '@temporalio/workflow'; import { WorkflowStream } from '@temporalio/workflow-streams/workflow'; import { TemporalOpenAIRunner, statelessMcpServer, statefulMcpServer } from '../../workflow'; @@ -123,49 +123,13 @@ export async function mcpToolMetaWorkflow(stateful: boolean): Promise { } } -const { orderingGate, orderingKick, orderingAfterMcp, orderingAfterGate } = proxyActivities< - Record<'orderingGate' | 'orderingKick' | 'orderingAfterMcp' | 'orderingAfterGate', () => Promise> ->({ startToCloseTimeout: '10 seconds' }); - -/** - * A stateful callTool() and a gate Activity complete while a Workflow Task spins, so both - * results reach the same activation. The order of the follow-up Activities depends on how many - * promise steps callTool() takes after its Activity completes. - */ -export async function statefulMcpOrderingWorkflow(spin: number): Promise { - const server = statefulMcpServer('ordering'); - await server.connect(); - await server.listTools(); - const mcp = async () => { - await server.callTool('slow', {}); - await orderingAfterMcp(); - }; - const gate = async () => { - await orderingGate(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await orderingAfterGate(); - }; - const kick = async () => { - await orderingKick(); - let x = 0; - for (let i = 0; i < spin; i++) x += i; - return x; - }; - await Promise.all([mcp(), gate(), kick()]); - await server.cleanup(); -} - function failureType(err: unknown): string { const failure = err instanceof ActivityFailure ? err.cause : err; return failure instanceof ApplicationFailure ? `${failure.type}:${failure.nonRetryable}` : String(err); } export async function statefulMcpFactoryResolverWorkflow(): Promise { - // listTools() is scheduled before the session fails, so it gets the refusal at its schedule-to-start timeout; - // callTool() starts after the session failed, so it throws the refusal without scheduling an Activity. - const server = statefulMcpServer('factoryResolver', { config: { scheduleToStartTimeout: '3 seconds' } }); + const server = statefulMcpServer('factoryResolver'); await server.connect(); const results: string[] = []; for (const operation of [() => server.listTools(), () => server.callTool('echo', {})]) { diff --git a/typescript/openai-agents/src/worker/stateful-mcp-provider.ts b/typescript/openai-agents/src/worker/stateful-mcp-provider.ts index 6122a89a..d92f14a8 100644 --- a/typescript/openai-agents/src/worker/stateful-mcp-provider.ts +++ b/typescript/openai-agents/src/worker/stateful-mcp-provider.ts @@ -74,6 +74,7 @@ export class StatefulMCPServerProvider { nonRetryable: true, }); } + rejectToolMetaResolver(server); return server; }; @@ -104,8 +105,6 @@ export class StatefulMCPServerProvider { } const server = this._serverFactory(input?.factoryArgument); - // Refuse before connecting, so the session fails at once instead of retrying the connection. - rejectToolMetaResolver(server); try { this._servers.set(sid, server); diff --git a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts index 2e3db642..6e1feeb3 100644 --- a/typescript/openai-agents/src/workflow/stateful-mcp-client.ts +++ b/typescript/openai-agents/src/workflow/stateful-mcp-client.ts @@ -23,7 +23,6 @@ import { type McpServerHandleOptions, type StatefulMcpServerSessionArgs, type StatefulTemporalMCPServer, - TOOL_META_RESOLVER_UNSUPPORTED_TYPE, } from '../common/mcp-types'; import { getCurrentPluginConfig } from './plugin-config-store'; @@ -56,29 +55,12 @@ const DEDICATED_WORKER_SCHEDULE_FAILURE_MESSAGE = const DEDICATED_WORKER_HEARTBEAT_FAILURE_MESSAGE = 'MCP Stateful Server Worker failed to heartbeat. Check that a worker is polling the dedicated task queue for this MCP run.'; -function isToolMetaResolverRefusal(err: unknown): err is ActivityFailure { - return ( - err instanceof ActivityFailure && - err.cause instanceof ApplicationFailure && - err.cause.type === TOOL_META_RESOLVER_UNSUPPORTED_TYPE - ); -} - -/** - * Translates dedicated-Worker timeouts. `sessionRefusal` returns the session's failure if the Worker refused a - * `toolMetaResolver` on the factory's server: then the dedicated Worker never started, so that failure is the - * cause of a schedule-to-start timeout. - */ -async function handleWorkerFailure(fn: () => Promise, sessionRefusal: () => unknown): Promise { +async function handleWorkerFailure(fn: () => Promise): Promise { try { return await fn(); } catch (err: unknown) { if (err instanceof ActivityFailure && err.cause instanceof TimeoutFailure) { if (err.cause.timeoutType === TimeoutType.SCHEDULE_TO_START) { - const refusal = sessionRefusal(); - if (refusal) { - throw refusal; - } throw ApplicationFailure.create({ message: DEDICATED_WORKER_SCHEDULE_FAILURE_MESSAGE, type: DEDICATED_WORKER_FAILURE_TYPE, @@ -111,7 +93,6 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { private _sessionScope: CancellationScope | undefined; private _sessionPromise: Promise | undefined; private _connected = false; - private _sessionRefusal: ActivityFailure | undefined; toolMetaResolver?: MCPToolMetaResolver; constructor(name: string, options?: StatefulMcpServerOptions) { @@ -159,17 +140,12 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { activityInterceptorOptions: pluginConfig ? { addTemporalSpans: pluginConfig.addTemporalSpans } : undefined, }; - this._sessionRefusal = undefined; this._sessionScope = new CancellationScope(); this._sessionPromise = this._sessionScope.run(() => scheduleActivity(sessionActivityName, [args], this._sessionConfig) ); - // Errors are observed during cleanup, not here, except a refused toolMetaResolver, which operations throw. - this._sessionPromise.catch((err: unknown) => { - if (isToolMetaResolverRefusal(err)) { - this._sessionRefusal = err; - } - }); + // Errors are observed during cleanup, not here. + this._sessionPromise.catch(() => {}); this._connected = true; } @@ -186,7 +162,6 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { } this._sessionScope = undefined; this._sessionPromise = undefined; - this._sessionRefusal = undefined; this._connected = false; } } @@ -204,19 +179,9 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { } } - private assertNotRefused(): void { - if (this._sessionRefusal) { - throw this._sessionRefusal; - } - } - async listTools(): Promise { this.assertConnected(); - this.assertNotRefused(); - return handleWorkerFailure( - () => scheduleActivity(this._name + MCP_LIST_TOOLS_SUFFIX, [], this._operationConfig), - () => this._sessionRefusal - ); + return handleWorkerFailure(() => scheduleActivity(this._name + MCP_LIST_TOOLS_SUFFIX, [], this._operationConfig)); } async callTool( @@ -225,15 +190,12 @@ export class StatefulMCPServerReference implements StatefulTemporalMCPServer { meta?: Record | null ): Promise { this.assertConnected(); - this.assertNotRefused(); - return handleWorkerFailure( - () => - scheduleActivity( - this._name + MCP_CALL_TOOL_SUFFIX, - [{ toolName, args, ...(meta != null ? { meta } : {}) }], - this._operationConfig - ), - () => this._sessionRefusal + return handleWorkerFailure(() => + scheduleActivity( + this._name + MCP_CALL_TOOL_SUFFIX, + [{ toolName, args, ...(meta != null ? { meta } : {}) }], + this._operationConfig + ) ); } From aa51e5ffec7449a54cda2620dac3362f26b53d42 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 30 Oct 2026 12:37:21 -0700 Subject: [PATCH 10/16] Export TOOL_META_RESOLVER_UNSUPPORTED_TYPE Export the refusal's failure type from both entry points, next to DEDICATED_WORKER_FAILURE_TYPE, so Workflow code that handles it doesn't hard-code the string. --- typescript/openai-agents/README.md | 6 +++--- typescript/openai-agents/src/__tests__/mcp-provider.test.ts | 3 ++- typescript/openai-agents/src/index.ts | 2 +- typescript/openai-agents/src/workflow.ts | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index cfa67b81..466dc173 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -513,7 +513,7 @@ Dedicated Worker startup and heartbeat failures surface as `ApplicationFailure` ### MCP request metadata -A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported`. +A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported` (exported as `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`). ```ts const server = statelessMcpServer('tickets', { @@ -722,9 +722,9 @@ Most applications use two import paths: `@temporalio/openai-agents` in Worker an | `@temporalio/openai-agents/otel` | Worker or Client | Replay-safe OpenTelemetry setup | | `@temporalio/openai-agents/workflow-interceptor` | Worker bundling | Manual `workflowInterceptorModules` wiring without a plugin | -`@temporalio/openai-agents` exports `OpenAIAgentsPlugin`, MCP provider classes, `SandboxClientProvider`, model option types, hosted tool credential types, and `DEDICATED_WORKER_FAILURE_TYPE`. `OpenAIAgentsTraceClientInterceptor` is also public for Clients that need manual interceptor wiring instead of plugin registration. +`@temporalio/openai-agents` exports `OpenAIAgentsPlugin`, MCP provider classes, `SandboxClientProvider`, model option types, hosted tool credential types, `DEDICATED_WORKER_FAILURE_TYPE` and `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`. `OpenAIAgentsTraceClientInterceptor` is also public for Clients that need manual interceptor wiring instead of plugin registration. -`@temporalio/openai-agents/workflow` exports Workflow-safe APIs: `TemporalOpenAIRunner`, `WorkflowSafeMemorySession`, `activityAsTool`, `nexusOperationAsTool`, `agentAsTool`, `statelessMcpServer`, `statefulMcpServer`, `temporalSandboxClient`, `workerEnvValue`, related option/definition types, and `DEDICATED_WORKER_FAILURE_TYPE`. +`@temporalio/openai-agents/workflow` exports Workflow-safe APIs: `TemporalOpenAIRunner`, `WorkflowSafeMemorySession`, `activityAsTool`, `nexusOperationAsTool`, `agentAsTool`, `statelessMcpServer`, `statefulMcpServer`, `temporalSandboxClient`, `workerEnvValue`, related option/definition types, `DEDICATED_WORKER_FAILURE_TYPE` and `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`. `@temporalio/openai-agents/otel` exports `createTracerProvider`, `TemporalIdGenerator`, `markReplaySafeTracerProvider`, `isReplaySafeTracerProvider`, and `TemporalOpenAIAgentsTracerProviderOptions`. Install optional peer dependency `@opentelemetry/sdk-trace-base` only when you use this OTel integration. diff --git a/typescript/openai-agents/src/__tests__/mcp-provider.test.ts b/typescript/openai-agents/src/__tests__/mcp-provider.test.ts index a5828770..3dbc40f9 100644 --- a/typescript/openai-agents/src/__tests__/mcp-provider.test.ts +++ b/typescript/openai-agents/src/__tests__/mcp-provider.test.ts @@ -1,6 +1,7 @@ import test from 'ava'; import type { MCPServer } from '@openai/agents-core'; import { ApplicationFailure } from '@temporalio/common'; +import { TOOL_META_RESOLVER_UNSUPPORTED_TYPE } from '..'; import { StatelessMCPServerProvider } from '../worker/mcp-provider'; function serverWithResolver(onConnect: () => void): MCPServer { @@ -33,7 +34,7 @@ test('StatelessMCPServerProvider refuses a toolMetaResolver without connecting', const err = await t.throwsAsync(activities[name]!({ toolName: 'echo', args: {} }), { instanceOf: ApplicationFailure, }); - t.is(err?.type, 'MCPToolMetaResolverUnsupported'); + t.is(err?.type, TOOL_META_RESOLVER_UNSUPPORTED_TYPE); t.true(err?.nonRetryable); } t.is(connects, 0); diff --git a/typescript/openai-agents/src/index.ts b/typescript/openai-agents/src/index.ts index 184507f1..8bb0a4a4 100644 --- a/typescript/openai-agents/src/index.ts +++ b/typescript/openai-agents/src/index.ts @@ -12,7 +12,7 @@ export type { ModelSummaryProvider, SerializableModelActivityOptions, } from './common/model-activity-options'; -export { DEDICATED_WORKER_FAILURE_TYPE } from './common/mcp-types'; +export { DEDICATED_WORKER_FAILURE_TYPE, TOOL_META_RESOLVER_UNSUPPORTED_TYPE } from './common/mcp-types'; export type { HostedToolCredentials, HostedToolCredentialsResolver, diff --git a/typescript/openai-agents/src/workflow.ts b/typescript/openai-agents/src/workflow.ts index 6a2dd998..0a3a2bf0 100644 --- a/typescript/openai-agents/src/workflow.ts +++ b/typescript/openai-agents/src/workflow.ts @@ -12,7 +12,7 @@ export type { AgentAsToolOptions } from './workflow/agent-tools'; export { statelessMcpServer } from './workflow/mcp-client'; export type { StatelessMcpServerOptions } from './workflow/mcp-client'; export type { StatefulTemporalMCPServer } from './common/mcp-types'; -export { DEDICATED_WORKER_FAILURE_TYPE } from './common/mcp-types'; +export { DEDICATED_WORKER_FAILURE_TYPE, TOOL_META_RESOLVER_UNSUPPORTED_TYPE } from './common/mcp-types'; export { statefulMcpServer } from './workflow/stateful-mcp-client'; export type { StatefulMcpServerOptions } from './workflow/stateful-mcp-client'; export { temporalSandboxClient } from './workflow/sandbox-client'; From 62599e6e2aaf0d51299e795717a6263dd34e1523 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 30 Oct 2026 12:37:46 -0700 Subject: [PATCH 11/16] README: upgrade order for a factory resolver, and the stateful handle's name A run that listed tools on an older Worker gets the call-tool refusal as a tool error and keeps going, so say to remove a factory-side toolMetaResolver before upgrading Workers. Also say that a resolver set on a stateful handle gets the internal `-stateful` name. --- typescript/openai-agents/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index 466dc173..40a8e881 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -513,7 +513,7 @@ Dedicated Worker startup and heartbeat failures surface as `ApplicationFailure` ### MCP request metadata -A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one: every MCP Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported` (exported as `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`). +A `toolMetaResolver` builds each tool call's MCP `_meta` from the run context, which exists only in the Workflow. Pass it as the `toolMetaResolver` option of `statelessMcpServer()` or `statefulMcpServer()`; it runs in Workflow code and must be deterministic. The call-tool Activity passes its result to the server's `callTool`. The Worker can't run a `toolMetaResolver` on the factory's server, so it refuses one: every list-tools and call-tool Activity for that server fails with a non-retryable `ApplicationFailure` of type `MCPToolMetaResolverUnsupported` (exported as `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`). ```ts const server = statelessMcpServer('tickets', { @@ -521,11 +521,13 @@ const server = statelessMcpServer('tickets', { }); ``` +A new run with a `toolMetaResolver` on the factory's server fails at its first list-tools Activity. A `statelessMcpServer()` run that listed tools on an older Worker instead gets a tool error, which the model reads, for each call to that server, and the run keeps going. So remove the `toolMetaResolver` from the factory's server before you upgrade Workers. + The `_meta` values are recorded in the call-tool Activity input in Workflow History, so don't put secrets in them. A call-tool Activity that runs on a Worker with an older plugin version drops the `_meta`, and nothing fails. Upgrade every Worker on the Task Queue before Workflows rely on `_meta`, or use Worker Versioning. -Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, so this option doesn't change their commands. +Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, so this option doesn't change their commands. On a `statefulMcpServer()` handle, a resolver set this way gets the handle's internal name, `-stateful`, as `serverName`; the option gets ``. ## Sandbox agents From e100cfa59466c05df39c47f4ebfb7bb59ffed9c0 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Mon, 2 Nov 2026 08:12:23 -0800 Subject: [PATCH 12/16] Address the third review: migration replay, export lists, History note - README: adding a resolver to a Workflow with open runs, including moving one off the factory's server, can fail their replay, so guard it with patched() or let those runs finish. - README: both export lists name TOOL_FILTER_UNSUPPORTED_TYPE too. - The option's doc comment says its result is recorded in History, so it must not hold secrets. --- typescript/openai-agents/README.md | 6 ++++-- typescript/openai-agents/src/common/mcp-types.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index f6709a51..bfe5f10f 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -547,6 +547,8 @@ A call-tool Activity that runs on a Worker with an older plugin version drops th Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, so this option doesn't change their commands. On a `statefulMcpServer()` handle, a resolver set this way gets the handle's internal name, `-stateful`, as `serverName`; the option gets ``. +Adding a resolver to a Workflow that has open runs, including moving one off the factory's server, can fail their replay for the same reasons, so guard the change with `patched()` or let those runs finish first. + ## Sandbox agents > **Experimental** — sandbox support is subject to change prior to General Availability. @@ -742,9 +744,9 @@ Most applications use two import paths: `@temporalio/openai-agents` in Worker an | `@temporalio/openai-agents/otel` | Worker or Client | Replay-safe OpenTelemetry setup | | `@temporalio/openai-agents/workflow-interceptor` | Worker bundling | Manual `workflowInterceptorModules` wiring without a plugin | -`@temporalio/openai-agents` exports `OpenAIAgentsPlugin`, MCP provider classes, `SandboxClientProvider`, model option types, hosted tool credential types, `DEDICATED_WORKER_FAILURE_TYPE` and `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`. `OpenAIAgentsTraceClientInterceptor` is also public for Clients that need manual interceptor wiring instead of plugin registration. +`@temporalio/openai-agents` exports `OpenAIAgentsPlugin`, MCP provider classes, `SandboxClientProvider`, model option types, hosted tool credential types, `DEDICATED_WORKER_FAILURE_TYPE`, `TOOL_FILTER_UNSUPPORTED_TYPE` and `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`. `OpenAIAgentsTraceClientInterceptor` is also public for Clients that need manual interceptor wiring instead of plugin registration. -`@temporalio/openai-agents/workflow` exports Workflow-safe APIs: `TemporalOpenAIRunner`, `WorkflowSafeMemorySession`, `activityAsTool`, `nexusOperationAsTool`, `agentAsTool`, `statelessMcpServer`, `statefulMcpServer`, `temporalSandboxClient`, `workerEnvValue`, related option/definition types, `DEDICATED_WORKER_FAILURE_TYPE` and `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`. +`@temporalio/openai-agents/workflow` exports Workflow-safe APIs: `TemporalOpenAIRunner`, `WorkflowSafeMemorySession`, `activityAsTool`, `nexusOperationAsTool`, `agentAsTool`, `statelessMcpServer`, `statefulMcpServer`, `temporalSandboxClient`, `workerEnvValue`, related option/definition types, `DEDICATED_WORKER_FAILURE_TYPE`, `TOOL_FILTER_UNSUPPORTED_TYPE` and `TOOL_META_RESOLVER_UNSUPPORTED_TYPE`. `@temporalio/openai-agents/otel` exports `createTracerProvider`, `TemporalIdGenerator`, `markReplaySafeTracerProvider`, `isReplaySafeTracerProvider`, and `TemporalOpenAIAgentsTracerProviderOptions`. Install optional peer dependency `@opentelemetry/sdk-trace-base` only when you use this OTel integration. diff --git a/typescript/openai-agents/src/common/mcp-types.ts b/typescript/openai-agents/src/common/mcp-types.ts index 6162f01b..6729885a 100644 --- a/typescript/openai-agents/src/common/mcp-types.ts +++ b/typescript/openai-agents/src/common/mcp-types.ts @@ -35,7 +35,8 @@ export interface McpServerHandleOptions { * Builds each tool call's MCP `_meta` in the Workflow, as for a native MCP server. It gets the * run context and must be deterministic. Older plugin versions ignore this option and may not * replay a History in which it ran, because running it can change the Workflow's commands or - * their order. + * their order. Its result is recorded in Workflow History as part of the call-tool Activity + * input, so it must not hold secrets. */ toolMetaResolver?: MCPToolMetaResolver; } From 6161ecfbc6fc82b44f2ec271cbd193dce504cea2 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Mon, 2 Nov 2026 10:31:48 -0800 Subject: [PATCH 13/16] Refuse a tool_meta_resolver on the Python factory's server too The same decision covers both languages: the Python plugin silently ignored a tool_meta_resolver on the server a factory returns, so the server got no _meta. temporal_mcp_server, StatelessMCPServerProvider and StatefulMCPServerProvider now refuse one with a non-retryable error, as they do a callable tool_filter and a require_approval policy, and the stateful session never connects such a server. The README and docstrings say so. --- python/openai_agents/README.md | 21 ++++++++++------ .../src/temporalio/openai_agents/_mcp.py | 25 +++++++++++++------ .../temporalio/openai_agents/_mcp_backend.py | 2 ++ .../openai_agents/_mcp_server_checks.py | 22 ++++++++++++++++ .../src/temporalio/openai_agents/workflow.py | 5 ++-- python/openai_agents/tests/test_mcp_v2.py | 12 +++++++++ python/openai_agents/tests/test_openai.py | 11 ++++++-- 7 files changed, 79 insertions(+), 19 deletions(-) diff --git a/python/openai_agents/README.md b/python/openai_agents/README.md index e62322f5..b629bb99 100644 --- a/python/openai_agents/README.md +++ b/python/openai_agents/README.md @@ -447,8 +447,8 @@ worker-side OpenAI `MCPServer`. Configure workflow-facing behavior such as openai-agents 0.22.3, tool input and output guardrails on `temporal_mcp_server(...)`, where the OpenAI agent can use it. On the worker-side server, a static `tool_filter` still filters the listed tools, a -callable `tool_filter` and a `require_approval` policy are rejected as described -below, and the other settings are ignored. Custom `MCPServer` method +callable `tool_filter`, a `require_approval` policy and a `tool_meta_resolver` +are rejected as described below, and the other settings are ignored. Custom `MCPServer` method implementations still execute worker-side. A callable `tool_filter` on the worker-side server is rejected, because the run @@ -463,13 +463,20 @@ Agents SDK normalizes to no approval is allowed there: `"never"`, `False`, or a mapping or tool list with only `"never"` entries. A callable policy is always rejected, because it cannot be evaluated on the worker. +A `tool_meta_resolver` on the worker-side server is rejected too, because the +run context it receives exists only in the workflow. Pass it to +`temporal_mcp_server(tool_meta_resolver=...)` instead, where it runs in the +workflow and must be deterministic. Its result is recorded in workflow history, +so it must not hold secrets. + These checks run in every MCP Activity. A run that already holds its cached tool list, the default, first meets them when it calls a tool: the agent gets the error as the tool's output, and the run can complete without the tool. Let open runs finish before you upgrade or add such a setting to the factory's server. Moving the policy into `temporal_mcp_server(require_approval=...)` is also a workflow change: an open run that already called one of that server's tools -fails replay with a nondeterminism error. +fails replay with a nondeterminism error. Moving a resolver there is a workflow +change too, so guard it with `workflow.patched()` or let open runs finish first. Every MCP operation is a Temporal Activity. The workflow-side tool list is cached by default; pass `cache_tools_list=False` to refresh it on every Agents @@ -540,10 +547,10 @@ The legacy stateful path retains its dedicated per-workflow worker and persistent-session behavior. To require tool approval on these servers, pass `require_approval` to `stateless_mcp_server()` or `stateful_mcp_server()`; as with `temporal_mcp_server()`, a policy on the factory's server is rejected -unless it never asks for approval. So is a callable `tool_filter` there, in -every MCP operation, direct `call_tool()` and prompt calls included; a static -filter still works. Remove such a filter from the factory in the same deploy, -or let open runs finish first. +unless it never asks for approval. So are a callable `tool_filter` and a +`tool_meta_resolver` there, in every MCP operation, direct `call_tool()` and +prompt calls included; a static filter still works. Remove such a filter or +resolver from the factory in the same deploy, or let open runs finish first. ### Hosted MCP Tool diff --git a/python/openai_agents/src/temporalio/openai_agents/_mcp.py b/python/openai_agents/src/temporalio/openai_agents/_mcp.py index fac7c391..74b90047 100644 --- a/python/openai_agents/src/temporalio/openai_agents/_mcp.py +++ b/python/openai_agents/src/temporalio/openai_agents/_mcp.py @@ -29,8 +29,10 @@ from temporalio.openai_agents._mcp_server_checks import ( _has_dynamic_tool_filter, _has_worker_side_require_approval, + _has_worker_side_tool_meta_resolver, _reject_dynamic_tool_filter, _reject_worker_side_require_approval, + _reject_worker_side_tool_meta_resolver, ) from temporalio.worker import PollerBehaviorSimpleMaximum, Worker from temporalio.workflow import ActivityConfig, ActivityHandle @@ -173,9 +175,9 @@ def __init__( name: The name of the MCP server. server_factory: A function which will produce MCPServer instances. It should return a new server each time so that state is not shared between workflow runs. It may accept a single positional parameter, which - receives a ``factory_argument`` from the workflow. A callable ``tool_filter`` or a ``require_approval`` - policy that can ask for approval on the returned server is rejected in every MCP operation; a static - filter is applied. + receives a ``factory_argument`` from the workflow. A callable ``tool_filter``, a ``require_approval`` + policy that can ask for approval, or a ``tool_meta_resolver`` on the returned server is rejected in + every MCP operation; a static filter is applied. """ warnings.warn( "StatelessMCPServerProvider is deprecated; use " @@ -207,6 +209,7 @@ def _create_server(self, factory_argument: Any | None) -> MCPServer: _reject_worker_side_require_approval( self._server_name, server, "stateless_mcp_server" ) + _reject_worker_side_tool_meta_resolver(self._server_name, server) return server @property @@ -481,8 +484,9 @@ def __init__( name: The name of the MCP server. server_factory: A function which will produce MCPServer instances. It should return a new server each time so that state is not shared between workflow runs. It receives an optional ``factory_argument`` from the - workflow. A callable ``tool_filter`` or a ``require_approval`` policy that can ask for approval on the - returned server is rejected in every MCP operation; a static filter is applied. + workflow. A callable ``tool_filter``, a ``require_approval`` policy that can ask for approval, or a + ``tool_meta_resolver`` on the returned server is rejected in every MCP operation; a static filter is + applied. """ warnings.warn( "StatefulMCPServerProvider is deprecated; use " @@ -515,6 +519,7 @@ def _server() -> MCPServer: _reject_worker_side_require_approval( self._server_name, server, "stateful_mcp_server" ) + _reject_worker_side_tool_meta_resolver(self._server_name, server) return server @activity.defn(name=self.name + "-list-tools") @@ -556,13 +561,17 @@ async def connect( "Cannot connect to an already running server. Use a distinct name if running multiple servers in one workflow." ) server = self._server_factory(args.factory_argument if args else None) - # A server with a callable tool filter or a worker-side approval - # policy is never connected. The dedicated worker still runs, and + # A server with a callable tool filter, a worker-side approval + # policy or a tool_meta_resolver is never connected. The dedicated worker still runs, and # each of its operations fails with the same error, so the workflow # sees that error at its first MCP call whatever its # schedule-to-start timeout. has_filter = _has_dynamic_tool_filter(server) - rejected = has_filter or _has_worker_side_require_approval(server) + rejected = ( + has_filter + or _has_worker_side_require_approval(server) + or _has_worker_side_tool_meta_resolver(server) + ) self._servers[server_id] = server try: try: diff --git a/python/openai_agents/src/temporalio/openai_agents/_mcp_backend.py b/python/openai_agents/src/temporalio/openai_agents/_mcp_backend.py index b864d7bb..11131990 100644 --- a/python/openai_agents/src/temporalio/openai_agents/_mcp_backend.py +++ b/python/openai_agents/src/temporalio/openai_agents/_mcp_backend.py @@ -29,6 +29,7 @@ from temporalio.openai_agents._mcp_server_checks import ( _reject_dynamic_tool_filter, _reject_worker_side_require_approval, + _reject_worker_side_tool_meta_resolver, ) _MCPServerFactory = Callable[[], MCPServer] | Callable[[Any], MCPServer] @@ -162,6 +163,7 @@ def create(argument: Any = _NOT_SUPPLIED) -> _OpenAIMCPServerBackend: server = cast(MCPServer, invoke(argument)) _reject_dynamic_tool_filter(name, server) _reject_worker_side_require_approval(name, server, "temporal_mcp_server") + _reject_worker_side_tool_meta_resolver(name, server) return _OpenAIMCPServerBackend(server) return create diff --git a/python/openai_agents/src/temporalio/openai_agents/_mcp_server_checks.py b/python/openai_agents/src/temporalio/openai_agents/_mcp_server_checks.py index a15815df..f3f31a15 100644 --- a/python/openai_agents/src/temporalio/openai_agents/_mcp_server_checks.py +++ b/python/openai_agents/src/temporalio/openai_agents/_mcp_server_checks.py @@ -71,6 +71,28 @@ def _has_worker_side_require_approval(server: MCPServer) -> bool: return callable(policy) or bool(policy) or overridden +def _has_worker_side_tool_meta_resolver(server: MCPServer) -> bool: + """Return whether the server sets a ``tool_meta_resolver``.""" + return getattr(server, "tool_meta_resolver", None) is not None + + +def _reject_worker_side_tool_meta_resolver(name: str, server: MCPServer) -> None: + """Raise when the factory's server has a ``tool_meta_resolver``; see the message. + + The resolver needs the run context, which exists only in the workflow, so the + worker cannot run it, and the agent never reads the factory's copy. + """ + if _has_worker_side_tool_meta_resolver(server): + raise ApplicationError( + f"MCP server {name!r} sets tool_meta_resolver on its worker-side " + "MCPServer, which cannot run there because the run context it " + "receives exists only in the workflow; pass it to " + "temporal_mcp_server(tool_meta_resolver=...) and remove it from the " + "factory's server", + non_retryable=True, + ) + + def _reject_worker_side_require_approval( name: str, server: MCPServer, route: str ) -> None: diff --git a/python/openai_agents/src/temporalio/openai_agents/workflow.py b/python/openai_agents/src/temporalio/openai_agents/workflow.py index dfc4d4ac..f88ec755 100644 --- a/python/openai_agents/src/temporalio/openai_agents/workflow.py +++ b/python/openai_agents/src/temporalio/openai_agents/workflow.py @@ -508,8 +508,9 @@ def temporal_mcp_server( of using the worker connection cache. server_options: OpenAI Agents ``MCPServer`` options such as ``require_approval`` and ``failure_error_function``. Set them here: - on the factory's server, a callable ``tool_filter`` and a - ``require_approval`` policy that can ask for approval are rejected. + on the factory's server, a callable ``tool_filter``, a + ``require_approval`` policy that can ask for approval and a + ``tool_meta_resolver`` are rejected. """ try: from temporalio.mcp import TemporalMCPClient diff --git a/python/openai_agents/tests/test_mcp_v2.py b/python/openai_agents/tests/test_mcp_v2.py index 00f738e7..19d76c6b 100644 --- a/python/openai_agents/tests/test_mcp_v2.py +++ b/python/openai_agents/tests/test_mcp_v2.py @@ -233,6 +233,18 @@ def factory() -> AgentsMCPServer: assert "temporal_mcp_server()" in str(err.value) +def test_worker_side_tool_meta_resolver_is_rejected() -> None: + def factory() -> AgentsMCPServer: + value = InProcessMCPServer(server()) + value.tool_meta_resolver = lambda context: {"tenant": "t-1"} # type: ignore[attr-defined] + return value + + with pytest.raises(ApplicationError) as err: + create_backend(factory) + assert err.value.non_retryable + assert "temporal_mcp_server(tool_meta_resolver=...)" in str(err.value) + + def test_static_worker_side_tool_filter_is_allowed() -> None: def factory() -> AgentsMCPServer: value = InProcessMCPServer(server()) diff --git a/python/openai_agents/tests/test_openai.py b/python/openai_agents/tests/test_openai.py index bc25aa93..7e2cc971 100644 --- a/python/openai_agents/tests/test_openai.py +++ b/python/openai_agents/tests/test_openai.py @@ -3080,7 +3080,8 @@ def factory(args: Any | None) -> MCPServer: @pytest.mark.parametrize( - "setting", ["require_approval", "instance_evaluator", "tool_filter"] + "setting", + ["require_approval", "instance_evaluator", "tool_filter", "tool_meta_resolver"], ) @pytest.mark.parametrize("stateful", [True, False]) async def test_mcp_server_factory_settings_are_rejected( @@ -3094,6 +3095,9 @@ async def test_mcp_server_factory_settings_are_rejected( tracking_server._get_needs_approval_for_tool = ( # type: ignore[method-assign] lambda tool, agent: True # type: ignore[reportUnknownLambdaType] ) + elif setting == "tool_meta_resolver": + tracking_server = get_tracking_server("HelloServer") + tracking_server.tool_meta_resolver = lambda context: {"tenant": "t-1"} # type: ignore[attr-defined] else: tracking_server = get_tracking_server("HelloServer") tracking_server.tool_filter = lambda context, tool: True # type: ignore[attr-defined] @@ -3138,7 +3142,10 @@ async def test_mcp_server_factory_settings_are_rejected( assert isinstance(err.value.cause.cause, ApplicationError) assert err.value.cause.cause.non_retryable message = err.value.cause.cause.message - if setting != "tool_filter": + if setting == "tool_meta_resolver": + assert "'HelloServer' sets tool_meta_resolver" in message + assert "temporal_mcp_server(tool_meta_resolver=...)" in message + elif setting != "tool_filter": route = "stateful_mcp_server" if stateful else "stateless_mcp_server" assert "'HelloServer' sets require_approval" in message assert f"{route}(require_approval=...)" in message From b2f2d761d5cf78ab85fa4dd8b922e0b391b0e199 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Mon, 2 Nov 2026 13:11:31 -0800 Subject: [PATCH 14/16] Point each Python route's resolver refusal at a remedy it can reach The deprecated providers sent users to temporal_mcp_server(), which their route can't reach. Their refusal now says to set tool_meta_resolver on the server stateless_mcp_server() or stateful_mcp_server() gives the workflow, or to move to OpenAIAgentsPlugin(mcp_servers=...) and temporal_mcp_server(tool_meta_resolver=...). The README says the same, and that removing a factory-side resolver in the same deploy is enough. The plugin's docstrings name the resolver, and a new test checks that temporal_mcp_server(tool_meta_resolver=...) sends _meta. --- python/openai_agents/README.md | 6 ++- .../src/temporalio/openai_agents/_mcp.py | 21 +++++++- .../openai_agents/_mcp_server_checks.py | 14 +++-- .../openai_agents/_temporal_openai_agents.py | 12 ++--- python/openai_agents/tests/test_mcp_v2.py | 54 +++++++++++++++++++ python/openai_agents/tests/test_openai.py | 3 +- 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/python/openai_agents/README.md b/python/openai_agents/README.md index b629bb99..38dde51e 100644 --- a/python/openai_agents/README.md +++ b/python/openai_agents/README.md @@ -473,6 +473,8 @@ These checks run in every MCP Activity. A run that already holds its cached tool list, the default, first meets them when it calls a tool: the agent gets the error as the tool's output, and the run can complete without the tool. Let open runs finish before you upgrade or add such a setting to the factory's server. +A resolver there never had an effect, so removing it from the factory's server +in the same deploy is enough. Moving the policy into `temporal_mcp_server(require_approval=...)` is also a workflow change: an open run that already called one of that server's tools fails replay with a nondeterminism error. Moving a resolver there is a workflow @@ -549,7 +551,9 @@ persistent-session behavior. To require tool approval on these servers, pass as with `temporal_mcp_server()`, a policy on the factory's server is rejected unless it never asks for approval. So are a callable `tool_filter` and a `tool_meta_resolver` there, in every MCP operation, direct `call_tool()` and -prompt calls included; a static filter still works. Remove such a filter or +prompt calls included; a static filter still works. On these routes, set a +resolver on the server that `stateless_mcp_server()` or `stateful_mcp_server()` +gives the workflow (`server.tool_meta_resolver = ...`). Remove such a filter or resolver from the factory in the same deploy, or let open runs finish first. ### Hosted MCP Tool diff --git a/python/openai_agents/src/temporalio/openai_agents/_mcp.py b/python/openai_agents/src/temporalio/openai_agents/_mcp.py index 74b90047..17ab3299 100644 --- a/python/openai_agents/src/temporalio/openai_agents/_mcp.py +++ b/python/openai_agents/src/temporalio/openai_agents/_mcp.py @@ -45,6 +45,15 @@ ) +def _legacy_tool_meta_resolver_remedy(route: str) -> str: + """What the deprecated routes' tool_meta_resolver error tells the user to do.""" + return ( + f"set tool_meta_resolver on the server {route}() gives the workflow, or " + "register the factory with OpenAIAgentsPlugin(mcp_servers=...) and pass " + "it to temporal_mcp_server(tool_meta_resolver=...)" + ) + + @dataclasses.dataclass class _StatelessListToolsArguments: factory_argument: Any | None @@ -209,7 +218,11 @@ def _create_server(self, factory_argument: Any | None) -> MCPServer: _reject_worker_side_require_approval( self._server_name, server, "stateless_mcp_server" ) - _reject_worker_side_tool_meta_resolver(self._server_name, server) + _reject_worker_side_tool_meta_resolver( + self._server_name, + server, + _legacy_tool_meta_resolver_remedy("stateless_mcp_server"), + ) return server @property @@ -519,7 +532,11 @@ def _server() -> MCPServer: _reject_worker_side_require_approval( self._server_name, server, "stateful_mcp_server" ) - _reject_worker_side_tool_meta_resolver(self._server_name, server) + _reject_worker_side_tool_meta_resolver( + self._server_name, + server, + _legacy_tool_meta_resolver_remedy("stateful_mcp_server"), + ) return server @activity.defn(name=self.name + "-list-tools") diff --git a/python/openai_agents/src/temporalio/openai_agents/_mcp_server_checks.py b/python/openai_agents/src/temporalio/openai_agents/_mcp_server_checks.py index f3f31a15..4c52162a 100644 --- a/python/openai_agents/src/temporalio/openai_agents/_mcp_server_checks.py +++ b/python/openai_agents/src/temporalio/openai_agents/_mcp_server_checks.py @@ -76,19 +76,23 @@ def _has_worker_side_tool_meta_resolver(server: MCPServer) -> bool: return getattr(server, "tool_meta_resolver", None) is not None -def _reject_worker_side_tool_meta_resolver(name: str, server: MCPServer) -> None: +def _reject_worker_side_tool_meta_resolver( + name: str, + server: MCPServer, + remedy: str = "pass it to temporal_mcp_server(tool_meta_resolver=...)", +) -> None: """Raise when the factory's server has a ``tool_meta_resolver``; see the message. The resolver needs the run context, which exists only in the workflow, so the - worker cannot run it, and the agent never reads the factory's copy. + worker cannot run it, and the agent never reads the factory's copy. ``remedy`` + says where to set it instead. """ if _has_worker_side_tool_meta_resolver(server): raise ApplicationError( f"MCP server {name!r} sets tool_meta_resolver on its worker-side " "MCPServer, which cannot run there because the run context it " - "receives exists only in the workflow; pass it to " - "temporal_mcp_server(tool_meta_resolver=...) and remove it from the " - "factory's server", + f"receives exists only in the workflow; {remedy}, and remove it from " + "the factory's server", non_retryable=True, ) diff --git a/python/openai_agents/src/temporalio/openai_agents/_temporal_openai_agents.py b/python/openai_agents/src/temporalio/openai_agents/_temporal_openai_agents.py index b8803a58..63441cbf 100644 --- a/python/openai_agents/src/temporalio/openai_agents/_temporal_openai_agents.py +++ b/python/openai_agents/src/temporalio/openai_agents/_temporal_openai_agents.py @@ -372,9 +372,9 @@ def __init__( mcp_server_providers: Legacy MCP server providers to register with the worker. Deprecated; use ``mcp_servers`` for new MCP v2 integrations. A provider's server with a callable - ``tool_filter`` or a ``require_approval`` policy that can ask for - approval is rejected in every MCP operation; a static filter is - applied. + ``tool_filter``, a ``require_approval`` policy that can ask for + approval, or a ``tool_meta_resolver`` is rejected in every MCP + operation; a static filter is applied. mcp_servers: Named OpenAI MCP server factories to register on the worker. Each factory may declare at most one positional parameter, which receives the workflow's ``factory_argument`` (``None`` when the @@ -382,9 +382,9 @@ def __init__( parameterless factory's modern connection is cached between Activities. A factory called with a workflow ``factory_argument`` always creates a fresh server for that Activity. A factory's server - with a callable ``tool_filter`` or a ``require_approval`` policy - that can ask for approval is rejected; pass those to - ``workflow.temporal_mcp_server()``. + with a callable ``tool_filter``, a ``require_approval`` policy + that can ask for approval, or a ``tool_meta_resolver`` is + rejected; pass those to ``workflow.temporal_mcp_server()``. mcp_connection_idle_timeout: How long an idle modern MCP connection remains cached. Defaults to five minutes. None disables idle eviction, and zero disables reuse. diff --git a/python/openai_agents/tests/test_mcp_v2.py b/python/openai_agents/tests/test_mcp_v2.py index 19d76c6b..90d224ad 100644 --- a/python/openai_agents/tests/test_mcp_v2.py +++ b/python/openai_agents/tests/test_mcp_v2.py @@ -75,6 +75,27 @@ async def run(self) -> str: return result.final_output +@workflow.defn +class MCPToolMetaWorkflow: + @workflow.run + async def run(self) -> str: + server = openai_agents.workflow.temporal_mcp_server( + "hello", + tool_meta_resolver=lambda context: { + "tenant": "t-7", + "tool": context.tool_name, + "server": context.server_name, + }, + ) + agent = Agent[str]( + name="MCP workflow", + instructions="Use the tools.", + mcp_servers=[server], + ) + result = await Runner.run(agent, "Say hello to Tom and Tim.") + return result.final_output + + def model() -> TestModel: return TestModel.returning_responses( [ @@ -214,6 +235,39 @@ def factory(argument: Any) -> AgentsMCPServer: assert arguments == [{"tenant": "acme"}] * 3 +async def test_temporal_mcp_server_sends_tool_meta(client: Client) -> None: + metas: list[Any] = [] + mcp_server = server() + + class RecordingServer(InProcessMCPServer): + async def call_tool( + self, + tool_name: str, + arguments: dict[str, Any] | None, + meta: dict[str, Any] | None = None, + ) -> CallToolResult: + metas.append(meta) + return await super().call_tool(tool_name, arguments, meta) + + async with AgentEnvironment( + model=model(), + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30) + ), + mcp_servers={"hello": lambda: RecordingServer(mcp_server)}, + ) as environment: + plugged_client = environment.applied_on_client(client) + async with new_worker(plugged_client, MCPToolMetaWorkflow) as worker: + result = await plugged_client.execute_workflow( + MCPToolMetaWorkflow.run, + id=f"mcp-v2-meta-{uuid4()}", + task_queue=worker.task_queue, + ) + + assert result == "Hi Tom and Tim!" + assert metas == [{"tenant": "t-7", "tool": "say_hello", "server": "hello"}] * 2 + + def create_backend(factory: Callable[[], AgentsMCPServer]) -> _OpenAIMCPServerBackend: return cast( Callable[[], _OpenAIMCPServerBackend], diff --git a/python/openai_agents/tests/test_openai.py b/python/openai_agents/tests/test_openai.py index 7e2cc971..876edee0 100644 --- a/python/openai_agents/tests/test_openai.py +++ b/python/openai_agents/tests/test_openai.py @@ -3143,8 +3143,9 @@ async def test_mcp_server_factory_settings_are_rejected( assert err.value.cause.cause.non_retryable message = err.value.cause.cause.message if setting == "tool_meta_resolver": + route = "stateful_mcp_server" if stateful else "stateless_mcp_server" assert "'HelloServer' sets tool_meta_resolver" in message - assert "temporal_mcp_server(tool_meta_resolver=...)" in message + assert f"on the server {route}() gives the workflow" in message elif setting != "tool_filter": route = "stateful_mcp_server" if stateful else "stateless_mcp_server" assert "'HelloServer' sets require_approval" in message From ca7595892dfe3d0aba8764fe328445425e0e115e Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Mon, 2 Nov 2026 13:11:31 -0800 Subject: [PATCH 15/16] README: resolver after a RunState resume, and which Workers to upgrade - A resolver also runs after a run resumes from a RunState and then sees the context rebuilt from JSON; if it throws, the model gets a tool error. - Upgrade every Worker that runs the call-tool Activities, including Workers on a taskQueue passed to statelessMcpServer(). --- typescript/openai-agents/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/typescript/openai-agents/README.md b/typescript/openai-agents/README.md index bfe5f10f..0e65dd11 100644 --- a/typescript/openai-agents/README.md +++ b/typescript/openai-agents/README.md @@ -539,11 +539,13 @@ const server = statelessMcpServer('tickets', { }); ``` +A resolver also runs for tool calls after a run resumes from a `RunState`, for example after a tool approval, and then sees the context rebuilt from JSON: plain objects, without class methods. If it throws there, the model gets a tool error and the server isn't called. + A new run with a `toolMetaResolver` on the factory's server fails at its first list-tools Activity. A `statelessMcpServer()` run that listed tools on an older Worker instead gets a tool error, which the model reads, for each call to that server, and the run keeps going. So remove the `toolMetaResolver` from the factory's server before you upgrade Workers. The `_meta` values are recorded in the call-tool Activity input in Workflow History, so don't put secrets in them. -A call-tool Activity that runs on a Worker with an older plugin version drops the `_meta`, and nothing fails. Upgrade every Worker on the Task Queue before Workflows rely on `_meta`, or use Worker Versioning. +A call-tool Activity that runs on a Worker with an older plugin version drops the `_meta`, and nothing fails. Upgrade every Worker that runs the call-tool Activities, including Workers on a `taskQueue` you pass to `statelessMcpServer()`, before Workflows rely on `_meta`, or use Worker Versioning. Plugin versions before this option ignore it and don't run its resolver. An older version can replay a History only if it produces the same Workflow commands in the same order, and a resolver passed as this option can change them even when it is synchronous and never fails: the agent awaits it, which can reorder the Activities of parallel tool calls, and its changes to the run context can change later commands. An older version fails such a History with a nondeterminism error (`TMPRL1100`). Before you roll Workers back to an older plugin version, let the Workflows that use this option finish. Workflows with no resolver, or with a resolver set on the handle itself (`server.toolMetaResolver = ...`), run the same resolver code on older versions, so this option doesn't change their commands. On a `statefulMcpServer()` handle, a resolver set this way gets the handle's internal name, `-stateful`, as `serverName`; the option gets ``. From 0a36feef240372f32a27f4025900cf62842d41c1 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Mon, 2 Nov 2026 19:35:09 -0800 Subject: [PATCH 16/16] README: the deprecated routes' resolver gets the suffixed server name A resolver set on the server stateless_mcp_server() or stateful_mcp_server() gives the workflow gets -stateless or -stateful as server_name. Also repeat for these routes what the v2 paragraph says: it runs in the workflow and must be deterministic, its result is recorded in history, and adding it to a workflow with open runs needs workflow.patched() or those runs to finish first. --- python/openai_agents/README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/python/openai_agents/README.md b/python/openai_agents/README.md index 38dde51e..8ba873fd 100644 --- a/python/openai_agents/README.md +++ b/python/openai_agents/README.md @@ -553,8 +553,13 @@ unless it never asks for approval. So are a callable `tool_filter` and a `tool_meta_resolver` there, in every MCP operation, direct `call_tool()` and prompt calls included; a static filter still works. On these routes, set a resolver on the server that `stateless_mcp_server()` or `stateful_mcp_server()` -gives the workflow (`server.tool_meta_resolver = ...`). Remove such a filter or -resolver from the factory in the same deploy, or let open runs finish first. +gives the workflow (`server.tool_meta_resolver = ...`); it then gets +`-stateless` or `-stateful` as `server_name`. As with +`temporal_mcp_server()`, it runs in the workflow and must be deterministic, its +result is recorded in workflow history, and adding it to a workflow with open +runs needs `workflow.patched()` or those runs to finish first. Remove such a +filter or resolver from the factory in the same deploy, or let open runs finish +first. ### Hosted MCP Tool