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.
Files changed (126) hide show
  1. package/README.md +2 -1
  2. package/dist/{agent-routing-CDbpVjYq.d.ts → agent-routing-wS1TimZ_.d.ts} +64 -603
  3. package/dist/agent-routing.d.ts +1 -1
  4. package/dist/agent-tool-types.d.ts +1 -1
  5. package/dist/{agent-tools-C8DH8Yii.d.ts → agent-tools-Bamxxb6Y.d.ts} +2 -2
  6. package/dist/agent-tools.d.ts +1 -1
  7. package/dist/{ai-iXw0uOGj.d.ts → ai-CDn3Abax.d.ts} +30 -4
  8. package/dist/{ai-sdk-DRPh2rRa.js → ai-sdk-Kon40i0q.js} +2 -36
  9. package/dist/ai-sdk-Kon40i0q.js.map +1 -0
  10. package/dist/ai-sdk-hW3vntnk.d.ts +72 -0
  11. package/dist/browser/ai.d.ts +1 -1
  12. package/dist/browser/ai.js +16 -3
  13. package/dist/browser/ai.js.map +1 -1
  14. package/dist/browser/index.d.ts +64 -16
  15. package/dist/browser/index.js +4 -4
  16. package/dist/browser/tools/ai-sdk.d.ts +7 -70
  17. package/dist/browser/tools/ai-sdk.js +4 -4
  18. package/dist/browser/tools/ai-sdk.js.map +1 -1
  19. package/dist/browser/tools/pi.d.ts +9 -15
  20. package/dist/browser/tools/pi.js +6 -14
  21. package/dist/browser/tools/pi.js.map +1 -1
  22. package/dist/browser/tools/tanstack-ai.d.ts +46 -23
  23. package/dist/browser/tools/tanstack-ai.js +8 -4
  24. package/dist/browser/tools/tanstack-ai.js.map +1 -1
  25. package/dist/{browser-DccPw_wW.js → browser-1eUbPvC2.js} +413 -14
  26. package/dist/browser-1eUbPvC2.js.map +1 -0
  27. package/dist/{browser-DlXhSAdf.d.ts → browser-D9RNpJuT.d.ts} +254 -20
  28. package/dist/browser-tool-BhjCcNVE.d.ts +71 -0
  29. package/dist/browser-tool-lGnvEBLJ.js +497 -0
  30. package/dist/browser-tool-lGnvEBLJ.js.map +1 -0
  31. package/dist/capability-id-B3J8Nok_.js +13 -0
  32. package/dist/capability-id-B3J8Nok_.js.map +1 -0
  33. package/dist/chat/index.d.ts +2 -2
  34. package/dist/chat-sdk/index.d.ts +3 -3
  35. package/dist/chat-sdk/index.js +1 -1
  36. package/dist/client-4OlMIJx-.js.map +1 -1
  37. package/dist/{client-BnYbNT3z.js → client-DMWujQJ1.js} +68 -244
  38. package/dist/client-DMWujQJ1.js.map +1 -0
  39. package/dist/{client-C27CtRZr.d.ts → client-wn4-yqGz.d.ts} +9 -7
  40. package/dist/client.d.ts +1 -1
  41. package/dist/{connector-DAXBOIIi.js → connector-CM-uuxKM.js} +96 -8
  42. package/dist/connector-CM-uuxKM.js.map +1 -0
  43. package/dist/{connector-20DJ6p4k.d.ts → connector-DZ71EjE-.d.ts} +109 -9
  44. package/dist/experimental/webmcp.js +1 -1
  45. package/dist/{handler-stateless-Bly_nSH3.js → handler-stateless-B5hU3dxK.js} +35 -2
  46. package/dist/handler-stateless-B5hU3dxK.js.map +1 -0
  47. package/dist/{handler-stateless-DxYpJ_XF.d.ts → handler-stateless-C90kD5_R.d.ts} +3 -47
  48. package/dist/harness/container/index.d.ts +15 -15
  49. package/dist/harness/opencode/index.js.map +1 -1
  50. package/dist/harness/pi/index.d.ts +4 -3
  51. package/dist/harness/pi/index.js +31 -10
  52. package/dist/harness/pi/index.js.map +1 -1
  53. package/dist/harness/think/index.d.ts +2 -1
  54. package/dist/{images-xaDv2Fqe.js → images-CfuRCjwq.js} +1 -5
  55. package/dist/images-CfuRCjwq.js.map +1 -0
  56. package/dist/{index-DCxk3OSJ.d.ts → index-8k4qsX1T.d.ts} +2 -28
  57. package/dist/index.d.ts +14 -14
  58. package/dist/index.js +2 -2
  59. package/dist/internal-hosts-Kfp5A392.d.ts +230 -0
  60. package/dist/internal-kmw70xwR.js.map +1 -1
  61. package/dist/invoker-BC1SZ3q1.js +26 -0
  62. package/dist/invoker-BC1SZ3q1.js.map +1 -0
  63. package/dist/invoker-DoLFPJZV.d.ts +21 -0
  64. package/dist/mcp/client/index.d.ts +15 -1
  65. package/dist/mcp/client/index.js +2 -2
  66. package/dist/mcp/client/x402.d.ts +7 -5
  67. package/dist/mcp/client/x402.js +34 -1
  68. package/dist/mcp/client/x402.js.map +1 -1
  69. package/dist/mcp/index.d.ts +561 -27
  70. package/dist/mcp/index.js +180 -3
  71. package/dist/mcp/index.js.map +1 -1
  72. package/dist/mcp/server/index.d.ts +1 -1
  73. package/dist/mcp/server/index.js +1 -1
  74. package/dist/models/ai-sdk/index.d.ts +2 -2
  75. package/dist/models/ai-sdk/index.js +1 -1
  76. package/dist/models/opencode/index.d.ts +2 -2
  77. package/dist/models/opencode/index.js +1 -1
  78. package/dist/models/pi-ai/index.d.ts +3 -28
  79. package/dist/models/pi-ai/index.js +31 -16
  80. package/dist/models/pi-ai/index.js.map +1 -1
  81. package/dist/protocol-BEgrJYC5.d.ts +29 -29
  82. package/dist/react.d.ts +2 -2
  83. package/dist/routing/index.d.ts +1 -1
  84. package/dist/serializable.d.ts +1 -1
  85. package/dist/{session-store-yvwq1f9x.js → session-7CDgVE0z.js} +455 -33
  86. package/dist/session-7CDgVE0z.js.map +1 -0
  87. package/dist/{session-store-_IK6dV-w.d.ts → session-store-B7Ca-u1t.d.ts} +8 -1
  88. package/dist/settings-Dk2il2Hf.js.map +1 -1
  89. package/dist/{src-DfLzdwlK.js → src-BGolXG_H.js} +18 -2
  90. package/dist/src-BGolXG_H.js.map +1 -0
  91. package/dist/sub-routing.d.ts +12 -12
  92. package/dist/tool-CTp72Jb_.d.ts +10 -10
  93. package/dist/tool-DoVberpN.d.ts +22 -22
  94. package/dist/tool-helpers-BodWi4gd.js +256 -0
  95. package/dist/tool-helpers-BodWi4gd.js.map +1 -0
  96. package/dist/{transport-CPh7YGiw.d.ts → transport-5moD2pW5.d.ts} +1 -43
  97. package/dist/types-D2naO3rA.d.ts +49 -0
  98. package/dist/voice/index.d.ts +1 -1
  99. package/dist/workflow-types.d.ts +1 -1
  100. package/dist/workflows.d.ts +1 -1
  101. package/dist/workflows.js +1 -1
  102. package/docs/browse-the-web.md +247 -413
  103. package/docs/harnesses/pi.md +27 -27
  104. package/docs/index.md +4 -4
  105. package/docs/mcp-client.md +2 -0
  106. package/docs/mcp-servers.md +25 -6
  107. package/docs/models-pi-ai.md +9 -11
  108. package/docs/models.md +1 -8
  109. package/docs/securing-mcp-servers.md +113 -302
  110. package/package.json +8 -3
  111. package/dist/ai-sdk-DRPh2rRa.js.map +0 -1
  112. package/dist/browser-DccPw_wW.js.map +0 -1
  113. package/dist/browser-tool-B1mhifin.js +0 -616
  114. package/dist/browser-tool-B1mhifin.js.map +0 -1
  115. package/dist/browser-tool-Cxjk5sgG.d.ts +0 -92
  116. package/dist/client-BnYbNT3z.js.map +0 -1
  117. package/dist/connector-DAXBOIIi.js.map +0 -1
  118. package/dist/handler-stateless-Bly_nSH3.js.map +0 -1
  119. package/dist/images-xaDv2Fqe.js.map +0 -1
  120. package/dist/invoker-CG0_p_Wq.d.ts +0 -20
  121. package/dist/invoker-CHMnoxIA.js +0 -57
  122. package/dist/invoker-CHMnoxIA.js.map +0 -1
  123. package/dist/session-store-yvwq1f9x.js.map +0 -1
  124. package/dist/src-DfLzdwlK.js.map +0 -1
  125. package/dist/tool-helpers-O5BDVEAY.js +0 -58
  126. package/dist/tool-helpers-O5BDVEAY.js.map +0 -1
