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