1818import { Elysia } from 'elysia'
1919import * as z from 'zod'
2020import { McpServer , ResourceTemplate } from '@modelcontextprotocol/server'
21- import { handleMcpPost } from '@maxhealth.tech/mcp-http'
21+ import { createMcpHttpHandler } from '@maxhealth.tech/mcp-http'
2222import { isOriginAllowed } from '@/lib/cors-origins'
2323
2424import {
2525 typeboxToSchema ,
26- originGuard ,
2726 executeTool as pkgExecuteTool ,
2827 executeResource as pkgExecuteResource ,
2928 getMergedInputSchema ,
@@ -224,15 +223,36 @@ interface AuthResult {
224223 token ?: string
225224}
226225
227- async function authenticateRequest ( request : Request ) : Promise < AuthResult | Response > {
228- const authHeader = request . headers . get ( 'authorization' )
229- if ( ! authHeader ?. startsWith ( 'Bearer ' ) ) {
230- return unauthorized ( )
231- }
226+ /** A token that fails validation. Mapped to a 401 in `onError`. */
227+ class McpUnauthorizedError extends Error { }
228+
229+ /** mcp-http wants the origin to echo, or null to refuse. No Origin stays allowed. */
230+ function allowedOrigin ( req : Request ) : string | null {
231+ const origin = req . headers . get ( 'origin' )
232+ if ( ! origin ) return null
233+ return isOriginAllowed ( origin ) ? origin : null
234+ }
232235
233- const token = authHeader . substring ( 7 ) . trim ( )
234- if ( ! token ) return unauthorized ( )
236+ /**
237+ * Rewrite the 401 challenge on the way out.
238+ *
239+ * Two things upstream does not do. It derives the pointer from `req.url`, so a
240+ * spoofed Host behind a proxy that does not normalise it would aim the client at
241+ * an attacker's metadata; config.baseUrl is trusted. And it omits `scope`, which
242+ * is what lets a client following the challenge actually authorize.
243+ */
244+ function withChallenge ( res : Response ) : Response {
245+ if ( res . status !== 401 ) return res
246+ const baseUrl = ( config . baseUrl || 'http://localhost:8445' ) . replace ( / \/ + $ / , '' )
247+ const headers = new Headers ( res . headers )
248+ headers . set (
249+ 'WWW-Authenticate' ,
250+ `Bearer resource_metadata="${ baseUrl } /.well-known/oauth-protected-resource", scope="${ MCP_SCOPE_CHALLENGE } "` ,
251+ )
252+ return new Response ( res . body , { status : res . status , statusText : res . statusText , headers } )
253+ }
235254
255+ async function authenticateToken ( token : string ) : Promise < AuthResult > {
236256 try {
237257 // MCP tokens are bound to the MCP endpoint resource (RFC 8707) or one of the
238258 // proxy's own clients (matched on aud/azp): the admin WEBAPP client
@@ -250,86 +270,26 @@ async function authenticateRequest(request: Request): Promise<AuthResult | Respo
250270 ) . flatMap ( ( r ) => r ?. roles ?? [ ] )
251271 return { roles : [ ...new Set ( [ ...realmRoles , ...clientRoles ] ) ] , sub : payload . sub , token }
252272 } catch {
253- return unauthorized ( )
273+ throw new McpUnauthorizedError ( 'Unauthorized' )
254274 }
255275}
256276
257- function unauthorized ( ) : Response {
258- const baseUrl = config . baseUrl || 'http://localhost:8445'
259- return new Response (
260- JSON . stringify ( {
261- jsonrpc : '2.0' ,
262- error : { code : - 32001 , message : 'Unauthorized -- Bearer token required' } ,
263- id : null ,
264- } ) ,
265- {
266- status : 401 ,
267- headers : {
268- 'Content-Type' : 'application/json' ,
269- // The challenged scopes are the ones every provisioned client is granted by default,
270- // so a client that follows this challenge can actually authorize (see lib/oauth-scopes).
271- 'WWW-Authenticate' : `Bearer resource_metadata="${ baseUrl } /.well-known/oauth-protected-resource", scope="${ MCP_SCOPE_CHALLENGE } "` ,
272- } ,
273- } ,
274- )
275- }
276-
277277// ── Core request handler ─────────────────────────────────────────────────────
278278
279- async function handleMcpRequest ( request : Request ) : Promise < Response > {
280- // Master switch — file-backed config is the single source of truth
281- const endpointCfg = loadMcpEndpointConfig ( )
282- const effectiveEnabled = endpointCfg . enabled
283- if ( ! effectiveEnabled ) {
284- return new Response ( JSON . stringify ( { error : 'MCP endpoint is disabled' } ) , {
285- status : 404 ,
286- headers : { 'Content-Type' : 'application/json' } ,
287- } )
288- }
289-
290- // Origin gate before authentication: a rebound request must be REFUSED, not
291- // merely denied a readable response (MCP Streamable HTTP security warning).
292- const refused = originGuard ( request , isOriginAllowed )
293- if ( refused ) return refused
294-
295- // Authenticate. Every request carries its own bearer, which is what makes the
296- // stateless posture below safe: authorization is re-established per request
297- // rather than captured once and refreshed into a long-lived session.
298- const auth = await authenticateRequest ( request )
299- if ( auth instanceof Response ) return auth
300-
301- // ── Session operations: 405, because there are no sessions ─────────────
302- // The established stateless idiom (SDK v2: "Because serving is per-request and
303- // stateless, GET and DELETE (2025 session operations) are answered with 405").
304- // A 405 here is benign by design — the Streamable HTTP spec has the client
305- // proceed without the standalone stream, and terminateSession() resolve
306- // normally. Nothing is lost because nothing was being resumed.
307- if ( request . method === 'GET' || request . method === 'DELETE' ) {
308- return new Response (
309- JSON . stringify ( {
310- jsonrpc : '2.0' ,
311- error : { code : - 32000 , message : 'Method not allowed: this endpoint is stateless' } ,
312- id : null ,
313- } ) ,
314- { status : 405 , headers : { 'Content-Type' : 'application/json' , Allow : 'POST' } } ,
315- )
316- }
317-
318- if ( request . method !== 'POST' ) {
319- return new Response (
320- JSON . stringify ( { jsonrpc : '2.0' , error : { code : - 32000 , message : 'Bad Request' } , id : null } ) ,
321- { status : 400 , headers : { 'Content-Type' : 'application/json' } } ,
322- )
323- }
324-
325- // Transport only. The gates above stay local: this endpoint answers with a
326- // JSON-RPC error, not an OAuth one, and refuses a rebound Origin before
327- // authenticating — mcp-http's full edge inverts both.
328- const tokenRef = { current : auth . token }
329-
330- return handleMcpPost ( {
331- req : request ,
332- createServer : ( ) => {
279+ /** Built once; the tool registry is read per request inside createServer. */
280+ let handler : ReturnType < typeof createMcpHttpHandler > | null = null
281+
282+ function mcpHandler ( ) {
283+ if ( handler ) return handler
284+ // Fail closed: mcp-http reads an absent authorizationServer as a public
285+ // endpoint and drops the Bearer gate.
286+ handler = createMcpHttpHandler ( {
287+ mcpPath : config . mcp ?. path ?? '/mcp' ,
288+ authorizationServer : config . keycloak . expectedIssuer ?? config . baseUrl ,
289+ cors : { origin : allowedOrigin } ,
290+ createServer : async ( token ) => {
291+ const auth = await authenticateToken ( token ?? '' )
292+ const tokenRef = { current : auth . token }
333293 const server = new McpServer (
334294 { name : config . displayName , version : config . version } ,
335295 { capabilities : { tools : { listChanged : false } , resources : { listChanged : false } } } ,
@@ -339,7 +299,21 @@ async function handleMcpRequest(request: Request): Promise<Response> {
339299 registerResources ( server , auth . roles , tokenRef )
340300 return server
341301 } ,
302+ // A createServer throw is a 500 upstream; a bad token deserves a 401.
303+ onError : ( err ) =>
304+ err instanceof McpUnauthorizedError
305+ ? new Response ( null , { status : 401 } )
306+ : undefined ,
342307 } )
308+ return handler
309+ }
310+
311+ async function handleMcpRequest ( request : Request ) : Promise < Response > {
312+ // Master switch — file-backed config is the single source of truth.
313+ if ( ! loadMcpEndpointConfig ( ) . enabled ) {
314+ return Response . json ( { error : 'MCP endpoint is disabled' } , { status : 404 } )
315+ }
316+ return withChallenge ( await mcpHandler ( ) ( request ) )
343317}
344318
345319// ── Elysia route ─────────────────────────────────────────────────────────────
0 commit comments