Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4fa6ed9
feat: demo setup
mattzcarey Oct 9, 2025
132f761
feat: rpc transport first draft
mattzcarey Oct 13, 2025
24ba379
fix: rpc tests
mattzcarey Oct 13, 2025
fd9d155
fix: remove unused rpc types
mattzcarey Oct 13, 2025
7aacc06
fix: wrangler
mattzcarey Oct 13, 2025
f66440c
add public
mattzcarey Oct 13, 2025
2cc44ff
feat: json rpc validation + batches
mattzcarey Oct 14, 2025
289e7b3
fix: naming of connection helper
mattzcarey Oct 14, 2025
00a20d9
fix: cleaner handleMcpMessage
mattzcarey Oct 14, 2025
5273dcd
fix: example for oauth2
mattzcarey Oct 14, 2025
3baaeaa
fix: using onStart directly is bad
mattzcarey Oct 16, 2025
dafa564
fix: rpc-transport example
mattzcarey Oct 16, 2025
6e404aa
feat: example readme
mattzcarey Oct 16, 2025
1a363a2
chore: rename example to mcp-rpc-transport
mattzcarey Oct 16, 2025
0ab36d8
feat: transport doc
mattzcarey Oct 16, 2025
3078318
more fiddling
mattzcarey Oct 16, 2025
46018dd
feat: overload the addMcpServer function
mattzcarey Oct 16, 2025
b5cf27b
feat: pass props
mattzcarey Oct 16, 2025
7268b17
add note about onStart
mattzcarey Oct 16, 2025
da55f6b
fix: mega typing
mattzcarey Oct 16, 2025
12dbb84
fix: probs nesting
mattzcarey Oct 16, 2025
67ad112
feat: restructure docs
mattzcarey Oct 16, 2025
0e9a151
fix: concurent sends
mattzcarey Oct 17, 2025
f7da517
fix: setting name after hibernation
mattzcarey Oct 17, 2025
cae6c3e
fix: types
mattzcarey Oct 17, 2025
1d18f41
feat: add validation to rpc binding
mattzcarey Oct 20, 2025
dacbce8
fix: lazy load props
mattzcarey Oct 21, 2025
0d563e2
chore: enable observability in mcp-rpc-transport demo config
mattzcarey Oct 21, 2025
b1245a8
refactor: normalize RPC server names
mattzcarey Oct 22, 2025
0b17fcb
Merge origin/main into rpc-transport
threepointone Feb 22, 2026
66f28dc
Refactor MCP RPC transport docs & example app
threepointone Feb 22, 2026
4ed6cae
Merge remote-tracking branch 'origin/main' into rpc-transport
threepointone Feb 22, 2026
120567a
Update package-lock.json
threepointone Feb 22, 2026
d9b6246
Add MCP RPC transport via Durable Objects
threepointone Feb 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/rpc-transport-rewrite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"agents": minor
---

Add RPC transport for MCP: connect an Agent to an McpAgent via Durable Object bindings

**New feature: `addMcpServer` with DO binding**

Agents can now connect to McpAgent instances in the same Worker using RPC transport — no HTTP, no network overhead. Pass the Durable Object namespace directly:

```typescript
// In your Agent
await this.addMcpServer("counter", env.MY_MCP);

// With props
await this.addMcpServer("counter", env.MY_MCP, {
props: { userId: "user-123", role: "admin" }
});
```

The `addMcpServer` method now accepts `string | DurableObjectNamespace` as the second parameter with proper TypeScript overloads, so HTTP and RPC paths are type-safe and cannot be mixed.

**Hibernation support**

RPC connections survive Durable Object hibernation automatically. The binding name and props are persisted to storage and restored on wake-up, matching the behavior of HTTP MCP connections. No need to manually re-establish connections in `onStart()`.

**Deduplication**

Calling `addMcpServer` with the same server name multiple times (e.g., across hibernation cycles) now returns the existing connection instead of creating duplicates. This applies to both RPC and HTTP connections. Connection IDs are stable across hibernation restore.

**Other changes**

- Rewrote `RPCClientTransport` to accept a `DurableObjectNamespace` and create the stub internally via `getServerByName` from partyserver, instead of requiring a pre-constructed stub
- Rewrote `RPCServerTransport` to drop session management (unnecessary for DO-scoped RPC) and use `JSONRPCMessageSchema` from the MCP SDK for validation instead of 170 lines of hand-written validation
- Removed `_resolveRpcBinding`, `_buildRpcTransportOptions`, `_buildHttpTransportOptions`, and `_connectToMcpServerInternal` from the Agent base class — RPC transport logic no longer leaks into `index.ts`
- Added `AddRpcMcpServerOptions` type (discriminated from `AddMcpServerOptions`) so `props` is only available when passing a binding
- Added `RPC_DO_PREFIX` constant used consistently across all RPC naming
- Fixed `MCPClientManager.callTool` passing `serverId` through to `conn.client.callTool` (it should be stripped before the call)
- Added `getRpcServersFromStorage()` and `saveRpcServerToStorage()` to `MCPClientManager` for hibernation persistence
- `restoreConnectionsFromStorage` now skips RPC servers (restored separately by the Agent class which has access to `env`)
- Reduced `rpc.ts` from 609 lines to 245 lines
- Reduced `types.ts` from 108 lines to 26 lines
- Updated `mcp-rpc-transport` example to use Workers AI (no API keys needed), Kumo/agents-ui components, and Tailwind CSS
- Updated MCP transports documentation
295 changes: 295 additions & 0 deletions docs/mcp-transports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
# MCP Transports

