Skip to content

agentContext ALS not preserved in Worker-Loader-child→host RPC callbacks; server-initiated MCP requests throw "Agent was not found in send" #1490

@victor-bajanov

Description

@victor-bajanov

Summary

A DO-based McpAgent whose tools are dispatched through a Worker-Loader child isolate (e.g. @cloudflare/codemode) cannot perform server-initiated MCP requests (elicitInput, and presumably createMessage or listRoots) from a host-side callback the child invokes via RPC. StreamableHTTPServerTransport.send reads the current agent from __DO_NOT_USE_WILL_BREAK__agentContext (an AsyncLocalStorage), and that store is empty inside the callback because the child→host RPC arrives as a fresh entrypoint invocation with no ancestor frame in the original agentContext.run(...) call tree. The transport throws Error: Agent was not found in send. User-visible impact: any sandbox-based MCP tool that needs to elicit user consent, sample an LLM, or list roots fails with a generic transport error and the tool call returns no useful output to the client. The issue has been reproduced with elicitation, but based on my understanding the same issue should affect any situation where the Worker makes a request back to the MCP client as a result of an RPC from a Worker Loader child.

Affected versions

  • agents@^0.12.3 (verified failure on 0.12.3).
  • Workers runtime: any release that supports the worker_loaders binding and Dynamic Worker Loader RPC. Reproducible with current compatibility_date settings used together with @cloudflare/codemode@^0.3.4.

The bug is independent of MCP SDK version; it is rooted in how agentContext (an AsyncLocalStorage per Worker) interacts with cross-isolate RPC from Worker Loader children back into the host.

Reproduction

The failure can be reproduced with a minimal McpAgent that loads a child Worker via env.LOADER, hands the child a host-side callback, and has the callback call this.server.server.elicitInput(...). Pseudocode:

import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { RpcTarget } from "cloudflare:workers";

class HostCallbackBridge extends RpcTarget {
  constructor(private cb: () => Promise<unknown>) { super(); }
  async invoke() { return await this.cb(); }
}

export class ReproMCP extends McpAgent<{ LOADER: WorkerLoader }> {
  server: McpServer = new McpServer({ name: "repro", version: "0.0.1" });

  async init() {
    this.server.tool("repro_elicit", "trigger an elicit through a child callback", {}, async () => {
      const child = this.env.LOADER.get("repro-child", () => ({
        modules: [{ name: "main.js", esModule: CHILD_MODULE_SOURCE }],
      }));
      const stub = child.getEntrypoint();
      const bridge = new HostCallbackBridge(async () => {
        // This is the host-side callback the child will invoke via RPC.
        // At this point agentContext.getStore() returns undefined.
        return await this.server.server.elicitInput({
          message: "confirm?",
          requestedSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
        });
      });
      const result = await stub.run(bridge);
      return { content: [{ type: "text", text: JSON.stringify(result) }] };
    });
  }
}

export default { fetch: ReproMCP.serve("/mcp") };

with a child Worker that just calls the bridge:

import { WorkerEntrypoint } from "cloudflare:workers";
export default class extends WorkerEntrypoint {
  async run(bridge: { invoke(): Promise<unknown> }) { return await bridge.invoke(); }
}

Driving the agent over Streamable HTTP from any MCP client that supports elicitation capability (e.g. MCP Inspector) produces:

Error: Agent was not found in send
    at StreamableHTTPServerTransport.send (...)
    at ...elicitInput (...)
    at ...invoke (...)              ← our host-side callback (frame B)
    at ...run (...)                 ← codemode/raw RPC entrypoint in host isolate (frame A)
    at ToolDispatcher.call (...)

The two stack frames bracket the cross-isolate boundary. Frame A is the host-isolate RPC entrypoint that received the child's run(bridge) call; frame B is our host-side callback. By the time control reaches frame B, agentContext.getStore() is already undefined — there is no ancestor agentContext.run(...) frame on the call stack of this fresh entrypoint invocation.

The same pattern applies one level up in production: when @cloudflare/codemode's openApiMcpServer({ executor, request }) is in use, request is the host-side callback and the executor is a Worker Loader child that invokes it via RPC. The issue is easier to see in the simple code above, but it was actually encountered during development of a codemode MCP.

Runnable harness (attached)

elicit-als-context-minimal.tar.gz

elicit-als-context-minimal.tar.gz is attached. It's a self-contained Cloudflare Worker (no codemode etc) with one McpAgent, a Worker Loader child, and an RpcTarget host-side bridge that triggers the bug. Steps:

