Skip to content

Commit edc331c

Browse files
committed
fix: guide legacy SSE clients to Streamable HTTP
1 parent 89b1447 commit edc331c

9 files changed

Lines changed: 208 additions & 12 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'cloudflare-ai-gateway-mcp-server': patch
3+
'auditlogs': patch
4+
'cloudflare-autorag-mcp-server': patch
5+
'cloudflare-browser-mcp-server': patch
6+
'cloudflare-blog': patch
7+
'cloudflare-casb-mcp-server': patch
8+
'demo-day': patch
9+
'dex-analysis': patch
10+
'dns-analytics': patch
11+
'docs-ai-search': patch
12+
'graphql-mcp-server': patch
13+
'logpush': patch
14+
'cloudflare-radar-mcp-server': patch
15+
'containers-mcp': patch
16+
'stack-mcp': patch
17+
'workers-bindings': patch
18+
'workers-builds': patch
19+
'workers-observability': patch
20+
'@repo/mcp-common': patch
21+
---
22+
23+
Return an actionable `410 Gone` Problem Details response when a client attempts the removed HTTP+SSE transport with `GET /sse`. The response explains that clients can configure the existing `/sse` URL to use Streamable HTTP or, preferably, move to `/mcp` for future compatibility. It preserves query parameters, identifies the recommended replacement in a `Link` header, and is available before OAuth authentication. Streamable HTTP `POST` requests continue to work on both `/sse` and `/mcp`.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Model Context Protocol (MCP) is a [new, standardized protocol](https://modelcont
44

55
These MCP servers allow your [MCP Client](https://modelcontextprotocol.io/clients) to read configurations from your account, process information, make suggestions based on data, and even make those suggested changes for you. All of these actions can happen across Cloudflare's many services including application development, security and performance.
66

7-
Every server exposes the same stateless Streamable HTTP handler at `/mcp` and `/sse` through a fresh SDK v2 server factory. `/sse` remains as a URL compatibility alias; it does not use the deprecated HTTP+SSE transport. Modern 2026 requests and stateless 2025 requests share the same request-scoped implementation without an MCP protocol session. OAuth, credentials, account selection, application caches, and product Durable Objects remain application/security state where required.
7+
Every server exposes the same stateless Streamable HTTP handler at `/mcp` and `/sse` through a fresh SDK v2 server factory. `/sse` remains as a URL compatibility alias; it does not use the deprecated HTTP+SSE transport. A legacy SSE `GET /sse` request receives a `410 Gone` Problem Details response with two migration options: configure the existing URL to use Streamable HTTP, or switch to the recommended `/mcp` URL for future compatibility. Modern 2026 requests and stateless 2025 requests share the same request-scoped implementation without an MCP protocol session. OAuth, credentials, account selection, application caches, and product Durable Objects remain application/security state where required.
88

99
The following servers are included in this repository:
1010

packages/mcp-common/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export default app.worker
4848

4949
For lower-level use, `createCloudflareMcpHandler()` accepts explicit server metadata, observability factories, and HTTP policy. It delegates protocol routing, CORS, Host/Origin validation, and legacy compatibility to `createMcpHandler()` from the isolated `agents/mcp/server` entry point. Do not construct a global MCP server. Do not set `legacy: 'reject'`: the default `legacy: 'stateless'` fallback is part of the migration contract.
5050

51-
The shared handler serves `POST` and CORS `OPTIONS` on `/mcp` and `/sse`. The latter is a URL alias, not the deprecated HTTP+SSE transport. The SDK returns `405` for stateless legacy stream and session-deletion requests. MCP request bodies are capped at 4 MiB before SDK parsing, and OAuth resources use strict path-aware matching.
51+
The shared handler serves `POST` and CORS `OPTIONS` on `/mcp` and `/sse`. The latter is a URL alias, not the deprecated HTTP+SSE transport. A legacy SSE `GET /sse` request returns `410 Gone` with an `application/problem+json` body that offers two Streamable HTTP migrations: keep the existing URL by changing the configured transport, or switch to the recommended `/mcp` URL for future compatibility. The SDK continues to return `405` for other stateless legacy stream and session-deletion requests. MCP request bodies are capped at 4 MiB before SDK parsing, and OAuth resources use strict path-aware matching.
5252

5353
## Request registration context
5454

packages/mcp-common/src/oauth-router.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,31 @@ describe('OAuth router resource policy', () => {
6464
).toThrow('resourceMatchOriginOnly')
6565
})
6666

67+
it('routes legacy SSE stream requests to the migration handler before OAuth', async () => {
68+
const router = createCloudflareOAuthRouter<CloudflareOAuthEnv>({
69+
apiHandler: {
70+
fetch() {
71+
return new Response('migration', { status: 410 })
72+
},
73+
},
74+
scopes: {},
75+
metrics,
76+
mcpRequestPolicy,
77+
})
78+
79+
const response = await router.fetch(
80+
new Request('https://mcp.example.com/sse', {
81+
method: 'GET',
82+
headers: { Accept: 'text/event-stream', Host: 'mcp.example.com' },
83+
}),
84+
testEnv(),
85+
executionContext
86+
)
87+
88+
expect(response.status).toBe(410)
89+
await expect(response.text()).resolves.toBe('migration')
90+
})
91+
6792
it('returns a retryable response when a Wrangler OAuth identity probe is rate limited', async () => {
6893
server.use(
6994
http.get('https://api.cloudflare.com/client/v4/user', () =>
@@ -170,6 +195,7 @@ describe('OAuth router resource policy', () => {
170195

171196
const response = await router.fetch(
172197
new Request('https://mcp.example.com/sse', {
198+
method: 'POST',
173199
headers: {
174200
Authorization: `Bearer ${'a'.repeat(40)}`,
175201
Host: 'mcp.example.com',

packages/mcp-common/src/oauth-router.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resolveExternalToken,
1111
} from './api-token-mode'
1212
import { createAuthHandlers, handleTokenExchangeCallback } from './cloudflare-oauth-handler'
13+
import { isLegacySseStreamRequest } from './transport-migration'
1314

1415
import type { OAuthProviderOptions } from '@cloudflare/workers-oauth-provider'
1516
import type { MetricsTracker } from '@repo/mcp-observability'
@@ -82,10 +83,13 @@ export function createCloudflareOAuthRouter<Env extends CloudflareOAuthEnv>({
8283
return apiHandler.fetch(request, env, ctx)
8384
}
8485

85-
// Let the MCP handler own its browser preflight so the exact policy and
86-
// modern request-header allowlist are not replaced by the OAuth Provider's
87-
// intentionally broad discovery-endpoint CORS response.
88-
if (request.method === 'OPTIONS') return apiHandler.fetch(request, env, ctx)
86+
// Let the MCP handler own browser preflight and the unauthenticated SSE
87+
// migration response so its exact HTTP policy is preserved. The OAuth
88+
// Provider would otherwise challenge GET /sse before clients see the
89+
// actionable replacement endpoint.
90+
if (request.method === 'OPTIONS' || isLegacySseStreamRequest(request)) {
91+
return apiHandler.fetch(request, env, ctx)
92+
}
8993
}
9094

9195
if (devApiTokenModeEnabled(env)) {

packages/mcp-common/src/server.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,71 @@ describe('shared stateless MCP foundation', () => {
191191
expect(instances.size).toBe(4)
192192
})
193193

194+
it('returns an actionable migration problem for legacy SSE stream requests', async () => {
195+
let registrations = 0
196+
const handler = createCloudflareMcpHandler<TestEnv>({
197+
serverInfo: { name: 'sse-migration', version: '1.0.0' },
198+
register() {
199+
registrations++
200+
},
201+
handler: {
202+
allowedHostnames: ['mcp.example.com'],
203+
allowedOriginHostnames: ['app.example.com'],
204+
corsOptions: { origin: 'https://app.example.com' },
205+
},
206+
})
207+
const replacementUrl = 'https://mcp.example.com/mcp?libs=cloudflare,hono'
208+
const response = await handler.fetch(
209+
new Request('https://mcp.example.com/sse?libs=cloudflare,hono', {
210+
method: 'GET',
211+
headers: {
212+
Accept: 'text/event-stream',
213+
Host: 'mcp.example.com',
214+
Origin: 'https://app.example.com',
215+
},
216+
}),
217+
{ requestLabel: 'sse-migration' },
218+
executionContext()
219+
)
220+
221+
expect(response.status).toBe(410)
222+
expect(response.headers.get('content-type')).toContain('application/problem+json')
223+
expect(response.headers.get('cache-control')).toBe('no-store')
224+
expect(response.headers.get('link')).toBe(`<${replacementUrl}>; rel="alternate"`)
225+
expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example.com')
226+
await expect(response.json()).resolves.toEqual({
227+
type: 'https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/',
228+
title: 'Legacy SSE transport is no longer supported',
229+
status: 410,
230+
detail:
231+
'This URL no longer supports the deprecated HTTP+SSE transport. Configure this URL to use Streamable HTTP, or update to the replacement URL for future compatibility.',
232+
options: [
233+
{
234+
action: 'change-transport',
235+
transport: 'streamable-http',
236+
url: 'https://mcp.example.com/sse?libs=cloudflare,hono',
237+
recommended: false,
238+
},
239+
{
240+
action: 'update-url',
241+
transport: 'streamable-http',
242+
url: replacementUrl,
243+
recommended: true,
244+
},
245+
],
246+
})
247+
const rejected = await handler.fetch(
248+
new Request('https://mcp.example.com/sse', {
249+
method: 'GET',
250+
headers: { Accept: 'text/event-stream', Host: 'evil.example.com' },
251+
}),
252+
{ requestLabel: 'sse-migration-invalid-host' },
253+
executionContext()
254+
)
255+
expect(rejected.status).toBe(403)
256+
expect(registrations).toBe(0)
257+
})
258+
194259
it('rejects oversized MCP request bodies before constructing a server', async () => {
195260
let registrations = 0
196261
const handler = createCloudflareMcpHandler<TestEnv>({

packages/mcp-common/src/server.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AccountManager } from './account-manager'
77
import { AuthPropsSchema } from './auth-props'
88
import { createRegistrationContext } from './registration-context'
99
import { getRequestUserId } from './request-context'
10+
import { isLegacySseStreamRequest, legacySseMigrationResponse } from './transport-migration'
1011

1112
import type { Implementation, McpServerFactory, ServerOptions } from '@modelcontextprotocol/server'
1213
import type { CreateMcpHandlerOptions } from 'agents/mcp/server'
@@ -126,7 +127,8 @@ const DEFAULT_CORS_HEADERS = [
126127
*
127128
* The Agents/upstream default `legacy: "stateless"` is deliberately preserved;
128129
* this wrapper never changes it to `"reject"`. The historical `/sse` URL is
129-
* served by the same stateless handler and is not the deprecated HTTP+SSE transport.
130+
* served by the same stateless handler and is not the deprecated HTTP+SSE transport;
131+
* legacy `GET /sse` attempts receive an actionable `410 Gone` migration problem.
130132
*/
131133
export function createCloudflareMcpHandler<Env>(
132134
options: CreateCloudflareMcpHandlerOptions<Env>
@@ -160,7 +162,7 @@ export function createCloudflareMcpHandler<Env>(
160162
boundedRequest = bounded
161163
}
162164

163-
return createMcpHandler(
165+
const response = await createMcpHandler(
164166
createCloudflareMcpServerFactory(factoryOptions, {
165167
env,
166168
request: boundedRequest,
@@ -172,6 +174,13 @@ export function createCloudflareMcpHandler<Env>(
172174
corsOptions: resolvedCors,
173175
}
174176
)(boundedRequest, env, ctx)
177+
178+
// Let the MCP wrapper enforce Host and Origin policy before replacing its
179+
// generic stateless-GET rejection with an actionable transport migration.
180+
if (response.status === 405 && isLegacySseStreamRequest(boundedRequest)) {
181+
return withCors(legacySseMigrationResponse(boundedRequest, canonicalRoute), resolvedCors)
182+
}
183+
return response
175184
},
176185
}
177186
}

packages/mcp-common/src/test/stateless-app.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,22 +162,24 @@ export function testStatelessMcpApp<Env>({
162162
expect((await policyHandler.fetch(badOrigin, env, context(false))).status).toBe(403)
163163
})
164164

165-
it('serves /sse through the same modern and legacy stateless transport', async () => {
165+
it('serves the /sse Streamable HTTP alias and guides legacy SSE clients to /mcp', async () => {
166166
const routeHandler = authenticated ? handler : (authenticatedWorker ?? handler)
167+
const migrationHandler = authenticatedWorker ?? routeHandler
167168
const alias = new URL('/sse', url).href
169+
const replacement = new URL('/mcp', url).href
168170
const modern = await routeHandler.fetch(
169171
modernRequest(alias, 'server/discover'),
170172
env,
171173
context()
172174
)
173175
const legacy = await routeHandler.fetch(legacyInitializeRequest(alias), env, context())
174-
const oldSse = await routeHandler.fetch(
176+
const oldSse = await migrationHandler.fetch(
175177
new Request(alias, {
176178
method: 'GET',
177179
headers: { Accept: 'text/event-stream', Host: new URL(url).hostname },
178180
}),
179181
env,
180-
context()
182+
context(false)
181183
)
182184

183185
expect(modern.status).toBe(200)
@@ -190,7 +192,27 @@ export function testStatelessMcpApp<Env>({
190192
expect(await responseDocument(legacy)).toMatchObject({
191193
result: { protocolVersion: '2025-11-25' },
192194
})
193-
expect(oldSse.status).toBe(405)
195+
expect(oldSse.status).toBe(410)
196+
expect(oldSse.headers.get('content-type')).toContain('application/problem+json')
197+
expect(oldSse.headers.get('link')).toBe(`<${replacement}>; rel="alternate"`)
198+
await expect(oldSse.json()).resolves.toMatchObject({
199+
title: 'Legacy SSE transport is no longer supported',
200+
status: 410,
201+
options: [
202+
{
203+
action: 'change-transport',
204+
transport: 'streamable-http',
205+
url: alias,
206+
recommended: false,
207+
},
208+
{
209+
action: 'update-url',
210+
transport: 'streamable-http',
211+
url: replacement,
212+
recommended: true,
213+
},
214+
],
215+
})
194216
expect('MCP_OBJECT' in (env as object)).toBe(false)
195217
})
196218
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const LEGACY_SSE_ROUTE = '/sse'
2+
const MIGRATION_DOCUMENTATION =
3+
'https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/'
4+
5+
export function isLegacySseStreamRequest(request: Request): boolean {
6+
return request.method === 'GET' && new URL(request.url).pathname === LEGACY_SSE_ROUTE
7+
}
8+
9+
export function legacySseMigrationResponse(request: Request, canonicalRoute = '/mcp'): Response {
10+
const current = new URL(request.url).href
11+
const replacementUrl = new URL(request.url)
12+
replacementUrl.pathname = canonicalRoute
13+
const replacement = replacementUrl.href
14+
15+
return new Response(
16+
JSON.stringify({
17+
type: MIGRATION_DOCUMENTATION,
18+
title: 'Legacy SSE transport is no longer supported',
19+
status: 410,
20+
detail:
21+
'This URL no longer supports the deprecated HTTP+SSE transport. Configure this URL to use Streamable HTTP, or update to the replacement URL for future compatibility.',
22+
options: [
23+
{
24+
action: 'change-transport',
25+
transport: 'streamable-http',
26+
url: current,
27+
recommended: false,
28+
},
29+
{
30+
action: 'update-url',
31+
transport: 'streamable-http',
32+
url: replacement,
33+
recommended: true,
34+
},
35+
],
36+
}),
37+
{
38+
status: 410,
39+
statusText: 'Gone',
40+
headers: {
41+
'Cache-Control': 'no-store',
42+
'Content-Type': 'application/problem+json',
43+
Link: `<${replacement}>; rel="alternate"`,
44+
},
45+
}
46+
)
47+
}

0 commit comments

Comments
 (0)