Skip to content

Commit c9b76cd

Browse files
Add jurisdiction support to MCP agent and handlers (#607)
* Add jurisdiction support to MCP agent and handlers Introduces a `jurisdiction` option to MCP agent server and streaming/SSE handlers, allowing Durable Object instances to be created in specific geographic regions for compliance (e.g., GDPR). Documentation updated to explain usage and available jurisdictions. * Add jurisdiction tests for McpAgent Introduces tests to verify the jurisdiction option in McpAgent.serve(), ensuring the parameter is correctly passed to Durable Object creation. Adds TestMcpJurisdiction agent and updates wrangler config to register the new Durable Object for testing. * Create pretty-swans-smash.md * fix types * removed some warnings
1 parent e75ec71 commit c9b76cd

File tree

9 files changed

+464
-32
lines changed

9 files changed

+464
-32
lines changed

.changeset/pretty-swans-smash.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"agents": patch
3+
---
4+
5+
Add jurisdiction support to MCP agent and handlers
6+
7+
Introduces a `jurisdiction` option to MCP agent server and streaming/SSE handlers, allowing Durable Object instances to be created in specific geographic regions for compliance (e.g., GDPR). Documentation updated to explain usage and available jurisdictions.

docs/mcp-servers.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,49 @@ The auth flow prompts us for the password.
269269
![model calls all 3 tools after authorization](https://github.com/user-attachments/assets/07e22fef-93de-47c2-af7e-9c361e460186)
270270
Once we've authenticated ourselves we can use all the tools!
271271

272+
## Data Jurisdiction for Compliance
273+
274+
`McpAgent` supports specifying a data jurisdiction for your MCP server, which is particularly useful for satisfying GDPR and other data residency regulations. By setting the `jurisdiction` option, you can ensure that your Durable Object instances (and their data) are created in a specific geographic region.
275+
276+
### Using the EU Jurisdiction for GDPR
277+
278+
To comply with GDPR requirements, you can specify the `"eu"` jurisdiction to ensure that all data processed by your MCP server remains within the European Union:
279+
280+
```typescript
281+
export default TinyMcp.serve("/", {
282+
jurisdiction: "eu"
283+
});
284+
```
285+
286+
Or with the OAuth-protected example:
287+
288+
```typescript
289+
export default new OAuthProvider({
290+
authorizeEndpoint: "/authorize",
291+
tokenEndpoint: "/token",
292+
clientRegistrationEndpoint: "/register",
293+
apiHandlers: {
294+
"/mcp": StorageMcp.serve("/mcp", { jurisdiction: "eu" })
295+
},
296+
defaultHandler
297+
});
298+
```
299+
300+
When you specify `jurisdiction: "eu"`, Cloudflare will create the Durable Object instances in EU data centers, ensuring that:
301+
302+
- All MCP session data stays within the EU
303+
- User data processed by your tools remains in the EU
304+
- State stored in the Durable Object's storage API stays in the EU
305+
306+
This helps you comply with GDPR's data localization requirements without any additional configuration.
307+
308+
### Available Jurisdictions
309+
310+
The `jurisdiction` option accepts any value supported by [Cloudflare's Durable Objects jurisdiction API](https://developers.cloudflare.com/durable-objects/reference/data-location/), including:
311+
312+
- `"eu"` - European Union
313+
- `"fedramp"` - FedRAMP compliant locations
314+
272315
### Read more
273316

274317
To find out how to use your favorite providers for your authorization flow and more complex examples, have a look at the demos [here](https://github.com/cloudflare/ai/tree/main/demos).

packages/agents/src/mcp/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,8 @@ export abstract class McpAgent<
358358
{
359359
binding = "MCP_OBJECT",
360360
corsOptions,
361-
transport = "streamable-http"
361+
transport = "streamable-http",
362+
jurisdiction
362363
}: ServeOptions = {}
363364
) {
364365
return {
@@ -399,17 +400,16 @@ export abstract class McpAgent<
399400
const handleStreamableHttp = createStreamingHttpHandler(
400401
path,
401402
namespace,
402-
corsOptions
403+
{ corsOptions, jurisdiction }
403404
);
404405
return handleStreamableHttp(request, ctx);
405406
}
406407
case "sse": {
407408
// Legacy SSE transport handling
408-
const handleLegacySse = createLegacySseHandler(
409-
path,
410-
namespace,
411-
corsOptions
412-
);
409+
const handleLegacySse = createLegacySseHandler(path, namespace, {
410+
corsOptions,
411+
jurisdiction
412+
});
413413
return handleLegacySse(request, ctx);
414414
}
415415
default:

packages/agents/src/mcp/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export interface ServeOptions {
1616
binding?: string;
1717
corsOptions?: CORSOptions;
1818
transport?: BaseTransportType;
19+
jurisdiction?: DurableObjectJurisdiction;
1920
}

packages/agents/src/mcp/utils.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ const MAXIMUM_MESSAGE_SIZE_BYTES = 4 * 1024 * 1024; // 4MB
3030
export const createStreamingHttpHandler = (
3131
basePath: string,
3232
namespace: DurableObjectNamespace<McpAgent>,
33-
corsOptions?: CORSOptions
33+
options: {
34+
corsOptions?: CORSOptions;
35+
jurisdiction?: DurableObjectJurisdiction;
36+
} = {}
3437
) => {
3538
let pathname = basePath;
3639
if (basePath === "/") pathname = "/*";
@@ -191,7 +194,10 @@ export const createStreamingHttpHandler = (
191194
const agent = await getAgentByName(
192195
namespace,
193196
`streamable-http:${sessionId}`,
194-
{ props: ctx.props as Record<string, unknown> | undefined }
197+
{
198+
props: ctx.props as Record<string, unknown> | undefined,
199+
jurisdiction: options.jurisdiction
200+
}
195201
);
196202
const isInitialized = await agent.getInitializeRequest();
197203

@@ -314,7 +320,7 @@ export const createStreamingHttpHandler = (
314320
ws.close();
315321

316322
return new Response(null, {
317-
headers: corsHeaders(request, corsOptions),
323+
headers: corsHeaders(request, options.corsOptions),
318324
status: 202
319325
});
320326
}
@@ -327,7 +333,7 @@ export const createStreamingHttpHandler = (
327333
Connection: "keep-alive",
328334
"Content-Type": "text/event-stream",
329335
"mcp-session-id": sessionId,
330-
...corsHeaders(request, corsOptions)
336+
...corsHeaders(request, options.corsOptions)
331337
},
332338
status: 200
333339
});
@@ -370,7 +376,10 @@ export const createStreamingHttpHandler = (
370376
const agent = await getAgentByName(
371377
namespace,
372378
`streamable-http:${sessionId}`,
373-
{ props: ctx.props as Record<string, unknown> | undefined }
379+
{
380+
props: ctx.props as Record<string, unknown> | undefined,
381+
jurisdiction: options.jurisdiction
382+
}
374383
);
375384
const isInitialized = await agent.getInitializeRequest();
376385
if (!isInitialized) {
@@ -444,7 +453,7 @@ export const createStreamingHttpHandler = (
444453
Connection: "keep-alive",
445454
"Content-Type": "text/event-stream",
446455
"mcp-session-id": sessionId,
447-
...corsHeaders(request, corsOptions)
456+
...corsHeaders(request, options.corsOptions)
448457
},
449458
status: 200
450459
});
@@ -460,12 +469,13 @@ export const createStreamingHttpHandler = (
460469
},
461470
id: null
462471
}),
463-
{ status: 400, headers: corsHeaders(request, corsOptions) }
472+
{ status: 400, headers: corsHeaders(request, options.corsOptions) }
464473
);
465474
}
466475
const agent = await getAgentByName(
467476
namespace,
468-
`streamable-http:${sessionId}`
477+
`streamable-http:${sessionId}`,
478+
{ jurisdiction: options.jurisdiction }
469479
);
470480
const isInitialized = await agent.getInitializeRequest();
471481
if (!isInitialized) {
@@ -475,7 +485,7 @@ export const createStreamingHttpHandler = (
475485
error: { code: -32001, message: "Session not found" },
476486
id: null
477487
}),
478-
{ status: 404, headers: corsHeaders(request, corsOptions) }
488+
{ status: 404, headers: corsHeaders(request, options.corsOptions) }
479489
);
480490
}
481491
// .destroy() passes an uncatchable Error, so we make sure we first return
@@ -487,7 +497,7 @@ export const createStreamingHttpHandler = (
487497
);
488498
return new Response(null, {
489499
status: 204,
490-
headers: corsHeaders(request, corsOptions)
500+
headers: corsHeaders(request, options.corsOptions)
491501
});
492502
}
493503
}
@@ -508,7 +518,10 @@ export const createStreamingHttpHandler = (
508518
export const createLegacySseHandler = (
509519
basePath: string,
510520
namespace: DurableObjectNamespace<McpAgent>,
511-
corsOptions?: CORSOptions
521+
options: {
522+
corsOptions?: CORSOptions;
523+
jurisdiction?: DurableObjectJurisdiction;
524+
} = {}
512525
) => {
513526
let pathname = basePath;
514527
if (basePath === "/") pathname = "/*";
@@ -540,7 +553,8 @@ export const createLegacySseHandler = (
540553

541554
// Get the Durable Object
542555
const agent = await getAgentByName(namespace, `sse:${sessionId}`, {
543-
props: ctx.props as Record<string, unknown> | undefined
556+
props: ctx.props as Record<string, unknown> | undefined,
557+
jurisdiction: options.jurisdiction
544558
});
545559

546560
// Connect to the Durable Object via WebSocket
@@ -626,7 +640,7 @@ export const createLegacySseHandler = (
626640
"Cache-Control": "no-cache",
627641
Connection: "keep-alive",
628642
"Content-Type": "text/event-stream",
629-
...corsHeaders(request, corsOptions)
643+
...corsHeaders(request, options.corsOptions)
630644
}
631645
});
632646
}
@@ -663,7 +677,8 @@ export const createLegacySseHandler = (
663677

664678
// Get the Durable Object
665679
const agent = await getAgentByName(namespace, `sse:${sessionId}`, {
666-
props: ctx.props as Record<string, unknown> | undefined
680+
props: ctx.props as Record<string, unknown> | undefined,
681+
jurisdiction: options.jurisdiction
667682
});
668683

669684
const messageBody = await request.json();
@@ -675,7 +690,7 @@ export const createLegacySseHandler = (
675690
"Cache-Control": "no-cache",
676691
Connection: "keep-alive",
677692
"Content-Type": "text/event-stream",
678-
...corsHeaders(request, corsOptions)
693+
...corsHeaders(request, options.corsOptions)
679694
},
680695
status: 400
681696
});
@@ -686,7 +701,7 @@ export const createLegacySseHandler = (
686701
"Cache-Control": "no-cache",
687702
Connection: "keep-alive",
688703
"Content-Type": "text/event-stream",
689-
...corsHeaders(request, corsOptions)
704+
...corsHeaders(request, options.corsOptions)
690705
},
691706
status: 202
692707
});

packages/agents/src/tests/mcp/handler.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@ import { createExecutionContext, env } from "cloudflare:test";
22
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
33
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
44
import { describe, expect, it } from "vitest";
5-
import { experimental_createMcpHandler } from "../../mcp/handler";
5+
import { createMcpHandler } from "../../mcp/handler";
66
import { z } from "zod";
77

88
declare module "cloudflare:test" {
99
interface ProvidedEnv {}
1010
}
1111

1212
/**
13-
* Tests for experimental_createMcpHandler
13+
* Tests for createMcpHandler
1414
* The handler primarily passes options to WorkerTransport and handles routing
1515
* Detailed CORS and protocol version behavior is tested in worker-transport.test.ts
1616
*/
17-
describe("experimental_createMcpHandler", () => {
17+
describe("createMcpHandler", () => {
1818
const createTestServer = () => {
1919
const server = new McpServer(
2020
{ name: "test-server", version: "1.0.0" },
@@ -36,7 +36,7 @@ describe("experimental_createMcpHandler", () => {
3636
describe("Route matching", () => {
3737
it("should only handle requests matching the configured route", async () => {
3838
const server = createTestServer();
39-
const handler = experimental_createMcpHandler(server, {
39+
const handler = createMcpHandler(server, {
4040
route: "/custom-mcp"
4141
});
4242

@@ -59,7 +59,7 @@ describe("experimental_createMcpHandler", () => {
5959

6060
it("should use default route /mcp when not specified", async () => {
6161
const server = createTestServer();
62-
const handler = experimental_createMcpHandler(server);
62+
const handler = createMcpHandler(server);
6363

6464
const ctx = createExecutionContext();
6565
const request = new Request("http://example.com/mcp", {
@@ -75,7 +75,7 @@ describe("experimental_createMcpHandler", () => {
7575
describe("Options passing - verification via behavior", () => {
7676
it("should apply custom CORS options", async () => {
7777
const server = createTestServer();
78-
const handler = experimental_createMcpHandler(server, {
78+
const handler = createMcpHandler(server, {
7979
route: "/mcp",
8080
corsOptions: {
8181
origin: "https://example.com",
@@ -103,7 +103,7 @@ describe("experimental_createMcpHandler", () => {
103103
describe("Integration - Basic functionality", () => {
104104
it("should handle initialization request end-to-end", async () => {
105105
const server = createTestServer();
106-
const handler = experimental_createMcpHandler(server, {
106+
const handler = createMcpHandler(server, {
107107
route: "/mcp"
108108
});
109109

0 commit comments

Comments
 (0)