Skip to content

Commit 261a25a

Browse files
Merge pull request #971 from Max-Health-Inc/develop
🧪 Auto-PR: Merge `develop` → `test`
2 parents 5d4ebb2 + b692dc7 commit 261a25a

15 files changed

Lines changed: 84 additions & 108 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "proxy-smart-backend",
33
"displayName": "Proxy Smart Backend",
4-
"version": "0.3.4-beta.202608081721.cb4b3a46e",
4+
"version": "0.3.4-alpha.202608081732.47cfad62f",
55
"type": "module",
66
"scripts": {
77
"test": "bun test --isolate",

backend/src/routes/mcp-endpoint.ts

Lines changed: 58 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@
1818
import { Elysia } from 'elysia'
1919
import * as z from 'zod'
2020
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'
21-
import { handleMcpPost } from '@maxhealth.tech/mcp-http'
21+
import { createMcpHttpHandler } from '@maxhealth.tech/mcp-http'
2222
import { isOriginAllowed } from '@/lib/cors-origins'
2323

2424
import {
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 ─────────────────────────────────────────────────────────────

backend/test/mcp-endpoint.test.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,12 @@ describe('MCP Endpoint — /mcp', () => {
225225
expect(res.status).toBe(401)
226226
})
227227

228-
it('POST: 401 body is valid JSON-RPC error with code -32001', async () => {
228+
// Deliberately NOT pinning a JSON-RPC body here. Auth fails at the HTTP
229+
// layer, before JSON-RPC processing, and the spec's mechanism is the status
230+
// plus WWW-Authenticate. The old -32001 also sat in the -32000..-32099 range
231+
// the spec allocates from — SEP 2243 proposed -32001 for HeaderMismatch
232+
// before the shipped spec moved it to -32020.
233+
it('POST: 401 carries the challenge, not a JSON-RPC error body', async () => {
229234
const app = createApp()
230235
const res = await app.handle(
231236
new Request('http://localhost/mcp', {
@@ -234,11 +239,8 @@ describe('MCP Endpoint — /mcp', () => {
234239
body: JSON.stringify(jsonRpcInitialize()),
235240
}),
236241
)
237-
const body = await res.json()
238-
expect(body.jsonrpc).toBe('2.0')
239-
expect(body.error).toBeDefined()
240-
expect(body.error.code).toBe(-32001)
241-
expect(body.id).toBeNull()
242+
expect(res.status).toBe(401)
243+
expect(res.headers.get('www-authenticate')).toContain('Bearer')
242244
})
243245

244246
it('401 includes WWW-Authenticate with resource_metadata pointing to RFC 9728 URL', async () => {
@@ -297,16 +299,16 @@ describe('MCP Endpoint — /mcp', () => {
297299
expect(res.status).toBe(401)
298300
})
299301

300-
it('GET: returns 401 without auth', async () => {
302+
it('GET: refuses without auth (method gate precedes the auth gate)', async () => {
301303
const app = createApp()
302304
const res = await app.handle(mcpGet())
303-
expect(res.status).toBe(401)
305+
expect(res.status).toBe(405)
304306
})
305307

306-
it('DELETE: returns 401 without auth', async () => {
308+
it('DELETE: refuses without auth (method gate precedes the auth gate)', async () => {
307309
const app = createApp()
308310
const res = await app.handle(mcpDelete())
309-
expect(res.status).toBe(401)
311+
expect(res.status).toBe(405)
310312
})
311313
})
312314

@@ -590,7 +592,7 @@ describe('MCP Endpoint — /mcp', () => {
590592
const app = createApp()
591593
const res = await app.handle(mcpGet({ token: 'valid-token' }))
592594
expect(res.status).toBe(405)
593-
expect(res.headers.get('allow')).toBe('POST')
595+
expect(res.headers.get('allow')).toContain('POST')
594596
})
595597

596598
it('answers DELETE (session teardown) with 405', async () => {

config/eslint/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@proxy-smart/eslint-config",
3-
"version": "0.3.4-alpha.202608081721.cb4b3a46e",
3+
"version": "0.3.4-beta.202608081721.cb4b3a46e",
44
"private": true,
55
"type": "module",
66
"exports": {

deploy/infra/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "proxy-smart-infra",
33
"displayName": "Proxy Smart Infrastructure",
44
"description": "AWS CDK infrastructure for Proxy Smart production deployment",
5-
"version": "0.3.4-beta.202608081721.cb4b3a46e",
5+
"version": "0.3.4-alpha.202608081732.47cfad62f",
66
"private": true,
77
"type": "module",
88
"scripts": {

frontend/smart-dicom-template/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "SMART DICOM Algorithm Template",
44
"description": "Starter kit for building SMART on FHIR imaging algorithm apps. Clone, implement your algorithm in src/algorithm.ts, and deploy as a SMART app.",
55
"private": true,
6-
"version": "0.3.4-alpha.202608081721.cb4b3a46e",
6+
"version": "0.3.4-beta.202608081721.cb4b3a46e",
77
"type": "module",
88
"scripts": {
99
"dev": "vite --port 5180",

frontend/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "Proxy Smart Admin UI",
44
"description": "A web-based administration interface for managing healthcare applications and resources via Proxy Smart.",
55
"private": true,
6-
"version": "0.3.4-alpha.202608081721.cb4b3a46e",
6+
"version": "0.3.4-beta.202608081721.cb4b3a46e",
77
"type": "module",
88
"scripts": {
99
"dev": "vite",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "proxy-smart",
3-
"version": "0.3.4-beta.202608081721.cb4b3a46e",
3+
"version": "0.3.4-alpha.202608081732.47cfad62f",
44
"repository": {
55
"type": "git",
66
"url": "git+https://github.com/Max-Health-Inc/proxy-smart.git"

packages/app-store/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@proxy-smart/app-store",
3-
"version": "0.3.4-alpha.202608081721.cb4b3a46e",
3+
"version": "0.3.4-beta.202608081721.cb4b3a46e",
44
"private": false,
55
"type": "module",
66
"description": "SMART on FHIR app store — manifest discovery, visibility configuration, and registry CRUD. Framework-agnostic.",

packages/auth/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@proxy-smart/auth",
3-
"version": "0.3.4-alpha.202608081721.cb4b3a46e",
3+
"version": "0.3.4-beta.202608081721.cb4b3a46e",
44
"private": false,
55
"type": "module",
66
"description": "SMART on FHIR STU 2.2.0 server-side authorization proxy — launch context, session management, token enrichment. Framework-agnostic, IdP-pluggable.",

0 commit comments

Comments
 (0)