Skip to content

[Bug] openai-agents (TypeScript): MCP tool calls get no _meta from a toolMetaResolver #208

Open

Description

@campoy

What happens

The OpenAI Agents SDK's MCP guide lists toolMetaResolver as an option of MCPServerStreamableHttp and MCPServerStdio: "Inject per-call MCP _meta request fields." The framework's MCP function tool calls the resolver with the run context and passes its result to callTool(name, args, meta).

Through the plugin's MCP servers (@temporalio/openai-agents), the server gets no _meta:

  • The Workflow-side handles from statelessMcpServer() and statefulMcpServer() take callTool(toolName, args) and drop the third argument. The call-tool Activity input holds only the tool name and arguments (and the factoryArgument).
  • The call-tool Activities call callTool(toolName, args) on the factory's server directly. That skips the framework's MCP function tool, the only caller of toolMetaResolver, so a resolver on the factory's server never runs. It would have no run context there anyway.

Nothing fails or warns: the server runs the call without the metadata.

Reproduction

One file. It needs a dev server (temporal server start-dev) and writes its Workflow file next to itself. The server's callTool records the _meta it gets. The script exits 0 when the server gets no _meta on the plugin route.

// issue_repro.js: MCP tool calls through the plugin's stateless MCP server get no `_meta` from a toolMetaResolver.
// Needs a Temporal dev server (`temporal server start-dev`). Usage: node issue_repro.js [address]
// The script writes its Workflow file next to itself. Exits 0 when the server gets no `_meta` on the plugin route.
const fs = require('node:fs');
const path = require('node:path');
const { Agent, Runner, Usage } = require('@openai/agents-core');
const { Client, Connection } = require('@temporalio/client');
const { NativeConnection, Runtime, DefaultLogger, Worker } = require('@temporalio/worker');
const { OpenAIAgentsPlugin, StatelessMCPServerProvider } = require('@temporalio/openai-agents');

const workflowsPath = path.join(__dirname, 'issue_repro_workflows.js');
fs.writeFileSync(
  workflowsPath,
  `const { Agent } = require('@openai/agents-core');
const { TemporalOpenAIRunner, statelessMcpServer } = require('@temporalio/openai-agents/workflow');
exports.mcpAgent = async (setResolverInWorkflow) => {
  const server = statelessMcpServer('tickets');
  if (setResolverInWorkflow) server.toolMetaResolver = ({ runContext }) => ({ tenant: runContext.context.tenant });
  const agent = new Agent({ name: 'tickets', instructions: 'Use the tool.', model: 'fake', mcpServers: [server] });
  return (await new TemporalOpenAIRunner().run(agent, 'go', { context: { tenant: 't-1' } })).finalOutput;
};
`
);

// An MCP server with a toolMetaResolver, as the framework's MCP guide shows. callTool records its `_meta`.
const metas = [];
const schema = { type: 'object', properties: {}, required: [], additionalProperties: false };
const server = () => ({
  name: 'tickets',
  cacheToolsList: false,
  toolMetaResolver: ({ runContext }) => ({ tenant: runContext.context.tenant }),
  async connect() {},
  async close() {},
  async invalidateToolsCache() {},
  async listTools() {
    return [{ name: 'echo_meta', description: 'Echo the request _meta.', inputSchema: schema }];
  },
  async callTool(_name, _args, meta) {
    metas.push(meta ?? null);
    return [{ type: 'text', text: 'ok' }];
  },
});

// A fake model: calls echo_meta once, then answers.
const model = {
  async getResponse(request) {
    if (request.input.some?.((item) => item.type === 'function_call_result')) {
      return {
        usage: new Usage(),
        output: [{ type: 'message', id: 'm', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'done' }] }],
      };
    }
    return {
      usage: new Usage(),
      output: [{ type: 'function_call', id: 'fc', callId: 'call', name: 'echo_meta', arguments: '{}', status: 'completed' }],
    };
  },
  async *getStreamedResponse() {
    throw new Error('not used');
  },
};

