()
| 193 | }); |
| 194 | |
| 195 | export const makeCloudMcpAgentHandler = () => { |
| 196 | const serveOptions = { |
| 197 | binding: "MCP_SESSION", |
| 198 | transport: "streamable-http", |
| 199 | } as const; |
| 200 | // The agents SDK builds an exact-match `URLPattern` from the path handed to |
| 201 | // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) — |
| 202 | // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls |
| 203 | // through to its own internal 404. A second `serve` mounted on the |
| 204 | // parameterized path picks it up (`URLPattern` supports `:slug` segments); |
| 205 | // the auth/ownership/props logic above is unchanged and shared, only the |
| 206 | // final dispatch target differs. |
| 207 | const serve = McpSessionDOSqlite.serve("/mcp", serveOptions); |
| 208 | const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions); |
| 209 | |
| 210 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| 211 | |
| 212 | return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => { |
| 213 | if (request.method === "OPTIONS") return corsPreflightResponse(); |
| 214 | // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything |
| 215 | // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK |
| 216 | // handler only understands its own transport verbs and falls through to |
| 217 | // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach |
| 218 | // the session engine. |
| 219 | if (!ALLOWED_METHODS.has(request.method)) { |
| 220 | return jsonRpcResponse(405, -32001, "Method not allowed"); |
| 221 | } |
| 222 | const sessionId = request.headers.get("mcp-session-id"); |
| 223 | |
| 224 | const { auth, outcome } = await runTraced(request, authenticate(request)); |
| 225 | if (!Predicate.isTagged(outcome, "Authenticated")) { |
| 226 | // Destroying a live session on auth grounds requires a POSITIVE |
| 227 | // determination that access is genuinely gone — only `Forbidden` carries |
| 228 | // that (valid bearer, org absent/revoked). `Unavailable` (transient WorkOS |
| 229 | // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave |
| 230 | // the session intact, so the condemn path is gated on `Forbidden` alone. |
| 231 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { |
| 232 | await Effect.runPromise( |
| 233 | Effect.ignore( |
| 234 | Effect.tryPromise(() => |
| 235 | mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), |
| 236 | ), |
| 237 | ), |
| 238 | ); |
| 239 | } |
| 240 | return renderAuthError(auth, request, outcome); |
| 241 | } |
| 242 | |
| 243 | if (!sessionId && request.method === "DELETE") { |
| 244 | // Matches the old envelope's contract (@modelcontextprotocol/sdk's |
| 245 | // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, |
| 246 | // not 204 — see e2e/cloud/mcp-protocol.test.ts. |
| 247 | return new Response(null, { |
| 248 | status: 200, |
| 249 | headers: { "access-control-allow-origin": "*" }, |
| 250 | }); |
| 251 | } |
| 252 |
no test coverage detected