()
| 114 | }); |
| 115 | |
| 116 | export const makeCloudMcpAgentHandler = () => { |
| 117 | const serveOptions = { |
| 118 | binding: "MCP_SESSION", |
| 119 | transport: "streamable-http", |
| 120 | } as const; |
| 121 | // The agents SDK builds an exact-match `URLPattern` from the path handed to |
| 122 | // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) — |
| 123 | // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls |
| 124 | // through to its own internal 404. A second `serve` mounted on the |
| 125 | // parameterized path picks it up (`URLPattern` supports `:slug` segments); |
| 126 | // the auth/ownership/props logic above is unchanged and shared, only the |
| 127 | // final dispatch target differs. |
| 128 | const serve = McpSessionDOSqlite.serve("/mcp", serveOptions); |
| 129 | const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions); |
| 130 | |
| 131 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| 132 | |
| 133 | return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => { |
| 134 | if (request.method === "OPTIONS") return corsPreflightResponse(); |
| 135 | // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything |
| 136 | // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK |
| 137 | // handler only understands its own transport verbs and falls through to |
| 138 | // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach |
| 139 | // the session engine. |
| 140 | if (!ALLOWED_METHODS.has(request.method)) { |
| 141 | return jsonRpcResponse(405, -32001, "Method not allowed"); |
| 142 | } |
| 143 | const sessionId = request.headers.get("mcp-session-id"); |
| 144 | |
| 145 | const { auth, outcome } = await Effect.runPromise(authenticate(request)); |
| 146 | if (!Predicate.isTagged(outcome, "Authenticated")) { |
| 147 | // Destroying a live session on auth grounds requires a POSITIVE |
| 148 | // determination that access is genuinely gone — only `Forbidden` carries |
| 149 | // that (valid bearer, org absent/revoked). `Unavailable` (transient WorkOS |
| 150 | // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave |
| 151 | // the session intact, so the condemn path is gated on `Forbidden` alone. |
| 152 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { |
| 153 | await Effect.runPromise( |
| 154 | Effect.ignore( |
| 155 | Effect.tryPromise(() => |
| 156 | mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), |
| 157 | ), |
| 158 | ), |
| 159 | ); |
| 160 | } |
| 161 | return renderAuthError(auth, request, outcome); |
| 162 | } |
| 163 | |
| 164 | if (!sessionId && request.method === "DELETE") { |
| 165 | // Matches the old envelope's contract (@modelcontextprotocol/sdk's |
| 166 | // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, |
| 167 | // not 204 — see e2e/cloud/mcp-protocol.test.ts. |
| 168 | return new Response(null, { |
| 169 | status: 200, |
| 170 | headers: { "access-control-allow-origin": "*" }, |
| 171 | }); |
| 172 | } |
| 173 |
no test coverage detected