cloudflare / cloudflare/workerd

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)

Open
#6,873 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8.7k
Forks
739
Avg merge
2d 20h
Merged PRs (30d)
174

Description

> **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()` 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:

```c++
} else if (obj.isInstanceOf(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`
```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`
```jsonc
{
"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`:

```json
{
"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.

Contributor guide

Open the contributing guide

Research direction

Start in src/workerd/api/worker-rpc.c++ by comparing tryGetProperty's Proxy handling with serializeJsValueWithPipeline's result classification. Reproduce with worker.js, wrangler.json, npx wrangler dev, and curl; done means pipelined calls on both dynamic and empty-handler Proxy-wrapped RpcTargets behave like awaited calls without the missing-method error.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, javascript
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.