tar xzf elicit-als-context-minimal.tar.gz
cd elicit-als-context/minimal
pnpm install --ignore-workspace
node verify.mjs           # WRAP unset -> reproduces the ALS error
WRAP=1 node verify.mjs    # WRAP=1     -> wrap workaround succeeds

Each invocation exits 0 if the observed behavior matches the expectation for that toggle. expected-output-before.txt and expected-output-after.txt are committed inside the tarball for diffing.

The codemode-pattern tarball (elicit-als-context-codemode-pattern.tar.gz, also attached) exercises the same bug through the production-shaped openApiMcpServer({ executor, request }) pattern.

Root cause

Three relevant references in cloudflare/agents:

  1. The throw site:

    // packages/agents/src/mcp/transport.ts:307-308
    const { agent } = getCurrentAgent();
    if (!agent) throw new Error("Agent was not found in send");
  2. The ALS definition:

    // packages/agents/src/internal_context.ts
    export const agentContext = new AsyncLocalStorage<AgentContextStore>();

    exported as __DO_NOT_USE_WILL_BREAK__agentContext from the package root.

  3. The internal helper that does exactly the wrap pattern needed here, but is not exported:

    // packages/agents/src/index.ts:1040
    function withAgentContext<T>(store: AgentContextStore, fn: () => T): T {
      return agentContext.run(store, fn);
    }

Mechanism. agentContext is a Workers AsyncLocalStorage, bound to call-stack ancestry within a single V8 isolate. The agents runtime wraps every incoming agent invocation in agentContext.run({ agent, connection, request, email }, ...), so any code synchronously or asynchronously descended from that frame can recover the current agent via getCurrentAgent().

Worker Loader child→host RPC, however, arrives as a fresh entrypoint invocation in the host isolate — it does not inherit ALS from the caller (ALS is per-isolate; even within one isolate, RPC entrypoints don't inherit ALS from the calling frame). So when host-side code reached via that RPC calls getCurrentAgent(), the store is empty and transport.send throws.

The same shape applies in principle to any cross-isolate-RPC callback that needs to perform server-initiated MCP requests: Service bindings, DO RPC, Workflows entrypoints, etc.

Workaround

User-side wrap of the host callback in agentContext.run(...) using the unstable export:

import { __DO_NOT_USE_WILL_BREAK__agentContext as agentContext } from "agents";

// inside the McpAgent class:
const agent = this;
this.server = openApiMcpServer({
  // ...
  request: (ctx) =>
    agentContext.run(
      { agent, connection: undefined, request: undefined, email: undefined },
      () => handleRequest(ctx),
    ),
});

This works in production for the elicit path, but:

  • The symbol is named __DO_NOT_USE_WILL_BREAK__agentContext — I have reason to believe it will break ;)
  • We pass connection: undefined, request: undefined, email: undefined. The elicit code path doesn't appear to read those fields, but the contract isn't documented.
  • The wrap pattern duplicates withAgentContext, which already exists internally but is not exported.

Suggested library-side fix

Export withAgentContext (or an equivalent stable name) as public API on the agents package, with a documented contract:

Any host-side code reached via cross-isolate RPC (Worker Loader child→host, Service binding, DO RPC) that needs to perform server-initiated MCP requests must re-enter the agent context using withAgentContext({ agent }, () => ...) before calling into the MCP server.

The helper already exists, just needs a stable export and naming.

Open questions for maintainers

  1. Is the absence of ALS propagation across Worker-Loader-RPC callbacks intentional or a known gap? My reading: it's an infrastructure inheritance from the Workers runtime (ALS doesn't cross isolate boundaries, and RPC entrypoints don't inherit ALS even within an isolate) rather than a deliberate library policy. The presence of the internal withAgentContext helper suggests the same.

  2. If the gap is intentional, what confidentiality property does ALS-loss preserve that the user-side agentContext.run wrap would break? I couldn't think of one: the child isolate cannot read host-side AsyncLocalStorage regardless (ALS is per-isolate; RPC marshaling does not stub AsyncLocalStorage); the store contents are object handles to host-side instances, not data that crosses the boundary; re-entering ALS in a host-side callback exposes nothing additional to the child isolate.

  3. Is passing connection: undefined, request: undefined, email: undefined valid in the store, or does the library expect synthesized values for paths that don't read those fields? It seems to work, but I'm getting the strong sense of "no guarantees" from that symbol name ...

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingmcpIssues related to MCP functionality

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions