mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -103,6 +103,7 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
|
|||||||
'ConfigController.getConfig',
|
'ConfigController.getConfig',
|
||||||
// OAuth/OIDC discovery documents + the JSON 404 catchalls that keep
|
// OAuth/OIDC discovery documents + the JSON 404 catchalls that keep
|
||||||
// /.well-known probes from ever seeing SPA HTML.
|
// /.well-known probes from ever seeing SPA HTML.
|
||||||
|
'DiscoveryController.authorizationServerForMcp',
|
||||||
'DiscoveryController.openidConfiguration',
|
'DiscoveryController.openidConfiguration',
|
||||||
'DiscoveryController.protectedResource',
|
'DiscoveryController.protectedResource',
|
||||||
'DiscoveryController.wellKnownFallback',
|
'DiscoveryController.wellKnownFallback',
|
||||||
@@ -129,6 +130,12 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
|
|||||||
'JourneyPublicController.legacyPhoto',
|
'JourneyPublicController.legacyPhoto',
|
||||||
'JourneyPublicController.photo',
|
'JourneyPublicController.photo',
|
||||||
// The MCP transport — bearer tokens are verified inside the handler.
|
// 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.delete',
|
||||||
'McpTransportController.get',
|
'McpTransportController.get',
|
||||||
'McpTransportController.post',
|
'McpTransportController.post',
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ export class DiscoveryController {
|
|||||||
throw new NotFoundException(`Cannot ${req.method} ${req.originalUrl}`);
|
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
|
// 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
|
// handle. Without this, the SPA catch-all serves HTML — clients probing
|
||||||
// /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata
|
// /.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' });
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Module, type MiddlewareConsumer, type NestModule } from '@nestjs/common';
|
import { Module, type MiddlewareConsumer, type NestModule } from '@nestjs/common';
|
||||||
import { AddonsModule } from '../addons/addons.module';
|
import { AddonsModule } from '../addons/addons.module';
|
||||||
import { DiscoveryController } from './discovery.controller';
|
import { DiscoveryController, McpResourceDiscoveryController } from './discovery.controller';
|
||||||
import { DiscoveryMetadataService } from './discovery-metadata.service';
|
import { DiscoveryMetadataService } from './discovery-metadata.service';
|
||||||
import { mcpMetadataMiddlewareProvider } from './mcp-metadata.middleware';
|
import { mcpMetadataMiddlewareProvider } from './mcp-metadata.middleware';
|
||||||
import { ConsentCoopMiddleware } from './consent-coop.middleware';
|
import { ConsentCoopMiddleware } from './consent-coop.middleware';
|
||||||
@@ -23,8 +23,8 @@ import { ConsentCoopMiddleware } from './consent-coop.middleware';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AddonsModule],
|
imports: [AddonsModule],
|
||||||
controllers: [DiscoveryController],
|
controllers: [DiscoveryController, McpResourceDiscoveryController],
|
||||||
providers: [DiscoveryMetadataService, mcpMetadataMiddlewareProvider, ConsentCoopMiddleware],
|
providers: [DiscoveryMetadataService, DiscoveryController, mcpMetadataMiddlewareProvider, ConsentCoopMiddleware],
|
||||||
})
|
})
|
||||||
export class PlatformModule implements NestModule {
|
export class PlatformModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
mcpMetadataMiddlewareProvider,
|
mcpMetadataMiddlewareProvider,
|
||||||
} from '../../../src/nest/platform/mcp-metadata.middleware';
|
} from '../../../src/nest/platform/mcp-metadata.middleware';
|
||||||
import { ConsentCoopMiddleware } from '../../../src/nest/platform/consent-coop.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 { PlatformModule } from '../../../src/nest/platform/platform.module';
|
||||||
import { ALL_SCOPES } from '../../../src/mcp/scopes';
|
import { ALL_SCOPES } from '../../../src/mcp/scopes';
|
||||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
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('protectedResource')).toBeLessThan(names.indexOf('wellKnownRoot'));
|
||||||
expect(names.indexOf('wellKnownRoot')).toBeLessThan(names.indexOf('wellKnownFallback'));
|
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', () => {
|
describe('PlatformModule', () => {
|
||||||
|
|||||||
+9
-1
@@ -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`.
|
> 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
|
### Pre-created OAuth clients
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user