From da32eb8a3c3382465df7de0a0f11c55b1bb2527e Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 11:52:24 +0200 Subject: [PATCH] fix(mcp): answer the discovery documents under the server address too A client handed https://host/mcp as the server address looks for the well-known documents below that path instead of asking the origin, and every one of those probes fell through to the web app: 200 with an HTML body, which reads to the client as "this server has no OAuth" (Open WebUI reports it as an unexpected mimetype). The three documents are served under /mcp/.well-known now, anything else there answers a JSON 404, and the RFC 8414 path-inserted form /.well-known/oauth-authorization-server/mcp serves the metadata instead of 404. The wiki says which redirect URI belongs to the client and that a plain-HTTP APP_URL leaves the discovery document pointing at localhost. --- .../src/nest/common/validate-route-guards.ts | 7 +++ .../src/nest/platform/discovery.controller.ts | 43 +++++++++++++++++++ server/src/nest/platform/platform.module.ts | 6 +-- .../unit/nest/discovery.controller.test.ts | 41 +++++++++++++++++- wiki/MCP-Setup.md | 10 ++++- 5 files changed, 102 insertions(+), 5 deletions(-) diff --git a/server/src/nest/common/validate-route-guards.ts b/server/src/nest/common/validate-route-guards.ts index 16f05910d..f3cf8d96e 100644 --- a/server/src/nest/common/validate-route-guards.ts +++ b/server/src/nest/common/validate-route-guards.ts @@ -103,6 +103,7 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [ 'ConfigController.getConfig', // OAuth/OIDC discovery documents + the JSON 404 catchalls that keep // /.well-known probes from ever seeing SPA HTML. + 'DiscoveryController.authorizationServerForMcp', 'DiscoveryController.openidConfiguration', 'DiscoveryController.protectedResource', 'DiscoveryController.wellKnownFallback', @@ -129,6 +130,12 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [ 'JourneyPublicController.legacyPhoto', 'JourneyPublicController.photo', // The MCP transport — bearer tokens are verified inside the handler. + // The same documents under the resource URL, for clients that append the + // well-known path to the server address they were handed. + 'McpResourceDiscoveryController.authorizationServer', + 'McpResourceDiscoveryController.fallback', + 'McpResourceDiscoveryController.openidConfiguration', + 'McpResourceDiscoveryController.protectedResource', 'McpTransportController.delete', 'McpTransportController.get', 'McpTransportController.post', diff --git a/server/src/nest/platform/discovery.controller.ts b/server/src/nest/platform/discovery.controller.ts index b3d32fa8d..8230ec555 100644 --- a/server/src/nest/platform/discovery.controller.ts +++ b/server/src/nest/platform/discovery.controller.ts @@ -73,6 +73,15 @@ export class DiscoveryController { throw new NotFoundException(`Cannot ${req.method} ${req.originalUrl}`); } + // RFC 8414 path insertion: a client that starts from the resource URL + // (https://host/mcp) rather than the issuer looks for the AS metadata under + // the resource's path. The SDK router only serves the flat form, and the + // catchall below would answer 404 to a document we do have. + @Get('oauth-authorization-server/mcp') + authorizationServerForMcp(@Res() res: Response): void { + res.json(this.meta.getOAuthMetadata()); + } + // Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't // handle. Without this, the SPA catch-all serves HTML — clients probing // /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata @@ -83,3 +92,37 @@ export class DiscoveryController { res.status(404).json({ error: 'not_found' }); } } + +/** + * The same documents under the resource URL. A client handed https://host/mcp + * as the server address and looking for discovery appends the well-known path + * to it instead of asking the origin, and until now every one of those probes + * fell through to the SPA: 200 with an HTML body, which reads to the client as + * "this server has no OAuth" (Open WebUI reports it as an unexpected mimetype). + * Answering here costs one route per document and a JSON 404 for the rest. + */ +@Public('OAuth/OIDC discovery metadata read by MCP clients before any session exists') +@Controller('mcp/.well-known') +export class McpResourceDiscoveryController { + constructor(private readonly documents: DiscoveryController) {} + + @Get('openid-configuration') + openidConfiguration(@Res() res: Response): void { + this.documents.openidConfiguration(res); + } + + @Get('oauth-authorization-server') + authorizationServer(@Res() res: Response): void { + this.documents.authorizationServerForMcp(res); + } + + @Get('oauth-protected-resource') + protectedResource(@Res() res: Response): void { + this.documents.protectedResource(res); + } + + @All('*path') + fallback(@Res() res: Response): void { + res.status(404).json({ error: 'not_found' }); + } +} diff --git a/server/src/nest/platform/platform.module.ts b/server/src/nest/platform/platform.module.ts index 6df100c0e..d084d9c19 100644 --- a/server/src/nest/platform/platform.module.ts +++ b/server/src/nest/platform/platform.module.ts @@ -1,6 +1,6 @@ import { Module, type MiddlewareConsumer, type NestModule } from '@nestjs/common'; import { AddonsModule } from '../addons/addons.module'; -import { DiscoveryController } from './discovery.controller'; +import { DiscoveryController, McpResourceDiscoveryController } from './discovery.controller'; import { DiscoveryMetadataService } from './discovery-metadata.service'; import { mcpMetadataMiddlewareProvider } from './mcp-metadata.middleware'; import { ConsentCoopMiddleware } from './consent-coop.middleware'; @@ -23,8 +23,8 @@ import { ConsentCoopMiddleware } from './consent-coop.middleware'; */ @Module({ imports: [AddonsModule], - controllers: [DiscoveryController], - providers: [DiscoveryMetadataService, mcpMetadataMiddlewareProvider, ConsentCoopMiddleware], + controllers: [DiscoveryController, McpResourceDiscoveryController], + providers: [DiscoveryMetadataService, DiscoveryController, mcpMetadataMiddlewareProvider, ConsentCoopMiddleware], }) export class PlatformModule implements NestModule { configure(consumer: MiddlewareConsumer): void { diff --git a/server/tests/unit/nest/discovery.controller.test.ts b/server/tests/unit/nest/discovery.controller.test.ts index 0a08b55e1..4881120ba 100644 --- a/server/tests/unit/nest/discovery.controller.test.ts +++ b/server/tests/unit/nest/discovery.controller.test.ts @@ -24,7 +24,7 @@ import { mcpMetadataMiddlewareProvider, } from '../../../src/nest/platform/mcp-metadata.middleware'; import { ConsentCoopMiddleware } from '../../../src/nest/platform/consent-coop.middleware'; -import { DiscoveryController } from '../../../src/nest/platform/discovery.controller'; +import { DiscoveryController, McpResourceDiscoveryController } from '../../../src/nest/platform/discovery.controller'; import { PlatformModule } from '../../../src/nest/platform/platform.module'; import { ALL_SCOPES } from '../../../src/mcp/scopes'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; @@ -197,6 +197,45 @@ describe('DiscoveryController', () => { expect(names.indexOf('protectedResource')).toBeLessThan(names.indexOf('wellKnownRoot')); expect(names.indexOf('wellKnownRoot')).toBeLessThan(names.indexOf('wellKnownFallback')); }); + + it('DISC-037: the RFC 8414 path-inserted AS metadata is served, not 404', () => { + const res = makeRes(); + controller().authorizationServerForMcp(res as never); + expect((res.body as { issuer: string }).issuer).toBe('https://trek.example.test'); + expect(res.statusCode).toBe(200); + }); +}); + +describe('McpResourceDiscoveryController', () => { + function resourceController(enabled = true) { + return new McpResourceDiscoveryController(new DiscoveryController(new DiscoveryMetadataService(), addons(enabled))); + } + + it('DISC-038: serves the three documents under the resource URL, never SPA HTML', () => { + const oidc = makeRes(); + resourceController().openidConfiguration(oidc as never); + expect((oidc.body as { userinfo_endpoint: string }).userinfo_endpoint).toBe('https://trek.example.test/oauth/userinfo'); + + const as = makeRes(); + resourceController().authorizationServer(as as never); + expect((as.body as { registration_endpoint: string }).registration_endpoint).toBe('https://trek.example.test/oauth/register'); + + const prm = makeRes(); + resourceController().protectedResource(prm as never); + expect((prm.body as { resource: string }).resource).toBe('https://trek.example.test/mcp'); + }); + + it('DISC-039: an unknown path under the resource URL answers 404 JSON, and the PRM follows the addon switch', () => { + const res = makeRes(); + resourceController().fallback(res as never); + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + + const off = makeRes(); + resourceController(false).protectedResource(off as never); + expect(off.statusCode).toBe(404); + expect(off.ended).toBe(true); + }); }); describe('PlatformModule', () => { diff --git a/wiki/MCP-Setup.md b/wiki/MCP-Setup.md index 28beeb95b..aa4ceee5e 100644 --- a/wiki/MCP-Setup.md +++ b/wiki/MCP-Setup.md @@ -72,7 +72,15 @@ Create a client in TREK using the appropriate preset (Cursor, VS Code, Windsurf, > On Windows, `npx` may need a full path, for example `C:\PROGRA~1\nodejs\npx.cmd`. -> **Requirement:** `APP_URL` must be set on the server for OAuth discovery to work. +> **Requirement:** `APP_URL` must be set on the server for OAuth discovery to work, and it must be the public `https://` address. A plain-HTTP `APP_URL` is not a valid OAuth issuer, so TREK falls back to `http://localhost:{PORT}` in the discovery document and every remote client is sent to an address it cannot reach. The boot log says so: `APP_URL: not MCP-safe (requires https:// or http://localhost)`. + +TREK answers the discovery documents both at the origin (`/.well-known/oauth-authorization-server`, `/.well-known/openid-configuration`, `/.well-known/oauth-protected-resource`) and under the server address a client is given (`/mcp/.well-known/…`), because clients differ on where they look. Anything else below `/.well-known` answers a JSON 404, never the web app. + +### Redirect URI + +The redirect URI belongs to **your client, not to TREK**: it is the address the client wants the browser sent back to, so it is never your TREK URL and never `/mcp`. Open WebUI, for example, uses `{your Open WebUI URL}/oauth/clients/{connection id}/callback`, the Claude clients use their own callbacks, and the editors use `http://localhost`. When the client supports dynamic registration, let it register itself and the URI is filled in for you. A mismatch shows up at the consent step as `invalid_request: Unregistered redirect_uri`. + +TREK accepts `https://` URIs, plain HTTP only on `localhost` or `127.0.0.1`, and private custom schemes such as `myapp://callback`. A client running on a LAN address over plain HTTP is refused for that reason; put it behind HTTPS. ### Pre-created OAuth clients