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
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:
- mirror
tryGetProperty's isProxyOfRpcTarget prototype walk in serializeJsValueWithPipeline's classification, or
- 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.
Summary
When an RPC method returns a
Proxywrapping anRpcTarget, promise-pipelined calls on the result fail withTypeError: 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, thenisInstanceOf<JsRpcTarget>()won't actually work, because the Proxy is not an instance of any native type" — and walks the prototype chain manually. ButserializeJsValueWithPipeline, which classifies a call's result for pipelining, still uses the raw brand checks: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 Proxygettrap —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.jswrangler.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 devandcurl 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:
tryGetProperty'sisProxyOfRpcTargetprototype walk inserializeJsValueWithPipeline's classification, orSingleStubconsumer already assertsexternals.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.