Skip to content

Commit 0ba4dc2

Browse files
agents-git-bot[bot]github-actions[bot]claudemattzcarey
authored
fix callback validation in mcp client (#26902)
* Document MCP OAuth security hardening from agents PR #696 Add documentation for enhanced OAuth security measures that protect against replay attacks and DoS vulnerabilities: - New changelog entry explaining the security improvements and breaking changes - Added security note in OAuth guide describing automatic protections - Documents state validation with nonce, TTL, and single-use tokens - Notes callback URL unification across servers Related to cloudflare/agents#696 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * remove changelog * lil cleanup of the mcp oauth docs --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude <[email protected]> Co-authored-by: Matt Carey <[email protected]>
1 parent 5c2a1ca commit 0ba4dc2

File tree

1 file changed

+58
-91
lines changed

1 file changed

+58
-91
lines changed

src/content/docs/agents/guides/oauth-mcp-client.mdx

Lines changed: 58 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,31 @@ sidebar:
99

1010
import { Render, TypeScriptExample, PackageManagers } from "~/components";
1111

12-
When you build an Agent that connects to OAuth-protected MCP servers (like Slack or Notion), your end users will need to authenticate before the Agent can access their data. This guide shows you how to implement OAuth flows so your users can authorize access seamlessly.
12+
When connecting to OAuth-protected MCP servers (like Slack or Notion), your users need to authenticate before your Agent can access their data. This guide covers implementing OAuth flows for seamless authorization.
1313

14-
## Understanding the OAuth flow
14+
## How it works
1515

16-
When your Agent connects to an OAuth-protected MCP server, here's what happens:
16+
1. Call `addMcpServer()` with the server URL
17+
2. If OAuth is required, an `authUrl` is returned instead of connecting immediately
18+
3. Present the `authUrl` to your user (redirect, popup, or link)
19+
4. User authenticates on the provider's site
20+
5. Provider redirects back to your Agent's callback URL
21+
6. Your Agent completes the connection automatically
1722

18-
1. **Your code calls** `addMcpServer()` with the server URL
19-
2. **If OAuth is required**, it returns an `authUrl` instead of immediately connecting
20-
3. **Your application presents** the `authUrl` to your user
21-
4. **Your user authenticates** on the provider's site (Slack, etc.)
22-
5. **The provider redirects** back to your Agent's callback URL with an authorization code
23-
6. **Your Agent completes** the connection automatically
23+
The MCP client uses a built-in `DurableObjectOAuthClientProvider` to manage OAuth state securely — storing a nonce and server ID, validating on callback, and cleaning up after use or expiration.
2424

25-
## Connect and initiate OAuth
25+
## Initiate OAuth
2626

27-
When you connect to an OAuth-protected server (like Cloudflare Observability), check if `authUrl` is returned. If present, automatically redirect your user to complete authorization:
27+
When connecting to an OAuth-protected server, check if `authUrl` is returned. If present, redirect your user to complete authorization:
2828

2929
<TypeScriptExample>
3030

3131
```ts title="src/index.ts"
32-
export class ObservabilityAgent extends Agent<Env, never> {
32+
export class MyAgent extends Agent<Env, never> {
3333
async onRequest(request: Request): Promise<Response> {
3434
const url = new URL(request.url);
3535

36-
if (url.pathname.endsWith("connect-observability") && request.method === "POST") {
37-
// Attempt to connect to Cloudflare Observability MCP server
36+
if (url.pathname.endsWith("/connect") && request.method === "POST") {
3837
const { id, authUrl } = await this.addMcpServer(
3938
"Cloudflare Observability",
4039
"https://observability.mcp.cloudflare.com/mcp",
@@ -45,11 +44,8 @@ export class ObservabilityAgent extends Agent<Env, never> {
4544
return Response.redirect(authUrl, 302);
4645
}
4746

48-
// No OAuth needed - connection complete
49-
return new Response(
50-
JSON.stringify({ serverId: id, status: "connected" }),
51-
{ headers: { "Content-Type": "application/json" } },
52-
);
47+
// Already authenticated - connection complete
48+
return Response.json({ serverId: id, status: "connected" });
5349
}
5450

5551
return new Response("Not found", { status: 404 });
@@ -59,23 +55,21 @@ export class ObservabilityAgent extends Agent<Env, never> {
5955

6056
</TypeScriptExample>
6157

62-
Your user is automatically redirected to the provider's OAuth page to authorize access.
63-
6458
### Alternative approaches
6559

66-
Instead of an automatic redirect, you can also present the `authUrl` to your user as a:
60+
Instead of an automatic redirect, you can present the `authUrl` to your user as a:
6761

68-
- **Popup window**: `window.open(authUrl, '_blank', 'width=600,height=700')` (for dashboard-style apps)
69-
- **Clickable link**: Display as a button or link (for API documentation or multi-step flows)
62+
- **Popup window**: `window.open(authUrl, '_blank', 'width=600,height=700')` for dashboard-style apps
63+
- **Clickable link**: Display as a button or link for multi-step flows
7064
- **Deep link**: Use custom URL schemes for mobile apps
7165

7266
## Configure callback behavior
7367

74-
After your user completes OAuth, the provider redirects back to your Agent's callback URL. Configure what happens next.
68+
After OAuth completes, the provider redirects back to your Agent's callback URL. Configure what happens next.
7569

76-
### Redirect to your application (recommended)
70+
### Redirect to your application
7771

78-
For the automatic redirect approach, redirect users back to your application after OAuth completes:
72+
Redirect users back to your application after OAuth completes:
7973

8074
<TypeScriptExample>
8175

@@ -92,11 +86,11 @@ export class MyAgent extends Agent<Env, never> {
9286

9387
</TypeScriptExample>
9488

95-
Users return to `/dashboard` on success or `/auth-error?error=<message>` on failure, maintaining a smooth flow.
89+
Users return to `/dashboard` on success or `/auth-error?error=<message>` on failure.
9690

9791
### Close popup window
9892

99-
If you used `window.open()` to open OAuth in a popup:
93+
If you opened OAuth in a popup, close it automatically when complete:
10094

10195
<TypeScriptExample>
10296

@@ -128,13 +122,13 @@ export class MyAgent extends Agent<Env, never> {
128122

129123
</TypeScriptExample>
130124

131-
The popup closes automatically, and your main application can detect this and refresh the connection status.
125+
Your main application can detect the popup closing and refresh the connection status.
132126

133127
## Monitor connection status
134128

135-
### For React applications (recommended)
129+
### React applications
136130

137-
Use the `useAgent` hook for automatic state updates:
131+
Use the `useAgent` hook for real-time updates via WebSocket:
138132

139133
<TypeScriptExample>
140134

@@ -178,11 +172,11 @@ function App() {
178172

179173
</TypeScriptExample>
180174

181-
The `onMcpUpdate` callback receives real-time updates via WebSocket. No polling needed!
175+
The `onMcpUpdate` callback fires automatically when MCP state changes — no polling needed.
182176

183-
### For other applications
177+
### Other frameworks
184178

185-
If you're not using React, poll the connection status:
179+
Poll the connection status via an endpoint:
186180

187181
<TypeScriptExample>
188182

@@ -201,16 +195,14 @@ export class MyAgent extends Agent<Env, never> {
201195
([id, server]) => ({
202196
serverId: id,
203197
name: server.name,
204-
state: server.state, // "authenticating" | "connecting" | "ready" | "failed"
198+
state: server.state,
205199
isReady: server.state === "ready",
206200
needsAuth: server.state === "authenticating",
207201
authUrl: server.auth_url,
208202
}),
209203
);
210204

211-
return new Response(JSON.stringify(connections, null, 2), {
212-
headers: { "Content-Type": "application/json" },
213-
});
205+
return Response.json(connections);
214206
}
215207

216208
return new Response("Not found", { status: 404 });
@@ -220,11 +212,11 @@ export class MyAgent extends Agent<Env, never> {
220212

221213
</TypeScriptExample>
222214

223-
Connection states: `authenticating` (needs OAuth) > `connecting` (completing setup) > `ready` (available for use)
215+
Connection states flow: `authenticating` (needs OAuth) `connecting` (completing setup) `ready` (available for use)
224216

225-
## Handle authentication failures
217+
## Handle failures
226218

227-
When OAuth fails, the connection `state` becomes `"failed"`. Detect this in your UI and allow users to retry or clean up:
219+
When OAuth fails, the connection state becomes `"failed"`. Detect this in your UI and allow users to retry:
228220

229221
<TypeScriptExample>
230222

@@ -243,9 +235,7 @@ function App() {
243235
const agent = useAgent({
244236
agent: "my-agent",
245237
name: "session-id",
246-
onMcpUpdate: (mcpServers: MCPServersState) => {
247-
setMcpState(mcpServers);
248-
},
238+
onMcpUpdate: setMcpState,
249239
});
250240

251241
const handleRetry = async (serverId: string, serverUrl: string, name: string) => {
@@ -256,7 +246,7 @@ function App() {
256246
});
257247

258248
// Retry connection
259-
const response = await fetch(`/agents/my-agent/session-id/connect-observability`, {
249+
const response = await fetch(`/agents/my-agent/session-id/connect`, {
260250
method: "POST",
261251
body: JSON.stringify({ serverUrl, name }),
262252
});
@@ -290,21 +280,15 @@ function App() {
290280
Common failure reasons:
291281

292282
- **User canceled**: Closed OAuth window before completing authorization
293-
- **Invalid credentials**: Slack credentials were incorrect
294-
- **Permission denied**: User lacks required permissions (e.g., not a workspace admin)
283+
- **Invalid credentials**: Provider credentials were incorrect
284+
- **Permission denied**: User lacks required permissions
295285
- **Expired session**: OAuth session timed out
296286

297-
Failed connections remain in state until you remove them with `removeMcpServer(serverId)`.
287+
Failed connections remain in state until removed with `removeMcpServer(serverId)`.
298288

299289
## Complete example
300290

301-
This example demonstrates a complete Cloudflare Observability OAuth integration. Users connect to Cloudflare Observability, authorize in a popup window, and the connection becomes available. The Agent provides endpoints to connect, check status, and disconnect.
302-
303-
:::note
304-
305-
For React applications: Replace the `/status` polling endpoint with the `useAgent` hook's `onMcpUpdate` callback for automatic state updates via WebSocket.
306-
307-
:::
291+
This example demonstrates a complete OAuth integration with Cloudflare Observability. Users connect, authorize in a popup window, and the connection becomes available.
308292

309293
<TypeScriptExample>
310294

@@ -313,12 +297,11 @@ import { Agent, type AgentNamespace, routeAgentRequest } from "agents";
313297
import type { MCPClientOAuthResult } from "agents/mcp";
314298

315299
type Env = {
316-
ObservabilityAgent: AgentNamespace<ObservabilityAgent>;
300+
MyAgent: AgentNamespace<MyAgent>;
317301
};
318302

319-
export class ObservabilityAgent extends Agent<Env, never> {
303+
export class MyAgent extends Agent<Env, never> {
320304
onStart() {
321-
// Configure OAuth callback to close popup window
322305
this.mcp.configureOAuthCallback({
323306
customHandler: (result: MCPClientOAuthResult) => {
324307
if (result.authSuccess) {
@@ -338,59 +321,43 @@ export class ObservabilityAgent extends Agent<Env, never> {
338321
async onRequest(request: Request): Promise<Response> {
339322
const url = new URL(request.url);
340323

341-
// Endpoint: Connect to Cloudflare Observability MCP server
342-
if (url.pathname.endsWith("connect-observability") && request.method === "POST") {
324+
// Connect to MCP server
325+
if (url.pathname.endsWith("/connect") && request.method === "POST") {
343326
const { id, authUrl } = await this.addMcpServer(
344327
"Cloudflare Observability",
345328
"https://observability.mcp.cloudflare.com/mcp",
346329
);
347330

348331
if (authUrl) {
349-
return new Response(
350-
JSON.stringify({
351-
serverId: id,
352-
authUrl: authUrl,
353-
message: "Please authorize Cloudflare Observability access",
354-
}),
355-
{ headers: { "Content-Type": "application/json" } },
356-
);
332+
return Response.json({
333+
serverId: id,
334+
authUrl: authUrl,
335+
message: "Please authorize access",
336+
});
357337
}
358338

359-
return new Response(
360-
JSON.stringify({ serverId: id, status: "connected" }),
361-
{ headers: { "Content-Type": "application/json" } },
362-
);
339+
return Response.json({ serverId: id, status: "connected" });
363340
}
364341

365-
// Endpoint: Check connection status
366-
if (url.pathname.endsWith("status") && request.method === "GET") {
342+
// Check connection status
343+
if (url.pathname.endsWith("/status") && request.method === "GET") {
367344
const mcpState = this.getMcpServers();
368-
369345
const connections = Object.entries(mcpState.servers).map(
370346
([id, server]) => ({
371347
serverId: id,
372348
name: server.name,
373349
state: server.state,
374-
isReady: server.state === "ready",
375-
needsAuth: server.state === "authenticating",
376350
authUrl: server.auth_url,
377351
}),
378352
);
379-
380-
return new Response(JSON.stringify(connections, null, 2), {
381-
headers: { "Content-Type": "application/json" },
382-
});
353+
return Response.json(connections);
383354
}
384355

385-
// Endpoint: Disconnect from Cloudflare Observability
386-
if (url.pathname.endsWith("disconnect") && request.method === "POST") {
356+
// Disconnect
357+
if (url.pathname.endsWith("/disconnect") && request.method === "POST") {
387358
const { serverId } = (await request.json()) as { serverId: string };
388359
await this.removeMcpServer(serverId);
389-
390-
return new Response(
391-
JSON.stringify({ message: "Disconnected from Cloudflare Observability" }),
392-
{ headers: { "Content-Type": "application/json" } },
393-
);
360+
return Response.json({ message: "Disconnected" });
394361
}
395362

396363
return new Response("Not found", { status: 404 });
@@ -411,5 +378,5 @@ export default {
411378

412379
## Related
413380

414-
- [Connect to an MCP server](/agents/guides/connect-mcp-client) — Get started tutorial (no OAuth)
415-
- [McpClient — API reference](/agents/model-context-protocol/mcp-client-api/) — Complete API documentation
381+
- [Connect to an MCP server](/agents/guides/connect-mcp-client) — Get started (no OAuth)
382+
- [MCP Client API reference](/agents/model-context-protocol/mcp-client-api/)

0 commit comments

Comments
 (0)