Skip to content

Commit f7bd395

Browse files
deathbyknowledgewhoiskatrinthreepointone
authored
Make McpAgent extend Agent + Streaming HTTP protocol features (#415)
* make McpAgent extend Agent * route with getAgentByName * implement MCP server req/notifications + tool notifications * add tests for standalone sse with streaming http * rename standalone method to better match * fix format * usse base Agent onError * remove DO overrides * restore connectionId of standalone sse on hibernation * Create perfect-feet-breathe.md * fix for hibernate: false * refactor to only use Server and Agent primitives + DELETE support * add e2e tests for authless and oauth MCPs * tidy * use new partykit server props * close writer immediately * add multiple MCPs in e2e * include custom props examples + don't assume props can't change per request * better comments and amends * Update .changeset/perfect-feet-breathe.md * add docs * fix typos --------- Co-authored-by: whoiskatrin <kreznykova@cloudflare.com> Co-authored-by: Sunil Pai <threepointone@gmail.com>
1 parent 7d669e2 commit f7bd395

File tree

18 files changed

+9232
-3452
lines changed

18 files changed

+9232
-3452
lines changed

.changeset/perfect-feet-breathe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agents": patch
3+
---
4+
5+
Make McpAgent extend Agent + Streaming HTTP protocol features

docs/mcp-servers.md

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# Creating your own MCP Server with an McpAgent
2+
3+
This guide aims to help you get familiar with `McpAgent` and guide you through writing your own MCP servers.
4+
5+
## Writing TinyMCP
6+
7+
Prototyping is very easy! If you want to quickly deploy an MCP, it only takes ~20 lines of code:
8+
9+
```typescript
10+
import { McpAgent } from "agents/mcp";
11+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12+
import { z } from "zod";
13+
14+
// Our MCP server!
15+
export class TinyMcp extends McpAgent {
16+
server = new McpServer({ name: "", version: "v1.0.0" });
17+
18+
async init() {
19+
this.server.tool(
20+
"square",
21+
"Squares a number",
22+
{ number: z.number() },
23+
async ({ number }) => ({
24+
content: [{ type: "text", text: String(number ** 2) }]
25+
})
26+
);
27+
}
28+
}
29+
30+
// This is literally all there is to our Worker
31+
export default TinyMcp.serve("/");
32+
```
33+
34+
Your `wrangler.jsonc` would look something like:
35+
36+
```jsonc
37+
{
38+
"name": "tinymcp",
39+
"main": "src/index.ts",
40+
"compatibility_date": "2025-08-26",
41+
"compatibility_flags": ["nodejs_compat"],
42+
"durable_objects": {
43+
"bindings": [
44+
{
45+
"name": "MCP_OBJECT",
46+
"class_name": "TinyMcp"
47+
}
48+
]
49+
},
50+
"migrations": [
51+
{
52+
"tag": "v1",
53+
"new_sqlite_classes": ["TinyMcp"]
54+
}
55+
]
56+
}
57+
```
58+
59+
### What is going on here?
60+
61+
`McpAgent` requires us to define 2 bits, `server` and `init()`.
62+
63+
`init()` is the initialization logic that runs every time our MCP server is started (each client session goes to a different Agent instance).
64+
In there you'll normally setup all your tools/resources and anything else you might need. In this case, we're only setting the tool `square`.
65+
66+
That was just the `McpAgent`, but we still need a Worker to route requests to our MCP server. `McpAgent` exports a static method that deals with that for you. That's what `TinyMcp.serve(...)` is for.
67+
It returns an object with a `fetch` handler that can act as our Worker entrypoint and deal with the Streamable HTTP transport for us, so we can deploy our MCP directly!
68+
69+
### Putting it to the test
70+
71+
It's a very simple MCP indeed, but you can get a feel of how fast you can get a server up and running. You can deploy this worker and test your MCP with any client. I'll try with https://playground.ai.cloudflare.com:
72+
![model calls the square tool after connecting to our mcp](https://github.com/user-attachments/assets/1e979a82-ed3e-49e9-b9d5-a3fc9b0363a7)
73+
74+
## Password-protected StorageMcp with OAuth!
75+
76+
To get a feel of what a more realistic MCP might look like, let's deploy an MCP that lets anyone that knows our secret password access a shared R2 bucket. (This is an example of a custom authorization flow, please do **not** use this in production)
77+
78+
```typescript
79+
import { McpAgent } from "agents/mcp";
80+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
81+
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";
82+
import { z } from "zod";
83+
import { env } from "cloudflare:workers";
84+
85+
export class StorageMcp extends McpAgent {
86+
server = new McpServer({ name: "", version: "v1.0.0" });
87+
88+
async init() {
89+
// Helper to return text responses from our tools
90+
const textRes = (text: string) => ({
91+
content: [{ type: "text" as const, text }]
92+
});
93+
94+
this.server.tool(
95+
"writeFile",
96+
"Store text as a file with the given path",
97+
{
98+
path: z.string().describe("Absolute path of the file"),
99+
content: z.string().describe("The content to store")
100+
},
101+
async ({ path, content }) => {
102+
try {
103+
await env.BUCKET.put(path, content);
104+
return textRes(`Successfully stored contents to ${path}`);
105+
} catch (e: unknown) {
106+
return textRes(`Couldn't save to file. Found error ${e}`);
107+
}
108+
}
109+
);
110+
111+
this.server.tool(
112+
"readFile",
113+
"Read the contents of a file",
114+
{
115+
path: z.string().describe("Absolute path of the file to read")
116+
},
117+
async ({ path }) => {
118+
const obj = await env.BUCKET.get(path);
119+
if (!obj || !obj.body)
120+
return textRes(`Error reading file at ${path}: not found`);
121+
try {
122+
return textRes(await obj.text());
123+
} catch (e: unknown) {
124+
return textRes(`Error reading file at ${path}: ${e}`);
125+
}
126+
}
127+
);
128+
129+
this.server.tool("whoami", "Check who the user is", async () => {
130+
return textRes(`${this.props?.userId}`);
131+
});
132+
}
133+
}
134+
135+
// HTML form page for users to write our password
136+
function passwordPage(opts: { query: string; error?: string }) {
137+
const err = opts.error
138+
? `<p class="text-red-600 mb-2">${opts.error}</p>`
139+
: "";
140+
return new Response(
141+
`<!doctype html>
142+
<html lang="en">
143+
<head>
144+
<meta charset="utf-8" />
145+
<meta name="viewport" content="width=device-width, initial-scale=1" />
146+
<title>ENTER THE MAGIC WORD</title>
147+
<script src="https://cdn.tailwindcss.com"></script>
148+
</head>
149+
<body class="font-sans grid place-items-center min-h-screen bg-gray-100">
150+
<form method="POST" action="/authorize?${opts.query}"
151+
class="bg-white p-6 rounded-lg shadow-md w-full max-w-xs">
152+
<h1 class="text-lg font-semibold mb-3">ENTER THE MAGIC WORD</h1>
153+
${err}
154+
<label class="block text-sm mb-1">Password</label>
155+
<input name="password" type="password" required autocomplete="current-password"
156+
class="w-full border rounded px-3 py-2 mb-3" />
157+
<button type="submit"
158+
class="w-full py-2 bg-black text-white rounded font-medium hover:bg-gray-800">
159+
Continue
160+
</button>
161+
</form>
162+
</body>
163+
</html>`,
164+
{ headers: { "content-type": "text/html; charset=utf-8" } }
165+
);
166+
}
167+
168+
// This is the default handler of our worker BEFORE requests are authenticated.
169+
const defaultHandler = {
170+
async fetch(request: Request, env: any) {
171+
const provider = env.OAUTH_PROVIDER;
172+
const url = new URL(request.url);
173+
174+
// Only handle our auth UI/flow here
175+
if (url.pathname !== "/authorize") {
176+
return new Response("NOT FOUND", { status: 404 });
177+
}
178+
179+
// Parse the OAuth request
180+
const oauthReq = await provider.parseAuthRequest(request);
181+
182+
// We render the password page for GET requests
183+
if (request.method === "GET") {
184+
return passwordPage({ query: url.searchParams.toString() });
185+
}
186+
187+
// We validate the password in POST requests
188+
if (request.method === "POST") {
189+
const form = await request.formData();
190+
const password = String(form.get("password") || "");
191+
192+
const SHARED_PASSWORD = env.SHARED_PASSWORD; // Store this as a secret
193+
if (!SHARED_PASSWORD) {
194+
return new Response("Server misconfigured: missing SHARED_PASSWORD", {
195+
status: 500
196+
});
197+
}
198+
if (password !== SHARED_PASSWORD) {
199+
return passwordPage({
200+
query: url.searchParams.toString(),
201+
error: "Wrong password."
202+
});
203+
}
204+
205+
// We give everyone the same userId
206+
const userId = "friend";
207+
208+
const { redirectTo } = await provider.completeAuthorization({
209+
request: oauthReq,
210+
userId,
211+
scope: [], // We don't care about scopes
212+
213+
// We could add anything we wanted here so we could access it
214+
// within the MCP with `this.props`
215+
props: { userId },
216+
metadata: undefined
217+
});
218+
219+
return Response.redirect(redirectTo, 302);
220+
}
221+
222+
return new Response("Method Not Allowed", {
223+
status: 405,
224+
headers: { allow: "GET, POST" }
225+
});
226+
}
227+
};
228+
229+
// OAuthProvider creates our worker handler
230+
export default new OAuthProvider({
231+
authorizeEndpoint: "/authorize",
232+
tokenEndpoint: "/token",
233+
clientRegistrationEndpoint: "/register",
234+
apiHandlers: { "/mcp": StorageMcp.serve("/mcp") },
235+
defaultHandler
236+
});
237+
```
238+
239+
You would also add these to your `wrangler.jsonc`:
240+
241+
```jsonc
242+
{
243+
// rest of your config...
244+
"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "your-bucket-name" }],
245+
"kv_namespaces": [
246+
{
247+
"binding": "OAUTH_KV", // required by OAuthProvider
248+
"id": "your-kv-id"
249+
}
250+
]
251+
}
252+
```
253+
254+
### What's going on?
255+
256+
In ~160 lines we were able to write our custom OAuth authorization flow so anyone that knows our secret password can use the MCP server.
257+
258+
Just like before, in `init()` we set a few tools to access files in our R2 bucket. We also have the `whoami` tool to show users what `userId` we authenticated them with. It's just an example of how to access `props` from within the `McpAgent`.
259+
260+
Most of the code here is either the HTML page to type in the password or the OAuth `/authorize` logic.
261+
The important part is to notice how in the `OAuthProvider` we expose the `StorageMcp` through the `apiHandlers` key and use the same `serve` method we were using before.
262+
263+
### Let's see how this looks like
264+
265+
Once again, using https://playground.ai.cloudflare.com:
266+
![password page](https://github.com/user-attachments/assets/8e469110-fffa-45d2-84c1-ae16a651ae41)
267+
The auth flow prompts us for the password.
268+
269+
![model calls all 3 tools after authorization](https://github.com/user-attachments/assets/07e22fef-93de-47c2-af7e-9c361e460186)
270+
Once we've authenticated ourselves we can use all the tools!
271+
272+
### Read more
273+
274+
To find out how to use your favorite providers for your authorization flow and more complex examples, have a look at the demos [here](https://github.com/cloudflare/ai/tree/main/demos).

examples/mcp/src/server.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,32 +48,24 @@ export class MyMCP extends McpAgent<Env, State, {}> {
4848
console.log({ stateUpdate: state });
4949
}
5050

51-
onError(error: Error): { status: number; message: string } {
51+
onError(_: unknown, error?: unknown): void | Promise<void> {
5252
console.error("MyMCP initialization error:", error);
5353

5454
// Provide more specific error messages based on error type
55-
if (error.message.includes("counter")) {
56-
return {
57-
status: 500,
58-
message:
55+
if (error instanceof Error) {
56+
if (error.message.includes("counter")) {
57+
console.error(
5958
"Failed to initialize counter resource. Please check the counter configuration."
60-
};
61-
}
62-
63-
if (error.message.includes("tool")) {
64-
return {
65-
status: 500,
66-
message:
59+
);
60+
} else if (error.message.includes("tool")) {
61+
console.error(
6762
"Failed to register MCP tools. Please verify tool configurations."
68-
};
63+
);
64+
} else {
65+
// Fall back to default error handling
66+
console.error(error);
67+
}
6968
}
70-
71-
// Fall back to default error handling
72-
return {
73-
status: 500,
74-
message:
75-
error.message || "An unexpected error occurred during initialization"
76-
};
7769
}
7870
}
7971

0 commit comments

Comments
 (0)