async function main() {
  const address = process.argv[2] ?? '127.0.0.1:7233';
  Runtime.install({ logger: new DefaultLogger('ERROR') });

  // Native control: the same server and model with the framework's Runner.
  const agent = new Agent({ name: 'tickets', instructions: 'Use the tool.', model: 'fake', mcpServers: [server()] });
  await new Runner({ modelProvider: { getModel: () => model }, tracingDisabled: true }).run(agent, 'go', {
    context: { tenant: 't-native' },
  });
  console.log(`native: server got _meta ${JSON.stringify(metas[0])}`);

  // Plugin: the same server from a StatelessMCPServerProvider factory.
  const plugin = new OpenAIAgentsPlugin({
    modelProvider: { getModel: () => model },
    mcpServerProviders: [new StatelessMCPServerProvider('tickets', server)],
  });
  const taskQueue = `mcp-meta-${Date.now()}`;
  const worker = await Worker.create({
    connection: await NativeConnection.connect({ address }),
    taskQueue,
    workflowsPath,
    plugins: [plugin],
  });
  const client = new Client({ connection: await Connection.connect({ address }), plugins: [plugin] });
  await worker.runUntil(async () => {
    for (const inWorkflow of [false, true]) {
      await client.workflow.execute('mcpAgent', { taskQueue, workflowId: `${taskQueue}-${inWorkflow}`, args: [inWorkflow] });
      const where = inWorkflow ? 'resolver also set on the Workflow-side server' : 'resolver on the factory server';
      console.log(`plugin (${where}): server got _meta ${JSON.stringify(metas.at(-1))}`);
    }
  });
  return metas.length === 3 && metas[1] === null && metas[2] === null;
}

main().then(
  (bug) => process.exit(bug ? 0 : 1),
  (err) => {
    console.error(err);
    process.exit(2);
  }
);

With @temporalio/openai-agents 1.24.0 (npm, the latest release) and with the package built from this repository's main (c664a34), both with @temporalio/* 1.24.0, @openai/agents-core 0.14.3, Node v24.22.0 and Temporal CLI 1.9.1:

$ node issue_repro.js
native: server got _meta {"tenant":"t-native"}
plugin (resolver on the factory server): server got _meta null
plugin (resolver also set on the Workflow-side server): server got _meta null
exit=0

Both Workflows complete with no failed Activity, and the call-tool Activity input in History is {"toolName":"echo_meta","args":{}}. A stateful server behaves the same way.

Expected

The server gets the _meta the resolver builds, as with the native server.

Impact

A server that uses _meta for tenant, user or tracing data runs the call without it, so the tool result can change silently. Only servers with a toolMetaResolver are affected. Values that are fixed per handle can go in factoryArgument instead; it is recorded in History.

Proposed fix

  • The handles' callTool puts the meta argument in the call-tool Activity input, only when there is one, so the input of other calls doesn't change. The Activities pass it to the server's callTool.
  • Add a toolMetaResolver option to statelessMcpServer() and statefulMcpServer() that the agent runs in the Workflow with the run context, as it does natively. It must be deterministic. A resolver on the factory's server can't get the run context, so the Worker keeps skipping it, now with a warning; code must move it to the new option.

The _meta values are then recorded in the call-tool Activity input in History, so they must not hold secrets. Open runs replay the same: Activity inputs are not compared on replay.

Related: #207, the same kind of gap for toolFilter. A PR follows.

Activity

  1. DABH commented on Oct 30, 2026

    @DABH
    Collaborator

    Thanks for the detailed report and the reproduction. Confirmed on main: both handles send the call-tool Activity only { toolName, args }, so the third callTool argument the agent builds from toolMetaResolver never leaves the Workflow, and the Activities call the factory server's callTool(name, args) directly, so a resolver set there has nowhere to run and no run context to run with. Nothing fails; the server just gets no _meta. The Python plugin already forwards meta through its call-tool Activity input, so this is a TypeScript-only gap.

    #210 is the right shape for the fix and I've verified it locally: the handles forward meta in the Activity input only when the resolver produced one, the Activities pass it to the server, and both handle functions take a toolMetaResolver option that the agent runs in the Workflow with the run context. With that change the factory server receives the resolved _meta from both the stateless and the stateful handle. It is additive: no Activity names or existing input shapes change, Histories recorded today replay on the new code, and nothing changes for servers without a resolver.

    Two things to keep in mind once this lands. First, _meta becomes part of the call-tool Activity input, so it is recorded in Workflow History; keep secrets out of it, same as factoryArgument. Second, the resolver runs inside the Workflow sandbox and must be deterministic, and once a Workflow uses the option, rolling Workers back to a plugin version that ignores it is not guaranteed to replay (the agent awaits the resolver, which can change the order in which parallel tool-call Activities are scheduled), so let those Workflows finish before rolling back.

    On the open question in #210, I'd keep the warning for a resolver left on the factory's server so this stays a non-breaking fix, and handle the whole family of options on the factory's server that the Worker can't honor (this one, the callable toolFilter from #207) in one follow-up that fails fast, marked as a breaking change, in line with what #275 does on the Python side.

    Until the fix ships, a per-Workflow static _meta works today through factoryArgument, which is also recorded in History: pass { factoryArgument: { tenant } } to the handle and have the factory return a server that merges it into every call, for example a small subclass of MCPServerStreamableHttp whose callTool(name, args, meta, options) calls super.callTool(name, args, { tenant, ...meta }, options). Per-call values that depend on the tool, its arguments or the run context need the fix.

    Leaving this open until #210 merges. #209 and #242 touch the same files, so whichever lands second will need a rebase with conflict resolution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions