agents 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/{agent-routing-CDbpVjYq.d.ts → agent-routing-wS1TimZ_.d.ts} +64 -603
- package/dist/agent-routing.d.ts +1 -1
- package/dist/agent-tool-types.d.ts +1 -1
- package/dist/{agent-tools-C8DH8Yii.d.ts → agent-tools-Bamxxb6Y.d.ts} +2 -2
- package/dist/agent-tools.d.ts +1 -1
- package/dist/{ai-iXw0uOGj.d.ts → ai-CDn3Abax.d.ts} +30 -4
- package/dist/{ai-sdk-DRPh2rRa.js → ai-sdk-Kon40i0q.js} +2 -36
- package/dist/ai-sdk-Kon40i0q.js.map +1 -0
- package/dist/ai-sdk-hW3vntnk.d.ts +72 -0
- package/dist/browser/ai.d.ts +1 -1
- package/dist/browser/ai.js +16 -3
- package/dist/browser/ai.js.map +1 -1
- package/dist/browser/index.d.ts +64 -16
- package/dist/browser/index.js +4 -4
- package/dist/browser/tools/ai-sdk.d.ts +7 -70
- package/dist/browser/tools/ai-sdk.js +4 -4
- package/dist/browser/tools/ai-sdk.js.map +1 -1
- package/dist/browser/tools/pi.d.ts +9 -15
- package/dist/browser/tools/pi.js +6 -14
- package/dist/browser/tools/pi.js.map +1 -1
- package/dist/browser/tools/tanstack-ai.d.ts +46 -23
- package/dist/browser/tools/tanstack-ai.js +8 -4
- package/dist/browser/tools/tanstack-ai.js.map +1 -1
- package/dist/{browser-DccPw_wW.js → browser-1eUbPvC2.js} +413 -14
- package/dist/browser-1eUbPvC2.js.map +1 -0
- package/dist/{browser-DlXhSAdf.d.ts → browser-D9RNpJuT.d.ts} +254 -20
- package/dist/browser-tool-BhjCcNVE.d.ts +71 -0
- package/dist/browser-tool-lGnvEBLJ.js +497 -0
- package/dist/browser-tool-lGnvEBLJ.js.map +1 -0
- package/dist/capability-id-B3J8Nok_.js +13 -0
- package/dist/capability-id-B3J8Nok_.js.map +1 -0
- package/dist/chat/index.d.ts +2 -2
- package/dist/chat-sdk/index.d.ts +3 -3
- package/dist/chat-sdk/index.js +1 -1
- package/dist/client-4OlMIJx-.js.map +1 -1
- package/dist/{client-BnYbNT3z.js → client-DMWujQJ1.js} +68 -244
- package/dist/client-DMWujQJ1.js.map +1 -0
- package/dist/{client-C27CtRZr.d.ts → client-wn4-yqGz.d.ts} +9 -7
- package/dist/client.d.ts +1 -1
- package/dist/{connector-DAXBOIIi.js → connector-CM-uuxKM.js} +96 -8
- package/dist/connector-CM-uuxKM.js.map +1 -0
- package/dist/{connector-20DJ6p4k.d.ts → connector-DZ71EjE-.d.ts} +109 -9
- package/dist/experimental/webmcp.js +1 -1
- package/dist/{handler-stateless-Bly_nSH3.js → handler-stateless-B5hU3dxK.js} +35 -2
- package/dist/handler-stateless-B5hU3dxK.js.map +1 -0
- package/dist/{handler-stateless-DxYpJ_XF.d.ts → handler-stateless-C90kD5_R.d.ts} +3 -47
- package/dist/harness/container/index.d.ts +15 -15
- package/dist/harness/opencode/index.js.map +1 -1
- package/dist/harness/pi/index.d.ts +4 -3
- package/dist/harness/pi/index.js +31 -10
- package/dist/harness/pi/index.js.map +1 -1
- package/dist/harness/think/index.d.ts +2 -1
- package/dist/{images-xaDv2Fqe.js → images-CfuRCjwq.js} +1 -5
- package/dist/images-CfuRCjwq.js.map +1 -0
- package/dist/{index-DCxk3OSJ.d.ts → index-8k4qsX1T.d.ts} +2 -28
- package/dist/index.d.ts +14 -14
- package/dist/index.js +2 -2
- package/dist/internal-hosts-Kfp5A392.d.ts +230 -0
- package/dist/internal-kmw70xwR.js.map +1 -1
- package/dist/invoker-BC1SZ3q1.js +26 -0
- package/dist/invoker-BC1SZ3q1.js.map +1 -0
- package/dist/invoker-DoLFPJZV.d.ts +21 -0
- package/dist/mcp/client/index.d.ts +15 -1
- package/dist/mcp/client/index.js +2 -2
- package/dist/mcp/client/x402.d.ts +7 -5
- package/dist/mcp/client/x402.js +34 -1
- package/dist/mcp/client/x402.js.map +1 -1
- package/dist/mcp/index.d.ts +561 -27
- package/dist/mcp/index.js +180 -3
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server/index.d.ts +1 -1
- package/dist/mcp/server/index.js +1 -1
- package/dist/models/ai-sdk/index.d.ts +2 -2
- package/dist/models/ai-sdk/index.js +1 -1
- package/dist/models/opencode/index.d.ts +2 -2
- package/dist/models/opencode/index.js +1 -1
- package/dist/models/pi-ai/index.d.ts +3 -28
- package/dist/models/pi-ai/index.js +31 -16
- package/dist/models/pi-ai/index.js.map +1 -1
- package/dist/protocol-BEgrJYC5.d.ts +29 -29
- package/dist/react.d.ts +2 -2
- package/dist/routing/index.d.ts +1 -1
- package/dist/serializable.d.ts +1 -1
- package/dist/{session-store-yvwq1f9x.js → session-7CDgVE0z.js} +455 -33
- package/dist/session-7CDgVE0z.js.map +1 -0
- package/dist/{session-store-_IK6dV-w.d.ts → session-store-B7Ca-u1t.d.ts} +8 -1
- package/dist/settings-Dk2il2Hf.js.map +1 -1
- package/dist/{src-DfLzdwlK.js → src-BGolXG_H.js} +18 -2
- package/dist/src-BGolXG_H.js.map +1 -0
- package/dist/sub-routing.d.ts +12 -12
- package/dist/tool-CTp72Jb_.d.ts +10 -10
- package/dist/tool-DoVberpN.d.ts +22 -22
- package/dist/tool-helpers-BodWi4gd.js +256 -0
- package/dist/tool-helpers-BodWi4gd.js.map +1 -0
- package/dist/{transport-CPh7YGiw.d.ts → transport-5moD2pW5.d.ts} +1 -43
- package/dist/types-D2naO3rA.d.ts +49 -0
- package/dist/voice/index.d.ts +1 -1
- package/dist/workflow-types.d.ts +1 -1
- package/dist/workflows.d.ts +1 -1
- package/dist/workflows.js +1 -1
- package/docs/browse-the-web.md +247 -413
- package/docs/harnesses/pi.md +27 -27
- package/docs/index.md +4 -4
- package/docs/mcp-client.md +2 -0
- package/docs/mcp-servers.md +25 -6
- package/docs/models-pi-ai.md +9 -11
- package/docs/models.md +1 -8
- package/docs/securing-mcp-servers.md +113 -302
- package/package.json +8 -3
- package/dist/ai-sdk-DRPh2rRa.js.map +0 -1
- package/dist/browser-DccPw_wW.js.map +0 -1
- package/dist/browser-tool-B1mhifin.js +0 -616
- package/dist/browser-tool-B1mhifin.js.map +0 -1
- package/dist/browser-tool-Cxjk5sgG.d.ts +0 -92
- package/dist/client-BnYbNT3z.js.map +0 -1
- package/dist/connector-DAXBOIIi.js.map +0 -1
- package/dist/handler-stateless-Bly_nSH3.js.map +0 -1
- package/dist/images-xaDv2Fqe.js.map +0 -1
- package/dist/invoker-CG0_p_Wq.d.ts +0 -20
- package/dist/invoker-CHMnoxIA.js +0 -57
- package/dist/invoker-CHMnoxIA.js.map +0 -1
- package/dist/session-store-yvwq1f9x.js.map +0 -1
- package/dist/src-DfLzdwlK.js.map +0 -1
- package/dist/tool-helpers-O5BDVEAY.js +0 -58
- package/dist/tool-helpers-O5BDVEAY.js.map +0 -1
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
# Securing MCP Servers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP servers, like any web application, need to be secured so they can be used by trusted users without abuse. The MCP specification uses OAuth 2.1 for authorization between MCP clients and servers.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[`@cloudflare/workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider) handles client registration, tokens, and token validation. `OAuthProvider` runs the authorization server and your MCP server in one Worker:
|
|
6
6
|
|
|
7
7
|
```typescript
|
|
8
|
-
import { OAuthProvider
|
|
8
|
+
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
10
10
|
import { createMcpHandler } from "agents/mcp/server";
|
|
11
|
+
import { env } from "cloudflare:workers";
|
|
11
12
|
|
|
12
|
-
// A Worker that exposes an MCP server
|
|
13
13
|
function createServer() {
|
|
14
14
|
return new McpServer({ name: "authenticated-server", version: "1.0.0" });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const mcpHandler = createMcpHandler(createServer);
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
export default new OAuthProvider({
|
|
19
|
+
export default new OAuthProvider<Env>({
|
|
21
20
|
authorizeEndpoint: "/authorize",
|
|
22
21
|
tokenEndpoint: "/oauth/token",
|
|
23
22
|
clientRegistrationEndpoint: "/oauth/register",
|
|
@@ -30,338 +29,150 @@ export default new OAuthProvider({
|
|
|
30
29
|
}
|
|
31
30
|
},
|
|
32
31
|
|
|
33
|
-
defaultHandler: AuthHandler //
|
|
32
|
+
defaultHandler: AuthHandler, // Your /authorize page
|
|
33
|
+
|
|
34
|
+
// The URL MCP clients connect to. Every token is bound to it.
|
|
35
|
+
resourceMetadata: { resource: env.MCP_RESOURCE }
|
|
34
36
|
});
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
However, most MCP servers aren't just servers, they can actually be OAuth clients too. Your MCP server might sit between Claude Desktop and a third-party API like GitHub or Google. To Claude, you're a server. To GitHub, you're a client. This allows your users to authenticate and use their GitHub credentials to access your MCP server. We call this a proxy server.
|
|
40
|
-
|
|
41
|
-
There are a few security footguns to securely building a proxy server. The rest of this document aims to outline best practises to securing an MCP server.
|
|
42
|
-
|
|
43
|
-
## `redirect_uri` validation
|
|
39
|
+
Tool callbacks receive the `props` you stored at sign-in through `getMcpAuthContext().props`, and the verified token's client and scopes through `context.http.authInfo`. Treat `authInfo.token` and `authInfo.extra.props` as sensitive: do not log or return them.
|
|
44
40
|
|
|
45
|
-
The
|
|
41
|
+
The library secures the token side. Your `/authorize` page is application code, and it is where most MCP security mistakes happen. The rest of this page covers the library APIs that make it safe.
|
|
46
42
|
|
|
47
|
-
## Consent
|
|
43
|
+
## Consent page
|
|
48
44
|
|
|
49
|
-
|
|
45
|
+
Every MCP client can register itself, so your `/authorize` page must ask the user whether this client may act for them before calling `completeAuthorization()`. The page must show the client's name, the scopes, and the hostname the tokens will be sent to, and it must not be possible to frame or forge it.
|
|
50
46
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
| API | What it does |
|
|
48
|
+
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `describeConsent(request)` | Returns what the page must show: `clientName`, `clientDomain` (the verified domain of a Client ID Metadata Document client), `redirectHost`, `redirectIsLoopback`, and `scope`. |
|
|
50
|
+
| `beginConsent(request)` | Stores the authorization request on the server and returns a single-use `handle` for your form, plus `headers` to send with the page. The headers set a browser-bound `__Host-` cookie and block framing. |
|
|
51
|
+
| `approveConsent(request, handle, options?)` | Checks the handle against the cookie and returns the stored authorization request. Pass `scope` with the scopes the user approved. |
|
|
52
|
+
| `denyConsent(request, handle)` | Returns headers that redirect the user back to the client with `access_denied`. |
|
|
53
|
+
| `isConsentRemembered(request, authRequest, remember)` | Returns `true` if the user already approved this client, redirect URI, and scopes. Pass `remember` to `approveConsent()` to record an approval. |
|
|
54
54
|
|
|
55
55
|
```typescript
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
});
|
|
56
|
+
const oauth = env.OAUTH_PROVIDER;
|
|
57
|
+
|
|
58
|
+
// GET /authorize, after signing the user in
|
|
59
|
+
const authRequest = await oauth.parseAuthRequest(request);
|
|
60
|
+
const details = await oauth.describeConsent(authRequest);
|
|
61
|
+
const consent = await oauth.beginConsent(authRequest);
|
|
62
|
+
consent.headers.set("Content-Type", "text/html; charset=utf-8");
|
|
63
|
+
return new Response(consentPage(details, consent.handle), {
|
|
64
|
+
headers: consent.headers
|
|
66
65
|
});
|
|
67
66
|
|
|
68
|
-
// POST /authorize
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Then redirect to upstream provider and clear the CSRF with the clearCookie header
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// Helper functions
|
|
79
|
-
function generateCSRFProtection(): CSRFProtectionResult {
|
|
80
|
-
const token = crypto.randomUUID();
|
|
81
|
-
const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
|
|
82
|
-
return { token, setCookie };
|
|
67
|
+
// POST /authorize
|
|
68
|
+
const form = await request.formData();
|
|
69
|
+
const handle = String(form.get("handle"));
|
|
70
|
+
if (form.get("decision") !== "approve") {
|
|
71
|
+
const denied = await oauth.denyConsent(request, handle);
|
|
72
|
+
return new Response(null, { status: 302, headers: denied.headers });
|
|
83
73
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!tokenFromForm || !tokenFromCookie || tokenFromForm !== tokenFromCookie) {
|
|
97
|
-
throw new OAuthError("invalid_request", "CSRF token mismatch", 400);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Clear cookie after use (one-time use per RFC 9700)
|
|
101
|
-
return {
|
|
102
|
-
clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
Include the token as a hidden field in your consent form:
|
|
108
|
-
|
|
109
|
-
```html
|
|
110
|
-
<input type="hidden" name="csrf_token" value="${csrfToken}" />
|
|
74
|
+
const approved = await oauth.approveConsent(request, handle, {
|
|
75
|
+
scope: form.getAll("scope").map(String)
|
|
76
|
+
});
|
|
77
|
+
const { redirectTo } = await oauth.completeAuthorization({
|
|
78
|
+
request: approved.request, // from storage, not from the form
|
|
79
|
+
userId: session.userId,
|
|
80
|
+
metadata: {},
|
|
81
|
+
scope: approved.request.scope,
|
|
82
|
+
props: { userId: session.userId }
|
|
83
|
+
});
|
|
84
|
+
approved.headers.set("Location", redirectTo);
|
|
85
|
+
return new Response(null, { status: 302, headers: approved.headers });
|
|
111
86
|
```
|
|
112
87
|
|
|
113
|
-
|
|
88
|
+
The form carries only the handle. The authorization request stays on the server, so a forged form cannot approve a different client or redirect URI.
|
|
114
89
|
|
|
115
|
-
|
|
90
|
+
`describeConsent()` returns the values but does not render HTML, and every string in it can come from the client. Render them with a template or JSX library that escapes output, such as Hono's JSX.
|
|
116
91
|
|
|
117
|
-
|
|
92
|
+
See [Building a consent page](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/consent-page.md) for a complete page and for which errors to redirect back to the client and which to render on your page.
|
|
118
93
|
|
|
119
|
-
|
|
120
|
-
- **Logo URLs**: Validate URL scheme (allow only `http:` and `https:`), reject `javascript:`, `data:`, `file:` schemes
|
|
121
|
-
- **Client URIs**: Same as logo URLs - whitelist http/https only
|
|
122
|
-
- **Scopes**: Treat as text, HTML-escape before display
|
|
94
|
+
## Signing in through a third-party provider
|
|
123
95
|
|
|
124
|
-
|
|
125
|
-
function sanitizeText(text: string): string {
|
|
126
|
-
return text
|
|
127
|
-
.replace(/&/g, "&")
|
|
128
|
-
.replace(/</g, "<")
|
|
129
|
-
.replace(/>/g, ">")
|
|
130
|
-
.replace(/"/g, """)
|
|
131
|
-
.replace(/'/g, "'");
|
|
132
|
-
}
|
|
96
|
+
Many MCP servers sign users in with GitHub, Google, or another provider and call that provider's API with the user's token. Every MCP client then reaches the provider through your one OAuth app. This is the [confused deputy problem](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#confused-deputy-problem): without per-client consent, an attacker's client can ride on a user's earlier approval at the provider.
|
|
133
97
|
|
|
134
|
-
|
|
135
|
-
if (!url) return "";
|
|
136
|
-
try {
|
|
137
|
-
const parsed = new URL(url);
|
|
138
|
-
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
139
|
-
return ""; // Reject dangerous schemes
|
|
140
|
-
}
|
|
141
|
-
return url;
|
|
142
|
-
} catch {
|
|
143
|
-
return ""; // Invalid URL
|
|
144
|
-
}
|
|
145
|
-
}
|
|
98
|
+
Ask for consent first, then hand the user to the provider:
|
|
146
99
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
### Content Security Policy (CSP)
|
|
153
|
-
|
|
154
|
-
CSP headers instruct browsers to block dangerous content and behaviors. They provide defence in depth from multiple attack vectors.
|
|
100
|
+
| API | What it does |
|
|
101
|
+
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
102
|
+
| `beginUpstream(authRequest, options)` | Call after `approveConsent()`. Stores the authorization request and your `data` (for example, a PKCE verifier), and returns a `state` value for the provider redirect, bound to the user's browser by a `__Host-` cookie. |
|
|
103
|
+
| `finishUpstream(request)` | Call in your callback. Checks `state` against the cookie and returns the original authorization request and your `data`. A provider code replayed in another browser is rejected. |
|
|
104
|
+
| `tokenExchangeCallback` | An `OAuthProvider` option that runs on token refresh. Refresh the provider's token there, and throw `new OAuthError("invalid_grant", { description })` when the provider has revoked access. |
|
|
155
105
|
|
|
156
106
|
```typescript
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"img-src 'self' https:", // Allow client logos from HTTPS URLs
|
|
163
|
-
"font-src 'self'", // Allow web fonts from same origin
|
|
164
|
-
"form-action 'self'", // Only allow form submissions to same origin
|
|
165
|
-
"frame-ancestors 'none'", // Prevent clickjacking - block ALL iframe embedding
|
|
166
|
-
"base-uri 'self'", // Prevent base tag injection attacks
|
|
167
|
-
"connect-src 'self'" // Restrict fetch/XHR to same origin
|
|
168
|
-
].join("; ");
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
"Content-Security-Policy": cspDirectives,
|
|
172
|
-
"X-Frame-Options": "DENY", // Legacy clickjacking protection for older browsers
|
|
173
|
-
"X-Content-Type-Options": "nosniff", // Prevent MIME sniffing attacks
|
|
174
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
175
|
-
"Set-Cookie": setCookie
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
### Inline JavaScript
|
|
181
|
-
|
|
182
|
-
If your consent dialog needs inline JavaScript, use data attributes and nonces to prevent XSS attacks.
|
|
183
|
-
|
|
184
|
-
```typescript
|
|
185
|
-
// Generate a unique nonce per request
|
|
186
|
-
const nonce = crypto.randomUUID();
|
|
187
|
-
|
|
188
|
-
const htmlContent = `
|
|
189
|
-
<!DOCTYPE html>
|
|
190
|
-
<html>
|
|
191
|
-
<body>
|
|
192
|
-
<p>Authorization approved! Redirecting...</p>
|
|
193
|
-
<script nonce="${nonce}" data-redirect-url="${sanitizeUrl(redirectUrl)}">
|
|
194
|
-
setTimeout(() => {
|
|
195
|
-
const script = document.querySelector('script[data-redirect-url]');
|
|
196
|
-
window.location.href = script.dataset.redirectUrl;
|
|
197
|
-
}, 2000);
|
|
198
|
-
</script>
|
|
199
|
-
</body>
|
|
200
|
-
</html>
|
|
201
|
-
`;
|
|
202
|
-
|
|
203
|
-
return new Response(htmlContent, {
|
|
204
|
-
headers: buildSecurityHeaders(setCookie, nonce) // Pass nonce to include in CSP
|
|
107
|
+
// POST /authorize: the user approved this client
|
|
108
|
+
const approved = await oauth.approveConsent(request, handle);
|
|
109
|
+
const { state, headers } = await oauth.beginUpstream(approved.request, {
|
|
110
|
+
data: { verifier }, // returned at the callback, never sent to the provider
|
|
111
|
+
headers: approved.headers
|
|
205
112
|
});
|
|
113
|
+
headers.set("Location", providerAuthorizeUrl({ state }));
|
|
114
|
+
return new Response(null, { status: 302, headers });
|
|
115
|
+
|
|
116
|
+
// GET /callback
|
|
117
|
+
const {
|
|
118
|
+
request: original,
|
|
119
|
+
data,
|
|
120
|
+
headers: clear
|
|
121
|
+
} = await oauth.finishUpstream<{ verifier: string }>(request);
|
|
122
|
+
const upstream = await exchangeProviderCode(
|
|
123
|
+
new URL(request.url).searchParams.get("code")!,
|
|
124
|
+
data.verifier
|
|
125
|
+
);
|
|
126
|
+
const { redirectTo } = await oauth.completeAuthorization({
|
|
127
|
+
request: original,
|
|
128
|
+
userId: upstream.userId,
|
|
129
|
+
metadata: {},
|
|
130
|
+
scope: original.scope,
|
|
131
|
+
props: { providerToken: upstream.accessToken } // encrypted at rest
|
|
132
|
+
});
|
|
133
|
+
clear.set("Location", redirectTo);
|
|
134
|
+
return new Response(null, { status: 302, headers: clear });
|
|
206
135
|
```
|
|
207
136
|
|
|
208
|
-
|
|
209
|
-
- **Nonces** combined with the correct CSP headers (shown above) allow your specific inline script to execute while blocking any injected scripts
|
|
137
|
+
See [Signing in through another provider](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/upstream-sign-in.md) for the full flow.
|
|
210
138
|
|
|
211
|
-
|
|
139
|
+
## Checks on the MCP server
|
|
212
140
|
|
|
213
|
-
|
|
141
|
+
`OAuthProvider` accepts only tokens issued for `resourceMetadata.resource`, so keep `MCP_RESOURCE` exactly the URL clients connect to.
|
|
214
142
|
|
|
215
|
-
|
|
143
|
+
Scopes are not enforced for you. Check them before handling a request, and return `insufficientScope()` when one is missing so the client can ask the user for it (step-up authorization):
|
|
216
144
|
|
|
217
145
|
```typescript
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
kv: KVNamespace
|
|
223
|
-
): Promise<{ stateToken: string }> {
|
|
224
|
-
const stateToken = crypto.randomUUID();
|
|
225
|
-
await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {
|
|
226
|
-
expirationTtl: 600 // 10 minutes
|
|
227
|
-
});
|
|
228
|
-
return { stateToken };
|
|
229
|
-
}
|
|
146
|
+
import {
|
|
147
|
+
insufficientScope,
|
|
148
|
+
type OAuthResourceContext
|
|
149
|
+
} from "@cloudflare/workers-oauth-provider";
|
|
230
150
|
|
|
231
|
-
//
|
|
232
|
-
async function bindStateToSession(
|
|
233
|
-
stateToken: string
|
|
234
|
-
): Promise<{ setCookie: string }> {
|
|
235
|
-
const consentedStateCookieName = "__Host-CONSENTED_STATE";
|
|
236
|
-
|
|
237
|
-
// Hash the state token to create a derived parameter
|
|
238
|
-
const encoder = new TextEncoder();
|
|
239
|
-
const data = encoder.encode(stateToken);
|
|
240
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
241
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
242
|
-
const hashHex = hashArray
|
|
243
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
244
|
-
.join("");
|
|
245
|
-
|
|
246
|
-
const setCookie = `${consentedStateCookieName}=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
|
|
247
|
-
return { setCookie };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// In the GET /callback - validate state from query params against both the KV and the session cookie before exchanging code
|
|
251
|
-
async function validateOAuthState(
|
|
252
|
-
request: Request,
|
|
253
|
-
kv: KVNamespace
|
|
254
|
-
): Promise<{ oauthReqInfo: AuthRequest; clearCookie: string }> {
|
|
255
|
-
const consentedStateCookieName = "__Host-CONSENTED_STATE";
|
|
256
|
-
const url = new URL(request.url);
|
|
257
|
-
const stateFromQuery = url.searchParams.get("state");
|
|
258
|
-
|
|
259
|
-
if (!stateFromQuery) {
|
|
260
|
-
throw new OAuthError("invalid_request", "Missing state parameter", 400);
|
|
261
|
-
}
|
|
151
|
+
type Props = { userId: string }; // what you passed to completeAuthorization()
|
|
262
152
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// Check 2: Validate state matches session cookie
|
|
270
|
-
const cookieHeader = request.headers.get("Cookie") || "";
|
|
271
|
-
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
|
272
|
-
const consentedStateCookie = cookies.find((c) =>
|
|
273
|
-
c.startsWith(`${consentedStateCookieName}=`)
|
|
274
|
-
);
|
|
275
|
-
const consentedStateHash = consentedStateCookie
|
|
276
|
-
? consentedStateCookie.substring(consentedStateCookieName.length + 1)
|
|
277
|
-
: null;
|
|
278
|
-
|
|
279
|
-
if (!consentedStateHash) {
|
|
280
|
-
throw new OAuthError(
|
|
281
|
-
"invalid_request",
|
|
282
|
-
"Missing session binding cookie - authorization flow must be restarted",
|
|
283
|
-
400
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Hash the state from query and compare with cookie
|
|
288
|
-
const encoder = new TextEncoder();
|
|
289
|
-
const data = encoder.encode(stateFromQuery);
|
|
290
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
291
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
292
|
-
const stateHash = hashArray
|
|
293
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
294
|
-
.join("");
|
|
295
|
-
|
|
296
|
-
if (stateHash !== consentedStateHash) {
|
|
297
|
-
throw new OAuthError(
|
|
298
|
-
"invalid_request",
|
|
299
|
-
"State token does not match session - possible CSRF attack detected",
|
|
300
|
-
400
|
|
301
|
-
);
|
|
153
|
+
apiHandler: {
|
|
154
|
+
fetch(request: Request, env: Env, ctx: OAuthResourceContext<Props>) {
|
|
155
|
+
if (!ctx.auth.scope.includes("mcp:read")) {
|
|
156
|
+
return insufficientScope(ctx.auth, ["mcp:read"]);
|
|
157
|
+
}
|
|
158
|
+
return mcpHandler(request, env, ctx);
|
|
302
159
|
}
|
|
303
|
-
|
|
304
|
-
// Both checks passed - clean up KV and return the clear cookie header
|
|
305
|
-
await kv.delete(`oauth:state:${stateFromQuery}`);
|
|
306
|
-
const clearCookie = `${consentedStateCookieName}=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`;
|
|
307
|
-
|
|
308
|
-
return {
|
|
309
|
-
oauthReqInfo: JSON.parse(storedDataJson),
|
|
310
|
-
clearCookie
|
|
311
|
-
};
|
|
312
|
-
}
|
|
160
|
+
},
|
|
313
161
|
```
|
|
314
162
|
|
|
315
|
-
##
|
|
316
|
-
|
|
317
|
-
MCP proxy servers must maintain a registry of approved client IDs per user and check this registry before initiating the third-party authorization flow. Store approved clients in a secure, cryptographically signed cookie with HMAC-SHA256.
|
|
318
|
-
|
|
319
|
-
```typescript
|
|
320
|
-
// Use in POST /authorize - after user approves, add client to approved list
|
|
321
|
-
export async function addApprovedClient(
|
|
322
|
-
request: Request,
|
|
323
|
-
clientId: string,
|
|
324
|
-
cookieSecret: string
|
|
325
|
-
): Promise<string> {
|
|
326
|
-
const existingApprovedClients =
|
|
327
|
-
(await getApprovedClientsFromCookie(request, cookieSecret)) || [];
|
|
328
|
-
const updatedApprovedClients = Array.from(
|
|
329
|
-
new Set([...existingApprovedClients, clientId])
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
const payload = JSON.stringify(updatedApprovedClients);
|
|
333
|
-
const signature = await signData(payload, cookieSecret); // HMAC-SHA256
|
|
334
|
-
const cookieValue = `${signature}.${btoa(payload)}`;
|
|
335
|
-
|
|
336
|
-
return `__Host-APPROVED_CLIENTS=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`;
|
|
337
|
-
}
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
When reading the cookie in GET /authorize (before showing the consent dialog), verify the signature before trusting the data. If the signature doesn't match or the client isn't in the list, show the consent dialog. If the client is approved, skip the dialog and proceed directly to creating the OAuth state.
|
|
341
|
-
|
|
342
|
-
## Cookies
|
|
343
|
-
|
|
344
|
-
### Why `__Host-` prefix?
|
|
345
|
-
|
|
346
|
-
Throughout this document you'll see cookies named with the `__Host-` prefix (like `__Host-CSRF_TOKEN` and `__Host-APPROVED_CLIENTS`). This is especially important for MCP servers running on `*.workers.dev` domains.
|
|
347
|
-
|
|
348
|
-
The `__Host-` prefix is a security feature that prevents subdomain attacks. When you set a cookie with this prefix:
|
|
349
|
-
|
|
350
|
-
- It **must** be set with the `Secure` flag (HTTPS only)
|
|
351
|
-
- It **must** have `Path=/`
|
|
352
|
-
- It **must not** have a `Domain` attribute
|
|
353
|
-
|
|
354
|
-
This means the cookie is locked to the exact domain that set it. Without `__Host-`, an attacker controlling `evil.workers.dev` could set cookies for your `mcp-server.workers.dev` domain and potentially inject malicious CSRF tokens or approved client lists. The `__Host-` prefix prevents this by ensuring only your specific domain can set and read these cookies.
|
|
355
|
-
|
|
356
|
-
### Multiple OAuth clients on the same host
|
|
357
|
-
|
|
358
|
-
If you're running multiple OAuth flows on the same domain (e.g., GitHub OAuth and Google OAuth on the same worker), namespace your cookies to prevent collisions.
|
|
163
|
+
## Security checklist
|
|
359
164
|
|
|
360
|
-
|
|
165
|
+
| Protection | Provided by |
|
|
166
|
+
| --------------------------------------------------- | ---------------------------------------------- |
|
|
167
|
+
| Per-client consent before `completeAuthorization()` | `beginConsent()`, `approveConsent()` |
|
|
168
|
+
| Consent form cannot be framed or forged | `beginConsent()` headers and single-use handle |
|
|
169
|
+
| Client metadata escaped on the consent page | Your page template |
|
|
170
|
+
| Provider `state` bound to the browser | `beginUpstream()`, `finishUpstream()` |
|
|
171
|
+
| Tokens accepted only for this MCP server | `OAuthProvider` and `resourceMetadata` |
|
|
172
|
+
| Scopes checked for each operation | Your handler, with `insufficientScope()` |
|
|
361
173
|
|
|
362
174
|
# More info
|
|
363
175
|
|
|
364
|
-
- [
|
|
365
|
-
- [MCP
|
|
366
|
-
- [
|
|
367
|
-
- [RFC 9700 - Best Practices](https://www.rfc-editor.org/rfc/rfc9700#name-best-practices)
|
|
176
|
+
- [Workers OAuth Provider documentation](https://github.com/cloudflare/workers-oauth-provider#readme)
|
|
177
|
+
- [MCP Authorization](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
|
|
178
|
+
- [MCP Security Best Practices](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices)
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"bin": {
|
|
13
13
|
"agents": "./dist/cli.js"
|
|
14
14
|
},
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.31.0",
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
18
18
|
"directory": "packages/agents",
|
|
@@ -64,9 +64,8 @@
|
|
|
64
64
|
"highlight.js": "11.12.0",
|
|
65
65
|
"just-bash": "^3.0.2",
|
|
66
66
|
"react": "^19.2.7",
|
|
67
|
-
"teleproto": "^1.228.5",
|
|
68
67
|
"valibot": "1.4.2",
|
|
69
|
-
"zod": "^4.
|
|
68
|
+
"zod": "^4.6.7"
|
|
70
69
|
},
|
|
71
70
|
"peerDependencies": {
|
|
72
71
|
"@ai-sdk/provider": "^4.0.0",
|
|
@@ -109,6 +108,12 @@
|
|
|
109
108
|
"@earendil-works/pi-durable": {
|
|
110
109
|
"optional": true
|
|
111
110
|
},
|
|
111
|
+
"@modelcontextprotocol/sdk": {
|
|
112
|
+
"optional": true
|
|
113
|
+
},
|
|
114
|
+
"@modelcontextprotocol/server": {
|
|
115
|
+
"optional": true
|
|
116
|
+
},
|
|
112
117
|
"@opencode/plugin": {
|
|
113
118
|
"optional": true
|
|
114
119
|
},
|