Skip to content

JS RPC: pipelined calls fail with "The RPC receiver does not implement the method" when a method returns a Proxy-wrapped RpcTarget (awaited stub works; path traversal already special-cases Proxies) #6873

Description

@jonastemplestein

Note: this issue was researched, reproduced, and written by an AI agent (Claude Code) operating the account of a human maintainer of our codebase, who reviewed it before filing. Happy to provide account/script details privately.

Summary

When an RPC method returns a Proxy wrapping an RpcTarget, promise-pipelined calls on the result fail with TypeError: The RPC receiver does not implement the method "…" — for every method name, including plain class methods through an empty-handler Proxy. Awaiting the stub first and then calling the identical method works, and property-path traversal through proxied objects works too.

The cause appears to be an inconsistency inside src/workerd/api/worker-rpc.c++: the METHOD_PATH traversal (tryGetProperty) has an explicit Proxy special case — "If the object is a Proxy, then isInstanceOf<JsRpcTarget>() won't actually work, because the Proxy is not an instance of any native type" — and walks the prototype chain manually. But serializeJsValueWithPipeline, which classifies a call's result for pipelining, still uses the raw brand checks:

} else if (obj.isInstanceOf<JsRpcTarget>(js)) {   // a Proxy never passes this
  return MakeCallPipeline::SingleStub();
} ...
} else {
  return MakeCallPipeline::NonPipelinable{...};
}

A proxied RpcTarget falls through to NonPipelinable, whose error pipeline wraps an empty object — so every pipelined name fails the own-property lookup, producing the misleading "does not implement the method" error. The actual result serialization stub-ifies the same value correctly, which is why the awaited form works.

Our use case: an MCP-style client whose tool names are only known at runtime (discovered via tools/list), so unknown members dispatch dynamically through a Proxy get trap — api.getClient().searchDocs(args). That is also the most natural way to write the call, and the way LLM-generated code writes it every time; the workaround (await the stub before each call) works but is invisible from the error.

Reproduction (verified as written, wrangler 4.110.0 / wrangler dev)

Two files in an empty directory:

worker.js

import { WorkerEntrypoint, RpcTarget } from "cloudflare:workers";

// An MCP-style client: tool names are only known at RUNTIME (a real client
// discovers them via tools/list), so unknown members must dispatch
// dynamically — the textbook reason to wrap an RpcTarget in a Proxy.
class McpClient extends RpcTarget {
  callTool(name, args) {
    return { called: name, args };
  }
}

function withDynamicTools(client) {
  return new Proxy(client, {
    get(target, key) {
      if (key === "then") return undefined;
      if (typeof key === "symbol" || key in target) {
        const value = Reflect.get(target, key, target);
        return typeof value === "function" ? value.bind(target) : value;
      }
      return (args) => target.callTool(key, args);
    },
  });
}

export class Api extends WorkerEntrypoint {
  getClient() {
    return withDynamicTools(new McpClient());
  }
  getPlainProxyClient() {
    // Even an empty-handler Proxy reproduces the failure.
    return new Proxy(new McpClient(), {});
  }
}

export default {
  async fetch(req, env) {
    const out = {};

    // ✅ awaited stub: both class methods and dynamic tool names work.
    const client = await env.API.getClient();
    out.awaited_dynamic = await client.searchDocs({ q: "hello" });
    out.awaited_class_method = await client.callTool("direct", {});

    // ❌ pipelined on the un-awaited call result: fails.
    try {
      out.pipelined_dynamic = await env.API.getClient().searchDocs({ q: "hello" });
    } catch (e) {
      out.pipelined_dynamic_error = String(e);
    }

    // ❌ even a CLASS method through an EMPTY-handler Proxy fails pipelined.
    try {
      out.pipelined_empty_proxy = await env.API.getPlainProxyClient().callTool("direct", {});
    } catch (e) {
      out.pipelined_empty_proxy_error = String(e);
    }

    return Response.json(out);
  },
};

wrangler.json

{
  "name": "proxy-pipelining-repro",
  "main": "worker.js",
  "compatibility_date": "2026-07-01",
  "services": [{ "binding": "API", "service": "proxy-pipelining-repro", "entrypoint": "Api" }]
}

Run npx wrangler dev and curl localhost:8787:

{
    "awaited_dynamic": { "called": "searchDocs", "args": { "q": "hello" } },
    "awaited_class_method": { "called": "direct", "args": {} },
    "pipelined_dynamic_error": "TypeError: The RPC receiver does not implement the method \"searchDocs\".",
    "pipelined_empty_proxy_error": "TypeError: The RPC receiver does not implement the method \"callTool\"."
}

We first hit this in production (Cloudflare-hosted workerd, 2026-07-10), so it is not local-dev-only.

Expected

Pipelined calls on a returned Proxy-wrapped RpcTarget should behave like calls on the awaited stub, matching the traversal path's existing Proxy support. Either:

  1. mirror tryGetProperty's isProxyOfRpcTarget prototype walk in serializeJsValueWithPipeline's classification, or
  2. classify from the serialization outcome — the SingleStub consumer already asserts externals.size() == 1 && external.isRpcTarget(), so when serialization proves the whole value is a single RpcTarget external, trust it.

Failing either, a clearer error ("the pipelined parent resolved to a value that does not support pipelining") would save the next person a long debugging session — the current message says a method is missing from an object that has it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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