agents 0.30.0 → 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 (130) hide show
  1. package/README.md +4 -2
  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 +7 -3
  51. package/dist/harness/pi/index.js +46 -15
  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/get-current-agent.md +4 -7
  104. package/docs/harnesses/pi.md +28 -28
  105. package/docs/human-in-the-loop.md +3 -7
  106. package/docs/index.md +6 -6
  107. package/docs/mcp-client.md +4 -28
  108. package/docs/mcp-servers.md +137 -411
  109. package/docs/mcp-transports.md +30 -289
  110. package/docs/models-pi-ai.md +9 -11
  111. package/docs/models.md +1 -8
  112. package/docs/routing.md +0 -2
  113. package/docs/securing-mcp-servers.md +120 -304
  114. package/package.json +8 -3
  115. package/dist/ai-sdk-DRPh2rRa.js.map +0 -1
  116. package/dist/browser-DccPw_wW.js.map +0 -1
  117. package/dist/browser-tool-B1mhifin.js +0 -616
  118. package/dist/browser-tool-B1mhifin.js.map +0 -1
  119. package/dist/browser-tool-Cxjk5sgG.d.ts +0 -92
  120. package/dist/client-BnYbNT3z.js.map +0 -1
  121. package/dist/connector-DAXBOIIi.js.map +0 -1
  122. package/dist/handler-stateless-Bly_nSH3.js.map +0 -1
  123. package/dist/images-xaDv2Fqe.js.map +0 -1
  124. package/dist/invoker-CG0_p_Wq.d.ts +0 -20
  125. package/dist/invoker-CHMnoxIA.js +0 -57
  126. package/dist/invoker-CHMnoxIA.js.map +0 -1
  127. package/dist/session-store-yvwq1f9x.js.map +0 -1
  128. package/dist/src-DfLzdwlK.js.map +0 -1
  129. package/dist/tool-helpers-O5BDVEAY.js +0 -58
  130. package/dist/tool-helpers-O5BDVEAY.js.map +0 -1
@@ -1,362 +1,178 @@
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
- const apiHandler = createMcpHandler(createServer);
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",
24
23
 
25
24
  apiRoute: "/mcp", // Protected MCP endpoint
26
- apiHandler: apiHandler, // Your MCP server
25
+ apiHandler: {
26
+ // OAuthProvider needs an object with a fetch method
27
+ fetch(request: Request, env: Env, ctx: ExecutionContext) {
28
+ return mcpHandler(request, env, ctx);
29
+ }
30
+ },
31
+
32
+ defaultHandler: AuthHandler, // Your /authorize page
27
33
 
28
- defaultHandler: AuthHandler // Handles consent flow
34
+ // The URL MCP clients connect to. Every token is bound to it.
35
+ resourceMetadata: { resource: env.MCP_RESOURCE }
29
36
  });
30
37
  ```
31
38
 
32
- 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.
33
-
34
- 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.
35
-
36
- 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.
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.
37
40
 
38
- ## `redirect_uri` validation
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.
39
42
 
40
- 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.
43
+ ## Consent page
41
44
 
42
- ## Consent dialog
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.
43
46
 
44
- 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
-
46
- ### CSRF Protection
47
-
48
- 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. |
49
54
 
50
55
  ```typescript
51
- // GET /authorize - Generate CSRF token when showing consent form
52
- app.get("/authorize", async (c) => {
53
- const { token: csrfToken, setCookie } = generateCSRFProtection();
54
-
55
- return renderConsentDialog(c.req.raw, {
56
- client: await c.env.OAUTH_PROVIDER.lookupClient(clientId),
57
- csrfToken, // Pass to form as hidden field
58
- setCookie // Set the cookie
59
- // ... other dialog data
60
- });
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
61
65
  });
62
66
 
63
- // POST /authorize - Validate CSRF token when user approves
64
- app.post("/authorize", async (c) => {
65
- const formData = await c.req.raw.formData();
66
-
67
- // Validate CSRF token exists and matches cookie
68
- const { clearCookie } = validateCSRFToken(formData, c.req.raw);
69
-
70
- // Then redirect to upstream provider and clear the CSRF with the clearCookie header
71
- });
72
-
73
- // Helper functions
74
- function generateCSRFProtection(): CSRFProtectionResult {
75
- const token = crypto.randomUUID();
76
- const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
77
- 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 });
78
73
  }
79
-
80
- function validateCSRFToken(
81
- formData: FormData,
82
- request: Request
83
- ): ValidateCSRFResult {
84
- const tokenFromForm = formData.get("csrf_token");
85
- const cookieHeader = request.headers.get("Cookie") || "";
86
- const tokenFromCookie = cookieHeader
87
- .split(";")
88
- .find((c) => c.trim().startsWith("__Host-CSRF_TOKEN="))
89
- ?.split("=")[1];
90
-
91
- if (!tokenFromForm || !tokenFromCookie || tokenFromForm !== tokenFromCookie) {
92
- throw new OAuthError("invalid_request", "CSRF token mismatch", 400);
93
- }
94
-
95
- // Clear cookie after use (one-time use per RFC 9700)
96
- return {
97
- clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`
98
- };
99
- }
100
- ```
101
-
102
- Include the token as a hidden field in your consent form:
103
-
104
- ```html
105
- <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 });
106
86
  ```
107
87
 
108
- ### 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.
109
89
 
110
- 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.
111
-
112
- **Required protections:**
113
-
114
- - **Client names/descriptions**: HTML-escape all text before rendering (escape `<`, `>`, `&`, `"`, `'`)
115
- - **Logo URLs**: Validate URL scheme (allow only `http:` and `https:`), reject `javascript:`, `data:`, `file:` schemes
116
- - **Client URIs**: Same as logo URLs - whitelist http/https only
117
- - **Scopes**: Treat as text, HTML-escape before display
118
-
119
- ```typescript
120
- function sanitizeText(text: string): string {
121
- return text
122
- .replace(/&/g, "&amp;")
123
- .replace(/</g, "&lt;")
124
- .replace(/>/g, "&gt;")
125
- .replace(/"/g, "&quot;")
126
- .replace(/'/g, "&#039;");
127
- }
128
-
129
- function sanitizeUrl(url: string): string {
130
- if (!url) return "";
131
- try {
132
- const parsed = new URL(url);
133
- if (!["http:", "https:"].includes(parsed.protocol)) {
134
- return ""; // Reject dangerous schemes
135
- }
136
- return url;
137
- } catch {
138
- return ""; // Invalid URL
139
- }
140
- }
141
-
142
- // Always sanitize before rendering
143
- const clientName = sanitizeText(client.clientName);
144
- const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));
145
- ```
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.
146
91
 
147
- ### Content Security Policy (CSP)
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.
148
93
 
149
- CSP headers instruct browsers to block dangerous content and behaviors. They provide defence in depth from multiple attack vectors.
94
+ ## Signing in through a third-party provider
150
95
 
151
- ```typescript
152
- function buildSecurityHeaders(setCookie: string, nonce?: string): HeadersInit {
153
- const cspDirectives = [
154
- "default-src 'none'", // Deny everything by default
155
- "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""), // Allow scripts from same origin (+ nonce if using inline JS)
156
- "style-src 'self' 'unsafe-inline'", // Allow inline styles for rendering
157
- "img-src 'self' https:", // Allow client logos from HTTPS URLs
158
- "font-src 'self'", // Allow web fonts from same origin
159
- "form-action 'self'", // Only allow form submissions to same origin
160
- "frame-ancestors 'none'", // Prevent clickjacking - block ALL iframe embedding
161
- "base-uri 'self'", // Prevent base tag injection attacks
162
- "connect-src 'self'" // Restrict fetch/XHR to same origin
163
- ].join("; ");
164
-
165
- return {
166
- "Content-Security-Policy": cspDirectives,
167
- "X-Frame-Options": "DENY", // Legacy clickjacking protection for older browsers
168
- "X-Content-Type-Options": "nosniff", // Prevent MIME sniffing attacks
169
- "Content-Type": "text/html; charset=utf-8",
170
- "Set-Cookie": setCookie
171
- };
172
- }
173
- ```
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.
174
97
 
175
- ### Inline JavaScript
98
+ Ask for consent first, then hand the user to the provider:
176
99
 
177
- If your consent dialog needs inline JavaScript, use data attributes and nonces to prevent XSS attacks.
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. |
178
105
 
179
106
  ```typescript
180
- // Generate a unique nonce per request
181
- const nonce = crypto.randomUUID();
182
-
183
- const htmlContent = `
184
- <!DOCTYPE html>
185
- <html>
186
- <body>
187
- <p>Authorization approved! Redirecting...</p>
188
- <script nonce="${nonce}" data-redirect-url="${sanitizeUrl(redirectUrl)}">
189
- setTimeout(() => {
190
- const script = document.querySelector('script[data-redirect-url]');
191
- window.location.href = script.dataset.redirectUrl;
192
- }, 2000);
193
- </script>
194
- </body>
195
- </html>
196
- `;
197
-
198
- return new Response(htmlContent, {
199
- 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
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
200
132
  });
133
+ clear.set("Location", redirectTo);
134
+ return new Response(null, { status: 302, headers: clear });
201
135
  ```
202
136
 
203
- - **Data attributes** store user-controlled data (like URLs) separately from JavaScript code, ensuring they're always treated as strings, never as executable code
204
- - **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.
205
138
 
206
- 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
207
140
 
208
- ## Handling State
141
+ `OAuthProvider` accepts only tokens issued for `resourceMetadata.resource`, so keep `MCP_RESOURCE` exactly the URL clients connect to.
209
142
 
210
- 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):
211
144
 
212
145
  ```typescript
213
- // Use in POST /authorize - after CSRF validation, before redirecting to upstream provider
214
- // Firstly create a state token in KV
215
- async function createOAuthState(
216
- oauthReqInfo: AuthRequest,
217
- kv: KVNamespace
218
- ): Promise<{ stateToken: string }> {
219
- const stateToken = crypto.randomUUID();
220
- await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {
221
- expirationTtl: 600 // 10 minutes
222
- });
223
- return { stateToken };
224
- }
225
-
226
- // Bind state to browser session
227
- async function bindStateToSession(
228
- stateToken: string
229
- ): Promise<{ setCookie: string }> {
230
- const consentedStateCookieName = "__Host-CONSENTED_STATE";
231
-
232
- // Hash the state token to create a derived parameter
233
- const encoder = new TextEncoder();
234
- const data = encoder.encode(stateToken);
235
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
236
- const hashArray = Array.from(new Uint8Array(hashBuffer));
237
- const hashHex = hashArray
238
- .map((b) => b.toString(16).padStart(2, "0"))
239
- .join("");
240
-
241
- const setCookie = `${consentedStateCookieName}=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;
242
- return { setCookie };
243
- }
146
+ import {
147
+ insufficientScope,
148
+ type OAuthResourceContext
149
+ } from "@cloudflare/workers-oauth-provider";
244
150
 
245
- // In the GET /callback - validate state from query params against both the KV and the session cookie before exchanging code
246
- async function validateOAuthState(
247
- request: Request,
248
- kv: KVNamespace
249
- ): Promise<{ oauthReqInfo: AuthRequest; clearCookie: string }> {
250
- const consentedStateCookieName = "__Host-CONSENTED_STATE";
251
- const url = new URL(request.url);
252
- const stateFromQuery = url.searchParams.get("state");
253
-
254
- if (!stateFromQuery) {
255
- throw new OAuthError("invalid_request", "Missing state parameter", 400);
256
- }
151
+ type Props = { userId: string }; // what you passed to completeAuthorization()
257
152
 
258
- // Check 1: Validate state exists in KV
259
- const storedDataJson = await kv.get(`oauth:state:${stateFromQuery}`);
260
- if (!storedDataJson) {
261
- throw new OAuthError("invalid_request", "Invalid or expired state", 400);
262
- }
263
-
264
- // Check 2: Validate state matches session cookie
265
- const cookieHeader = request.headers.get("Cookie") || "";
266
- const cookies = cookieHeader.split(";").map((c) => c.trim());
267
- const consentedStateCookie = cookies.find((c) =>
268
- c.startsWith(`${consentedStateCookieName}=`)
269
- );
270
- const consentedStateHash = consentedStateCookie
271
- ? consentedStateCookie.substring(consentedStateCookieName.length + 1)
272
- : null;
273
-
274
- if (!consentedStateHash) {
275
- throw new OAuthError(
276
- "invalid_request",
277
- "Missing session binding cookie - authorization flow must be restarted",
278
- 400
279
- );
280
- }
281
-
282
- // Hash the state from query and compare with cookie
283
- const encoder = new TextEncoder();
284
- const data = encoder.encode(stateFromQuery);
285
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
286
- const hashArray = Array.from(new Uint8Array(hashBuffer));
287
- const stateHash = hashArray
288
- .map((b) => b.toString(16).padStart(2, "0"))
289
- .join("");
290
-
291
- if (stateHash !== consentedStateHash) {
292
- throw new OAuthError(
293
- "invalid_request",
294
- "State token does not match session - possible CSRF attack detected",
295
- 400
296
- );
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);
297
159
  }
298
-
299
- // Both checks passed - clean up KV and return the clear cookie header
300
- await kv.delete(`oauth:state:${stateFromQuery}`);
301
- const clearCookie = `${consentedStateCookieName}=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`;
302
-
303
- return {
304
- oauthReqInfo: JSON.parse(storedDataJson),
305
- clearCookie
306
- };
307
- }
308
- ```
309
-
310
- ## Approved client
311
-
312
- 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.
313
-
314
- ```typescript
315
- // Use in POST /authorize - after user approves, add client to approved list
316
- export async function addApprovedClient(
317
- request: Request,
318
- clientId: string,
319
- cookieSecret: string
320
- ): Promise<string> {
321
- const existingApprovedClients =
322
- (await getApprovedClientsFromCookie(request, cookieSecret)) || [];
323
- const updatedApprovedClients = Array.from(
324
- new Set([...existingApprovedClients, clientId])
325
- );
326
-
327
- const payload = JSON.stringify(updatedApprovedClients);
328
- const signature = await signData(payload, cookieSecret); // HMAC-SHA256
329
- const cookieValue = `${signature}.${btoa(payload)}`;
330
-
331
- return `__Host-APPROVED_CLIENTS=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`;
332
- }
160
+ },
333
161
  ```
334
162
 
335
- 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.
336
-
337
- ## Cookies
338
-
339
- ### Why `__Host-` prefix?
340
-
341
- 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.
342
-
343
- The `__Host-` prefix is a security feature that prevents subdomain attacks. When you set a cookie with this prefix:
344
-
345
- - It **must** be set with the `Secure` flag (HTTPS only)
346
- - It **must** have `Path=/`
347
- - It **must not** have a `Domain` attribute
348
-
349
- 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.
350
-
351
- ### Multiple OAuth clients on the same host
352
-
353
- 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
354
164
 
355
- 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()` |
356
173
 
357
174
  # More info
358
175
 
359
- - [MCP Authorization](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
360
- - [MCP Security Best Practices](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices)
361
- - [RFC 9700 - Protecting Redirect Based Flows](https://www.rfc-editor.org/rfc/rfc9700#name-protecting-redirect-based-f)
362
- - [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.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.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
  },