(resource: McpResource)
| 176 | |
| 177 | /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ |
| 178 | const mcpDispatch = (resource: McpResource) => |
| 179 | Effect.gen(function* () { |
| 180 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; |
| 181 | const auth = yield* McpAuthProvider; |
| 182 | const store = yield* McpSessionStore; |
| 183 | const request = yield* toWebRequest(httpRequest); |
| 184 | |
| 185 | // CORS preflight: answer before auth so unauthenticated clients can probe. |
| 186 | if (request.method === "OPTIONS") { |
| 187 | return HttpServerResponse.raw(corsPreflightResponse()); |
| 188 | } |
| 189 | |
| 190 | // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other |
| 191 | // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — |
| 192 | // otherwise it would fall through and spin up a session engine for a method |
| 193 | // the transport can't serve. |
| 194 | if (!ALLOWED_MCP_METHODS.has(request.method)) { |
| 195 | return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); |
| 196 | } |
| 197 | |
| 198 | const sessionId = request.headers.get("mcp-session-id"); |
| 199 | |
| 200 | // Authenticate (and, for session-aware providers, authorize) on EVERY |
| 201 | // request. Non-authenticated outcomes render directly. Session teardown is |
| 202 | // only safe after the store can validate the authenticated principal and MCP |
| 203 | // resource; an auth-level Forbidden may not carry either. |
| 204 | const outcome = yield* auth.authenticate(request); |
| 205 | if (!Predicate.isTagged(outcome, "Authenticated")) { |
| 206 | return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); |
| 207 | } |
| 208 | const principal = outcome.principal; |
| 209 | |
| 210 | // No session id: per the streamable-HTTP transport contract, only POST opens |
| 211 | // a session. A GET needs an existing id (400); a DELETE on nothing is a |
| 212 | // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up |
| 213 | // an engine for a bare GET/DELETE. |
| 214 | if (!sessionId) { |
| 215 | if (request.method === "GET") { |
| 216 | return HttpServerResponse.raw( |
| 217 | jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), |
| 218 | ); |
| 219 | } |
| 220 | if (request.method === "DELETE") { |
| 221 | return HttpServerResponse.raw( |
| 222 | new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }), |
| 223 | ); |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | const result: McpDispatchResult = yield* store.dispatch({ |
| 228 | request, |
| 229 | principal, |
| 230 | resource, |
| 231 | sessionId, |
| 232 | method: request.method, |
| 233 | }); |
| 234 | return HttpServerResponse.raw( |
| 235 | result instanceof Response ? result : renderDispatchError(result), |
no test coverage detected