@@ -1,23 +1,22 @@
1
1
  # Securing MCP Servers
2
2
 
3
- Model Context Protocol servers, like every other web application, need to be secured so they can be used by trusted users without abuse. The MCP spec uses the OAuth 2.1 standard for authentication between MCP clients and servers.
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
- Cloudflare's `workers-oauth-provider` lets you secure your MCP Server (or any application) running on a Cloudflare Worker. The provider handles token management, client registration, and access token validation automatically.
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, OAuthError } from "@cloudflare/workers-oauth-provider";
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
- // Wrap with OAuth protection
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 // Handles consent flow
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
- For provider-issued tokens, SDK v2 tool callbacks receive standard metadata at `context.http.authInfo`, while `getMcpAuthContext().props` retains the existing application-props shape. Treat `authInfo.token` and `authInfo.extra.props` as sensitive and do not log or return them.
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 `workers-oauth-provider` package handles this automatically. It validates that the `redirect_uri` in the authorization request matches one of the registered redirect URIs for the client. This prevents attackers from redirecting authorization codes to their own endpoints.
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 dialog
43
+ ## Consent page
48
44
 
49
- When your MCP server acts as an OAuth proxy to third-party providers (like Google, GitHub, etc.), you must implement your own consent dialog before forwarding users to the upstream authorization server. This prevents the ["confused deputy"](https://en.wikipedia.org/wiki/Confused_deputy_problem) problem where attackers could exploit cached consent from the third-party provider to gain unauthorized access. Your consent dialog should clearly identify the requesting MCP client by name and display the specific scopes being requested. Implementing this consent flow requires thinking about a few security concerns.
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
- ### CSRF Protection
52
-
53
- Without CSRF protection, an attacker can trick users into approving malicious OAuth clients. Use a random token stored in a secure cookie and validate it on form submission.
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
- // GET /authorize - Generate CSRF token when showing consent form
57
- app.get("/authorize", async (c) => {
58
- const { token: csrfToken, setCookie } = generateCSRFProtection();
59
-
60
- return renderConsentDialog(c.req.raw, {
61
- client: await c.env.OAUTH_PROVIDER.lookupClient(clientId),
62
- csrfToken, // Pass to form as hidden field
63
- setCookie // Set the cookie
64
- // ... other dialog data
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 - Validate CSRF token when user approves
69
- app.post("/authorize", async (c) => {
70
- const formData = await c.req.raw.formData();
71
-
72
- // Validate CSRF token exists and matches cookie
73
- const { clearCookie } = validateCSRFToken(formData, c.req.raw);
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
- function validateCSRFToken(
86
- formData: FormData,
87
- request: Request
88
- ): ValidateCSRFResult {
89
- const tokenFromForm = formData.get("csrf_token");
90
- const cookieHeader = request.headers.get("Cookie") || "";
91
- const tokenFromCookie = cookieHeader
92
- .split(";")
93
- .find((c) => c.trim().startsWith("__Host-CSRF_TOKEN="))
94
- ?.split("=")[1];
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
- ### Input sanitization
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
- User-controlled content (client names, logos, URIs) in your consent dialog can execute malicious scripts if not sanitized. Client registration is dynamic, so you must treat all client metadata as untrusted input.
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
- **Required protections:**
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
- - **Client names/descriptions**: HTML-escape all text before rendering (escape `<`, `>`, `&`, `"`, `'`)
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
- ```typescript
125
- function sanitizeText(text: string): string {
126
- return text
127
- .replace(/&/g, "&amp;")
128
- .replace(/</g, "&lt;")
129
- .replace(/>/g, "&gt;")
130
- .replace(/"/g, "&quot;")
131
- .replace(/'/g, "&#039;");
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
- function sanitizeUrl(url: string): string {
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
- // Always sanitize before rendering
148
- const clientName = sanitizeText(client.clientName);
149
- const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));
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
- function buildSecurityHeaders(setCookie: string, nonce?: string): HeadersInit {
158
- const cspDirectives = [
159
- "default-src 'none'", // Deny everything by default
160
- "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""), // Allow scripts from same origin (+ nonce if using inline JS)
161
- "style-src 'self' 'unsafe-inline'", // Allow inline styles for rendering
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
- - **Data attributes** store user-controlled data (like URLs) separately from JavaScript code, ensuring they're always treated as strings, never as executable code
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
- Note: Frameworks such as Vite will automatically handle nonce generation and insertion for you. See their docs on [Content Security Policy](https://vite.dev/guide/features.html#content-security-policy-csp) for more information.
139
+ ## Checks on the MCP server
212
140
 
213
- ## Handling State
141
+ `OAuthProvider` accepts only tokens issued for `resourceMetadata.resource`, so keep `MCP_RESOURCE` exactly the URL clients connect to.
214
142
 
215
- Between the consent dialog and the callback there is a gap where the user could do something nasty. We need to make sure it is the same user that hits authorize and then reaches back to our callback. Use a random state token stored server-side in KV with a short expiration time.
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
- // Use in POST /authorize - after CSRF validation, before redirecting to upstream provider
219
- // Firstly create a state token in KV
220
- async function createOAuthState(
221
- oauthReqInfo: AuthRequest,
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
- // Bind state to browser session
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
- // Check 1: Validate state exists in KV
264
- const storedDataJson = await kv.get(`oauth:state:${stateFromQuery}`);
265
- if (!storedDataJson) {
266
- throw new OAuthError("invalid_request", "Invalid or expired state", 400);
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
- ## Approved client
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
- Instead of `__Host-CSRF_TOKEN`, use `__Host-CSRF_TOKEN_GITHUB` and `__Host-CSRF_TOKEN_GOOGLE`. Same applies for approved clients: `__Host-APPROVED_CLIENTS_GITHUB` vs `__Host-APPROVED_CLIENTS_GOOGLE`. This ensures each OAuth flow maintains isolated state.
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
- - [MCP Authorization](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
365
- - [MCP Security Best Practices](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices)
366
- - [RFC 9700 - Protecting Redirect Based Flows](https://www.rfc-editor.org/rfc/rfc9700#name-protecting-redirect-based-f)
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.30.1",
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.5.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
  },