This guide explains the different transport options for connecting to MCP servers with the Agents SDK.

For a primer on MCP Servers and how they are implemented in the Agents SDK with `McpAgent`[here](docs/mcp-servers.md)

## Streamable HTTP Transport (Recommended)

The **Streamable HTTP** transport is the recommended way to connect to MCP servers.

### How it works

When a client connects to your MCP server:

1. The client makes an HTTP request to your Worker with a JSON-RPC message in the body
2. Your Worker upgrades the connection to a WebSocket
3. The WebSocket connects to your `McpAgent` Durable Object which manages connection state
4. JSON-RPC messages flow bidirectionally over the WebSocket
5. Your Worker streams responses back to the client using Server-Sent Events (SSE)

This is all handled automatically by the `McpAgent.serve()` method:

```typescript
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

export class MyMCP extends McpAgent {
server = new McpServer({ name: "Demo", version: "1.0.0" });

async init() {
// Define your tools, resources, prompts
}
}

// Serve with Streamable HTTP transport
export default MyMCP.serve("/mcp");
```

The `serve()` method returns a Worker with a `fetch` handler that:

- Handles CORS preflight requests
- Manages WebSocket upgrades
- Routes messages to your Durable Object

### Connection from clients

Clients connect using the `streamable-http` transport:

```typescript
await agent.addMcpServer("my-server", "https://your-worker.workers.dev/mcp");
```

## SSE Transport (Deprecated)

We also support the legacy **SSE (Server-Sent Events)** transport, but it's deprecated in favor of Streamable HTTP.

If you need SSE transport for compatibility:

```typescript
// Server
export default MyMCP.serveSSE("/sse");

// Client
await agent.addMcpServer("my-server", url, callbackHost);
```

## RPC Transport (Experimental)

The **RPC transport** is a custom transport designed for internal applications where your MCP server and agent are both running on Cloudflare. They can even run in the same Worker! It sends JSON-RPC messages directly over Cloudflare's RPC bindings without going over the public internet.

### Why use RPC transport?

- **Faster**: No network overhead - direct function calls
- **Simpler**: No HTTP endpoints, no connection management
- **Internal only**: Perfect for agents calling MCP servers within the same Worker

**Note**: RPC transport does not support authentication. Use HTTP/SSE for external connections that require OAuth.

### Connecting an Agent to an McpAgent via RPC

The RPC transport uses Durable Object bindings to connect your `Agent` (MCP client) directly to your `McpAgent` (MCP server).

#### Step 1: Define your MCP server

Create your `McpAgent` with the tools you want to expose:

```typescript
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

type State = { counter: number };

export class MyMCP extends McpAgent<Env, State> {
server = new McpServer({ name: "MyMCP", version: "1.0.0" });
initialState: State = { counter: 0 };

async init() {
this.server.tool(
"add",
"Add to the counter",
{ amount: z.number() },
async ({ amount }) => {
this.setState({ counter: this.state.counter + amount });
return {
content: [
{
type: "text",
text: `Added ${amount}, total is now ${this.state.counter}`
}
]
};
}
);
}
}
```

#### Step 2: Connect your Agent to the MCP server

In your `Agent`, call `addMcpServer()` with the Durable Object binding in `onStart()`:

```typescript
import { AIChatAgent } from "agents/ai-chat-agent";

export class Chat extends AIChatAgent<Env> {
async onStart(): Promise<void> {
// Pass the DO namespace binding directly
await this.addMcpServer("my-mcp", this.env.MyMCP);
}

async onChatMessage(onFinish) {
const allTools = this.mcp.getAITools();

const result = streamText({
model,
tools: allTools
// ...
});

return createUIMessageStreamResponse({ stream: result });
}
}
```

RPC connections are automatically restored after Durable Object hibernation, just like HTTP connections. The binding name and props are persisted to storage so the connection can be re-established without any extra code.

#### Step 3: Configure Durable Object bindings

In your `wrangler.jsonc`, define bindings for both Durable Objects:

```jsonc
{
"durable_objects": {
"bindings": [
{ "name": "Chat", "class_name": "Chat" },
{ "name": "MyMCP", "class_name": "MyMCP" }
]
},
"migrations": [
{
"new_sqlite_classes": ["MyMCP", "Chat"],
"tag": "v1"
}
]
}
```

#### Step 4: Set up your Worker fetch handler

Route requests to your Chat agent:

```typescript
import { routeAgentRequest } from "agents";

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);

// Optionally expose the MCP server via HTTP as well
if (url.pathname.startsWith("/mcp")) {
return MyMCP.serve("/mcp").fetch(request, env, ctx);
}

const response = await routeAgentRequest(request, env);
if (response) return response;

return new Response("Not found", { status: 404 });
}
};
```

### Passing props from client to server

Since RPC transport does not have an OAuth flow, you can pass user context (like userId, role, etc.) directly as props:

```typescript
await this.addMcpServer("my-mcp", this.env.MyMCP, {
props: { userId: "user-123", role: "admin" }
});
```

Your `McpAgent` can then access these props:

```typescript
export class MyMCP extends McpAgent<
Env,
State,
{ userId?: string; role?: string }
> {
async init() {
this.server.tool("whoami", "Get current user info", {}, async () => {
const userId = this.props?.userId || "anonymous";
const role = this.props?.role || "guest";

return {
content: [{ type: "text", text: `User ID: ${userId}, Role: ${role}` }]
};
});
}
}
```

The props are:

- **Type-safe**: TypeScript extracts the Props type from your McpAgent generic
- **Persistent**: Stored in Durable Object storage via `updateProps()`
- **Available immediately**: Set before any tool calls are made

This is useful for:

- User authentication context
- Tenant/organization IDs
- Feature flags or permissions
- Any per-connection configuration

### How RPC transport works under the hood

When you call `addMcpServer()` with a Durable Object binding, the SDK:

1. Creates an `RPCClientTransport` that wraps the DO stub
2. Calls `handleMcpMessage()` on the `McpAgent` for each JSON-RPC message
3. The `McpAgent` routes messages through its `RPCServerTransport` to the MCP server
4. Responses flow back synchronously through the RPC call

This happens entirely within your Worker's execution context using Cloudflare's RPC mechanism - no HTTP, no WebSockets, no public internet.

The RPC transport fully supports:

- JSON-RPC 2.0 validation (via the MCP SDK's schema)
- Batch requests
- Notifications (messages without `id` field)
- Automatic reconnection after Durable Object hibernation (when called from `onStart()`)

### Configuring RPC Transport Server Timeout

The RPC transport has a configurable timeout for waiting for tool responses. By default, the server will wait **60 seconds** for a tool handler to respond. You can customize this by overriding `getRpcTransportOptions()` in your `McpAgent`:

```typescript
export class MyMCP extends McpAgent<Env, State> {
server = new McpServer({ name: "MyMCP", version: "1.0.0" });

protected getRpcTransportOptions() {
return { timeout: 120000 }; // 2 minutes
}

async init() {
this.server.tool(
"long-running-task",
"A tool that takes a while",
{ input: z.string() },
async ({ input }) => {
await longRunningOperation(input);
return {
content: [{ type: "text", text: "Task completed" }]
};
}
);
}
}
```

## Choosing a transport

| Transport | Use when | Pros | Cons |
| ------------------- | ------------------------------------- | ---------------------------------------- | ------------------------------- |
| **Streamable HTTP** | External MCP servers, production apps | Standard protocol, secure, supports auth | Slight network overhead |
| **RPC** | Internal agents | Fastest, simplest setup | No auth, Service Bindings only |
| **SSE** | Legacy compatibility | Backwards compatible | Deprecated, use Streamable HTTP |

## Examples

- **Streamable HTTP**: See [`examples/mcp`](../examples/mcp)
- **RPC Transport**: See [`examples/mcp-rpc-transport`](../examples/mcp-rpc-transport)
- **MCP Client**: See [`examples/mcp-client`](../examples/mcp-client)
52 changes: 52 additions & 0 deletions examples/mcp-rpc-transport/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# MCP RPC Transport

An Agent calling an McpAgent within the same Worker using RPC transport -- no HTTP, no network overhead. Uses Workers AI so no API keys are needed.

## How to run

```bash
npm install && npm start
```

## What this demonstrates

The RPC transport connects an Agent to an McpAgent via Durable Object bindings. Both live in the same Worker. The Agent passes the DO namespace directly to `addMcpServer`:

```typescript
export class Chat extends AIChatAgent<Env> {
async onStart() {
await this.addMcpServer("my-mcp", this.env.MyMCP, {
props: { userId: "demo-user-123", role: "admin" }
});
}
}
```

The McpAgent defines tools that become available to the chat:

```typescript
export class MyMCP extends McpAgent<Env, State, Props> {
server = new McpServer({ name: "Demo", version: "1.0.0" });

async init() {
this.server.tool(
"add",
"Add to counter",
{ a: z.number() },
async ({ a }) => {
this.setState({ counter: this.state.counter + a });
return {
content: [{ type: "text", text: `Total: ${this.state.counter}` }]
};
}
);
}
}
```

Try asking the AI to add numbers to the counter or check who you are.

## Related

- [MCP Client](../mcp-client) -- connecting to remote MCP servers with OAuth
- [MCP Transports docs](../../docs/mcp-transports.md) -- all transport options
Loading
Loading