refactor(server): serve /mcp from a Nest controller; delete auth, audit, addons and oauth bridges

This commit is contained in:
jubnl
2026-08-11 23:01:19 +02:00
parent fac442086f
commit 954a3ca0a7
23 changed files with 861 additions and 638 deletions
+27 -15
View File
@@ -1,18 +1,17 @@
import nodeHttp from 'node:http';
import express from 'express';
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter, type NestExpressApplication } from '@nestjs/platform-express';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { AppModule } from './nest/app.module';
import { httpConfig } from './nest/app-config';
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes';
import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes';
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
import { setupApiDocs } from './nest/platform/api-docs';
import { McpRegistryService } from '@trek/nest-mcp';
import { setMcpRegistry } from './mcp/registry-handoff';
import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware';
import type { RequestHandler } from 'express';
import { validateBodyContracts } from './nest/common/validate-body-contracts';
import { validateRouteGuards } from './nest/common/validate-route-guards';
import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter';
@@ -83,7 +82,6 @@ export async function buildApp(): Promise<INestApplication> {
const http = app.get<ConfigType<typeof httpConfig>>(httpConfig.KEY);
applyGlobalMiddleware(instance, { http });
applyPlatformUploads(instance);
applyPlatformTransport(instance);
// The SDK discovery router (+ its addon gate). Container-built so its deps
// are injected (same pre-init consumption bridge as httpConfig above), but
// applied here as a PATHLESS app.use: the SDK router matches absolute
@@ -96,17 +94,31 @@ export async function buildApp(): Promise<INestApplication> {
// '100kb' and stopped doing it when the Nest instance took over parsing, which
// left the limit implicit — the same number, but nowhere anybody would find
// it, and one NestFactory default away from silently becoming something else.
// Configured through Nest rather than a second express.json(): its parser
// carries the verify hook that keeps req.rawBody, which the plugin webhook
// routes need to check a provider's HMAC over the exact payload.
const express = app as unknown as NestExpressApplication;
express.useBodyParser('json', { limit: '100kb' });
express.useBodyParser('urlencoded', { limit: '100kb', extended: true });
// The verify hook keeps req.rawBody, which the plugin webhook routes need to
// check a provider's HMAC over the exact payload (same hook Nest's own
// useBodyParser would install).
//
// /mcp is exempted and stays RAW: the MCP SDK's StreamableHTTPServerTransport
// reads the request stream itself (its own size handling and JSON-RPC error
// bodies), and the transport controller passes the still-undefined req.body
// through as handleRequest's parsedBody argument. No Nest route may ever
// @Body() on /mcp. The wrapper NAMES are load-bearing:
// ExpressAdapter.registerParserMiddleware skips its own init-time parsers
// only when layers named jsonParser/urlencodedParser already sit in the stack.
const rawBodyKeeper = (req: Request, _res: Response, buffer: Buffer) => {
if (Buffer.isBuffer(buffer)) (req as Request & { rawBody?: Buffer }).rawBody = buffer;
};
const json = express.json({ limit: '100kb', verify: rawBodyKeeper });
const urlencoded = express.urlencoded({ limit: '100kb', extended: true, verify: rawBodyKeeper });
const isMcp = (req: Request) => req.path === '/mcp' || req.path === '/mcp/';
instance.use(function jsonParser(req: Request, res: Response, next: NextFunction) {
return isMcp(req) ? next() : json(req, res, next);
});
instance.use(function urlencodedParser(req: Request, res: Response, next: NextFunction) {
return isMcp(req) ? next() : urlencoded(req, res, next);
});
if (apiDocsEnabled()) setupApiDocs(app);
await app.init();
// The /mcp handler is mounted pre-init (step 3) and has no DI access — hand
// it the boot-discovered registry now that the container is fully built.
setMcpRegistry(app.get(McpRegistryService));
// Fail closed on unvalidated mutation bodies: every POST/PUT/PATCH @Body()
// must carry a createZodDto class (validated by the global ZodValidationPipe)
// or sit on the ratchet-only legacy allow-list — otherwise refuse to boot.
+14 -345
View File
@@ -1,134 +1,25 @@
import { Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
import { User } from '../types';
import { verifyMcpToken, verifyJwtToken } from '../nest/auth/auth.bridge';
import { getUserByAccessToken } from '../nest/oauth/oauth.bridge';
import { isAddonEnabled } from '../nest/addons/addons.bridge';
import { ADDON_IDS } from '../addons';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser } from './sessionManager';
import { readEnv } from '../app-config';
import { writeAudit, getClientIp } from '../nest/audit/audit.bridge';
import { getMcpSafeUrl } from '../app-config';
import { sessions } from './sessionManager';
import { revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
export { revokeUserSessions, revokeUserSessionsForClient };
// ---------------------------------------------------------------------------
// Base instructions injected into every MCP session via the initialize response.
// Claude and other clients use these as system-level context before any tool call.
// Keep this actionable and concise — vague prose doesn't help the model.
// ---------------------------------------------------------------------------
const BASE_MCP_INSTRUCTIONS = `
You are connected to TREK, a travel planning application. Below is a compact reference of the data model, key workflows, and behavioral rules you must follow.
## Data model
- **Trip** — top-level container. Has dates, currency, members (owner + collaborators), and optional add-ons.
- **Day** — one calendar day within a trip (YYYY-MM-DD). Days are generated automatically when a trip is created with start/end dates.
- **Place** — a point of interest (POI) stored in the trip's place pool. A place is NOT on the itinerary until it is assigned to a day.
- **Assignment** — links a Place to a Day (ordered, with optional start/end time). This is what builds the daily itinerary.
- **Accommodation** — a hotel or rental linked to a Place and a check-in/check-out day range.
- **Reservation** — a booking record (flight, train, restaurant, etc.) with confirmation details, linked to a day.
- **Day note** — a free-text annotation attached to a day (with optional time label and emoji icon).
- **Budget item** — an expense entry for a trip (amount, category, payer, split between members).
- **Packing item** — a checklist entry grouped into bags and categories.
- **Todo** — a task (not packing-specific) attached to a trip, ordered and togglable.
- **Tag** — a label that can be applied to places for filtering.
- **Collab note / poll / message** — shared notes, decision polls, and chat messages for group trips.
- **Atlas** — global travel journal: bucket list, visited countries and regions.
- **Vacay** — vacation-day planner that tracks leave across team members and years.
- **Journey** — cross-trip travel narrative with dated entries, contributors, and share links. Requires the Journey addon.
## Key workflows
**Discovering trips:** Always call \`list_trips\` first when no trip ID has been provided. Never assume a trip ID.
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
**Adding a place to the itinerary (correct order):**
1. \`search_place\` — find the real-world POI; note the \`osm_id\`, \`google_place_id\`, and/or \`google_ftid\` in the result.
2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`.
**Creating an accommodation:** A place must exist in the trip first. Create the place (or reuse an existing one), then call \`create_accommodation\` with that \`place_id\` and the \`start_day_id\`/\`end_day_id\`.
**Reordering:** Assignments, todos, packing items, and reservations all support positional reordering via dedicated reorder tools. Always read the current order from \`get_trip_summary\` before reordering.
## Access rules
- The authenticated user can only access trips they own or are a member of. Never guess at trip IDs.
- Only the trip owner can delete the trip, add members, or remove members.
- Deleting a place removes all of its day assignments as well — warn the user before doing this.
- Trips created via MCP are capped at 90 days.
## Dates and times
- All dates use ISO format: **YYYY-MM-DD**.
- Times are strings like **"09:00"** or **"14:30"** (24-hour). Pass \`null\` to clear a time.
- When displaying dates to users, use a friendly human-readable format (e.g. "Mon, Apr 14").
## Add-on features
The following features are optional and may not be available on every TREK instance. Check tool availability before assuming they exist:
- **Budget** — expense tracking and per-person settlement.
- **Packing** — checklist with bags, categories, and templates.
- **Collab** — shared notes, polls, and chat messages for group trips.
- **Atlas** — bucket list and visited-country/region tracking.
- **Vacay** — team vacation-day planner with public holiday integration.
- **Journey** — cross-trip travel narrative with entries, contributors, and share links.
## Behavioral rules
- Prefer \`get_trip_summary\` over individual list tools when you need a full picture — it is one call instead of many.
- Use \`search_place\` before \`create_place\` so the app gets structured POI data (coordinates, address, opening hours). Do not skip this step.
- When the user asks to "add X to day Y", resolve both the place (search + create if needed) and the day ID before calling \`assign_place_to_day\`.
- Do not batch destructive operations (delete trip, delete day, delete place) without explicit user confirmation for each.
- Present budget amounts with the trip's currency. Use \`get_trip_summary\` to read the currency field.
- For group trips, always check member IDs via \`list_trip_members\` before calling tools that require a \`userId\` (e.g. budget splits, assignment participants).
`.trim();
const STATIC_TOKEN_DEPRECATION_NOTICE =
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
'The actual tool result follows — answer the user\'s question as well.';
/**
* Process-wide MCP state: the tuning knobs, the per-user rate-limit window,
* the session sweep, and the shutdown/invalidation entry points. The HTTP
* transport itself lives behind the container (src/nest/mcp-transport/);
* everything here is module-scoped ON PURPOSE — one sweep interval, one
* rate-limit map and one session map per process, no matter how many
* buildApp() instances the test workers create.
*/
// Configurable session TTL + SSE keep-alive cadence (#1414).
// Frozen at import on purpose (legacy timing) — MCP tuning knobs are boot-stable.
const mcpEnv = readEnv().mcp;
const SESSION_TTL_MS = mcpEnv.sessionTtlMs;
const MAX_SESSIONS_PER_USER = mcpEnv.maxSessionsPerUser;
const KEEPALIVE_MS = mcpEnv.sseKeepaliveMs;
export const SESSION_TTL_MS = mcpEnv.sessionTtlMs;
export const MAX_SESSIONS_PER_USER = mcpEnv.maxSessionsPerUser;
export const KEEPALIVE_MS = mcpEnv.sseKeepaliveMs;
/**
* Write SSE comment frames on an interval while the response is an open
* event stream. A no-op for JSON/error responses (content-type gate) and for
* per-POST streams that end quickly (writableEnded gate). `touch` refreshes
* the session's lastActivity so the sweep never evicts a session whose GET
* stream is still connected.
*/
function armSseKeepalive(res: Response, touch?: () => void): void {
// With pings disabled (MCP_SSE_KEEPALIVE=0) an open stream must STILL count
// as session activity, or the sweep would evict a live idle client — keep
// the interval for touch() and only skip the writes.
const writePings = KEEPALIVE_MS > 0;
if (!writePings && !touch) return;
const intervalMs = writePings ? KEEPALIVE_MS : 25_000;
const timer = setInterval(() => {
if (!res.headersSent) return;
const ct = String(res.getHeader('content-type') ?? '');
if (!ct.includes('text/event-stream') || res.writableEnded || res.destroyed) {
clearInterval(timer);
return;
}
if (writePings) res.write(': keepalive\n\n');
touch?.();
}, intervalMs);
timer.unref();
res.once('close', () => clearInterval(timer));
}
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX = mcpEnv.rateLimitMax; // requests per minute per user
@@ -138,7 +29,7 @@ interface RateLimitEntry {
}
const rateLimitMap = new Map<string, RateLimitEntry>();
function isRateLimited(userId: number, clientId: string | null): boolean {
export function isRateLimited(userId: number, clientId: string | null): boolean {
const key = `${userId}:${clientId ?? 'native'}`;
const now = Date.now();
const entry = rateLimitMap.get(key);
@@ -150,15 +41,6 @@ function isRateLimited(userId: number, clientId: string | null): boolean {
return entry.count > RATE_LIMIT_MAX;
}
function countSessionsForUser(userId: number): number {
const cutoff = Date.now() - SESSION_TTL_MS;
let count = 0;
for (const session of sessions.values()) {
if (session.userId === userId && session.lastActivity >= cutoff) count++;
}
return count;
}
const sessionSweepInterval = setInterval(() => {
const cutoff = Date.now() - SESSION_TTL_MS;
let cleaned = 0;
@@ -182,219 +64,6 @@ const sessionSweepInterval = setInterval(() => {
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
/** JSON-RPC 2.0 error body. MCP clients parse this and show `message`; a bare `{ error }`
* envelope isn't valid JSON-RPC, so they fall back to a generic "tool execution failed". */
function jsonRpcError(message: string, code = -32000): object {
return { jsonrpc: '2.0', error: { code, message }, id: null };
}
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
res.set('WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
}
interface VerifyTokenResult {
user: User;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
scopes: string[] | null;
/** OAuth client_id when authenticated via OAuth 2.1; null otherwise */
clientId: string | null;
isStaticToken: boolean;
}
function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
if (!authHeader) return null;
// M8: strictly require "Bearer" scheme (RFC 6750)
const spaceIdx = authHeader.indexOf(' ');
if (spaceIdx === -1) return null;
const scheme = authHeader.slice(0, spaceIdx);
const token = authHeader.slice(spaceIdx + 1);
if (scheme.toLowerCase() !== 'bearer' || !token) return null;
// OAuth 2.1 access token (trekoa_...)
if (token.startsWith('trekoa_')) {
const result = getUserByAccessToken(token);
if (!result) return null;
// RFC 8707: audience must always match this resource endpoint.
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
// Long-lived static MCP token (trek_...) — full access + deprecation notice
if (token.startsWith('trek_')) {
const user = verifyMcpToken(token);
if (!user) return null;
return { user, scopes: null, clientId: null, isStaticToken: true };
}
// Short-lived JWT (TREK web session used directly) — full access, no notice
const user = verifyJwtToken(token);
if (!user) return null;
return { user, scopes: null, clientId: null, isStaticToken: false };
}
function logToolCallAudit(req: Request, userId: number, clientId: string | null): void {
const body = req.body as Record<string, unknown> | undefined;
if (body?.method !== 'tools/call') return;
const toolName = (body?.params as Record<string, unknown> | undefined)?.name;
if (typeof toolName !== 'string') return;
writeAudit({
userId,
action: 'mcp.tool_call',
resource: toolName,
details: { clientId: clientId ?? 'native' },
ip: getClientIp(req),
});
}
export async function mcpHandler(req: Request, res: Response): Promise<void> {
if (!isAddonEnabled(ADDON_IDS.MCP)) {
res.status(403).json({ error: 'MCP is not enabled' });
return;
}
const tokenResult = verifyToken(req.headers['authorization']);
if (!tokenResult) {
setAuthChallenge(res);
res.status(401).json({ error: 'Access token required' });
return;
}
const { user, scopes, clientId, isStaticToken } = tokenResult;
if (isRateLimited(user.id, clientId)) {
res.status(429).json({ error: 'Too many requests. Please slow down.' });
return;
}
const sessionId = req.headers['mcp-session-id'] as string | undefined;
// Resume an existing session
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
if (session.userId !== user.id) {
setAuthChallenge(res);
res.status(403).json({ error: 'Session belongs to a different user' });
return;
}
if (session.clientId !== clientId) {
setAuthChallenge(res);
res.status(403).json({ error: 'Session was created with a different OAuth client' });
return;
}
session.lastActivity = Date.now();
logToolCallAudit(req, user.id, clientId);
armSseKeepalive(res, () => { session.lastActivity = Date.now(); });
try {
await session.transport.handleRequest(req, res, req.body);
} catch (err) {
console.error('[MCP] transport.handleRequest error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error' });
}
}
return;
}
// Only POST can initialize a new session
if (req.method !== 'POST') {
res.status(400).json({ error: 'Missing mcp-session-id header' });
return;
}
// A client that reuses its session sends the header on every call after initialize, so a
// steady stream of session-less POSTs means the id isn't making it back to the client —
// usually a reverse proxy dropping Mcp-Session-Id, or a client that ignores it. Say so,
// because the visible symptom (sessions piling up to the cap) points nowhere near the cause.
console.warn(
`[MCP] POST without mcp-session-id for user ${user.id} — starting a new session. ` +
'If this repeats on every tool call, the Mcp-Session-Id response header is not reaching ' +
'the client (check that your reverse proxy forwards it).',
);
// At the cap, evict this user's coldest session rather than refusing the request: a client
// stuck re-initializing would otherwise be locked out until the process restarted.
if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
const evicted = evictOldestSessionForUser(user.id);
if (!evicted) {
res.status(429).json(jsonRpcError('Session limit reached. Close an existing session before opening a new one.'));
return;
}
console.log(`[MCP] Session limit (${MAX_SESSIONS_PER_USER}) reached for user ${user.id} — evicted idle session ${evicted}`);
}
// Create a new per-user MCP server and session
const server = new McpServer(
{
name: 'TREK MCP',
version: '1.0.0',
},
{
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
},
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
}
);
// Per-session closure: fires the deprecation notice once, on the first tool call.
// Tool results are the only mechanism Claude reliably surfaces to the user;
// the instructions field is only background context and won't trigger a proactive warning.
let _noticeEmitted = false;
const getDeprecationNotice = (): string | null => {
if (!isStaticToken || _noticeEmitted) return null;
_noticeEmitted = true;
return STATIC_TOKEN_DEPRECATION_NOTICE;
};
registerTools(server, user.id, scopes, isStaticToken, getDeprecationNotice);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { server, transport, userId: user.id, scopes, clientId, isStaticToken, lastActivity: Date.now() });
const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt';
console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`);
},
onsessionclosed: (sid) => {
sessions.delete(sid);
},
});
// Belt-and-braces: whenever the SDK closes the transport for any reason, drop the map entry.
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId);
};
logToolCallAudit(req, user.id, clientId);
armSseKeepalive(res);
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error('[MCP] transport.handleRequest error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error' });
}
} finally {
// Only an `initialize` request assigns a sessionId (via onsessioninitialized). Any other
// session-less POST is rejected by the SDK with "Server not initialized" — and this server,
// with its ~200 registered tools, would otherwise be orphaned: never in `sessions`, never
// swept, never closed. Reap it here.
if (!transport.sessionId) {
try { server.close(); } catch { /* ignore */ }
try { transport.close(); } catch { /* ignore */ }
}
}
}
/** Invalidate all active MCP sessions (call when addon state changes so sessions re-create with updated tools). */
export function invalidateMcpSessions(): void {
for (const [sid, session] of sessions) {
-20
View File
@@ -1,20 +0,0 @@
import type { McpRegistry } from '@trek/nest-mcp';
/**
* Hands the Nest-discovered MCP registry to the non-Nest MCP handler.
*
* mcpHandler is mounted on the raw Express instance BEFORE app.init()
* (load-bearing composition order in bootstrap.ts), so it cannot reach the
* DI container itself — bootstrap.ts pushes the registry here right after
* init. registerTools() degrades gracefully while unset (direct callers
* like unit tests attach their own via createTestRegistry + setMcpRegistry).
*/
let registry: McpRegistry | null = null;
export function setMcpRegistry(value: McpRegistry): void {
registry = value;
}
export function getMcpRegistry(): McpRegistry | null {
return registry;
}
+6 -7
View File
@@ -1,7 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { getMcpRegistry } from './registry-handoff';
import type { McpRegistry } from '@trek/nest-mcp';
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
export function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
// The trip tools moved to the DI-discovered src/nest/trips/trips.mcp.ts and
// the share-link tools to src/nest/share/share.mcp.ts (@McpController,
// attached via the nest-mcp registry below — getDeprecationNotice rides the
@@ -61,10 +61,9 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
// static-token notice to auth.mcp.ts (its `if (isStaticToken)` became a
// `when` gate — the registry hands `when` the session context).
// Decorator-registered domains (@trek/nest-mcp) — migrating off the legacy
// registrar fan-out above, one domain at a time. Unset registry (direct
// callers without a Nest app, e.g. unit tests) ⇒ skip; the test harness
// attaches its own via createTestRegistry + setMcpRegistry.
const registry = getMcpRegistry();
// Decorator-registered domains (@trek/nest-mcp). Production passes the
// container-discovered McpRegistryService (injected into the transport
// service); the no-Nest harness hands in createTestRegistry()'s build.
// A null registry (direct callers without either) ⇒ skip.
if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice });
}
-26
View File
@@ -1,26 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { AddonsService } from './addons.service';
/**
* Non-Nest entry point for the addons domain — pinned by the MCP/OAuth
* transport (src/mcp/index.ts) and the pre-init platform.routes Express
* router, both mounted BEFORE app.init(), so the container is not available
* to them; systemNotices/conditions.ts reads it too until its enablement
* check is threaded in from the DI side. Inside the container, inject
* AddonsService instead (the `when:` gates in *.mcp.ts files do, via
* addon-gate.ts). Delete this file when the MCP/OAuth mount moves behind the
* container.
*
* Only the enablement READ is bridged, as an uncached per-call query on
* purpose: admin toggles must stay immediately visible without invalidation
* wiring.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const addons = new AddonsService(new DatabaseService(db));
export function isAddonEnabled(addonId: string): boolean {
return addons.isAddonEnabled(addonId);
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { DatabaseModule } from './database/database.module';
import { RealtimeModule } from './realtime/realtime.module';
import { HealthModule } from './health/health.module';
import { PlatformModule } from './platform/platform.module';
import { McpTransportModule } from './mcp-transport/mcp-transport.module';
import { GlobalAuthGuard } from './auth/global-auth.guard';
import { MfaPolicyGuard } from './auth/mfa-policy.guard';
import { WeatherModule } from './weather/weather.module';
@@ -70,7 +71,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
-22
View File
@@ -1,22 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { AuditService } from './audit.service';
/**
* Non-Nest entry point for the audit domain — pinned by the MCP/OAuth
* transport (src/mcp/index.ts and oauthProvider.ts), which platform.routes.ts
* mounts on the Express app BEFORE app.init(), so the container is not
* available to it. Inside the container, inject AuditService instead; log*-only
* consumers import audit-log.logger directly. Delete this file when the
* MCP/OAuth mount moves behind the container.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const audit = new AuditService(new DatabaseService(db));
export function writeAudit(entry: Parameters<AuditService['writeAudit']>[0]): void {
return audit.writeAudit(entry);
}
export { getClientIp } from './client-ip';
-56
View File
@@ -1,56 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { BudgetService } from '../budget/budget.service';
import { ExchangeRatesService } from '../budget/exchange-rates.service';
import { RealtimeService } from '../realtime/realtime.service';
import { AuthService } from './auth.service';
import { TokenService } from '../tokens/token.service';
import { MailerService } from '../notifications/mailer/mailer.service';
import { UserCleanupService } from './user-cleanup.service';
import { WebauthnConfigService } from './webauthn-config.service';
import { User } from '../../types';
import { EphemeralTokenService } from './ephemeral-token.service';
/**
* Non-Nest entry point for the auth domain — pinned by the MCP transport
* (src/mcp/index.ts token verification), which platform.routes.ts mounts on
* the Express app BEFORE app.init(), so the container is not available to it.
* Inside the container, inject AuthService/TokenService instead. Delete this
* file when the MCP/OAuth mount moves behind the container.
*
* Only two exports remain: verifyMcpToken lives on TokenService, but
* verifyJwtToken is login identity and deliberately stays on AuthService
* (see token.service.ts), so the whole AuthService collaborator graph below
* exists to serve that one call. WebauthnConfigService and UserCleanupService
* (with the BudgetService it injects) are constructed only because AuthService
* takes them — nothing this file
* exports reaches either one. The pending-MFA and reset-throttle maps are
* module-scoped in auth.service.ts, so this instance and the container
* singleton share one copy.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton (same pattern as permissions.bridge.ts).
*/
const dbs = new DatabaseService(db);
const permissions = new PermissionsService(dbs);
const auth = new AuthService(
dbs,
permissions,
new TripMembershipService(dbs),
new WebauthnConfigService(dbs),
new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), new RealtimeService())),
new MailerService(dbs),
new EphemeralTokenService(),
);
const tokens = new TokenService(dbs, new EphemeralTokenService());
export function verifyMcpToken(rawToken: string): User | null {
return tokens.verifyMcpToken(rawToken);
}
export function verifyJwtToken(token: string): User | null {
return auth.verifyJwtToken(token);
}
@@ -98,6 +98,10 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
'JourneyPublicController.get',
'JourneyPublicController.legacyPhoto',
'JourneyPublicController.photo',
// The MCP transport — bearer tokens are verified inside the handler.
'McpTransportController.delete',
'McpTransportController.get',
'McpTransportController.post',
'OauthPublicController.revoke',
'OauthPublicController.token',
'OauthPublicController.userinfo',
@@ -0,0 +1,79 @@
// ---------------------------------------------------------------------------
// Base instructions injected into every MCP session via the initialize response.
// Claude and other clients use these as system-level context before any tool call.
// Keep this actionable and concise — vague prose doesn't help the model.
// ---------------------------------------------------------------------------
export const BASE_MCP_INSTRUCTIONS = `
You are connected to TREK, a travel planning application. Below is a compact reference of the data model, key workflows, and behavioral rules you must follow.
## Data model
- **Trip** — top-level container. Has dates, currency, members (owner + collaborators), and optional add-ons.
- **Day** — one calendar day within a trip (YYYY-MM-DD). Days are generated automatically when a trip is created with start/end dates.
- **Place** — a point of interest (POI) stored in the trip's place pool. A place is NOT on the itinerary until it is assigned to a day.
- **Assignment** — links a Place to a Day (ordered, with optional start/end time). This is what builds the daily itinerary.
- **Accommodation** — a hotel or rental linked to a Place and a check-in/check-out day range.
- **Reservation** — a booking record (flight, train, restaurant, etc.) with confirmation details, linked to a day.
- **Day note** — a free-text annotation attached to a day (with optional time label and emoji icon).
- **Budget item** — an expense entry for a trip (amount, category, payer, split between members).
- **Packing item** — a checklist entry grouped into bags and categories.
- **Todo** — a task (not packing-specific) attached to a trip, ordered and togglable.
- **Tag** — a label that can be applied to places for filtering.
- **Collab note / poll / message** — shared notes, decision polls, and chat messages for group trips.
- **Atlas** — global travel journal: bucket list, visited countries and regions.
- **Vacay** — vacation-day planner that tracks leave across team members and years.
- **Journey** — cross-trip travel narrative with dated entries, contributors, and share links. Requires the Journey addon.
## Key workflows
**Discovering trips:** Always call \`list_trips\` first when no trip ID has been provided. Never assume a trip ID.
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
**Adding a place to the itinerary (correct order):**
1. \`search_place\` — find the real-world POI; note the \`osm_id\`, \`google_place_id\`, and/or \`google_ftid\` in the result.
2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`.
**Creating an accommodation:** A place must exist in the trip first. Create the place (or reuse an existing one), then call \`create_accommodation\` with that \`place_id\` and the \`start_day_id\`/\`end_day_id\`.
**Reordering:** Assignments, todos, packing items, and reservations all support positional reordering via dedicated reorder tools. Always read the current order from \`get_trip_summary\` before reordering.
## Access rules
- The authenticated user can only access trips they own or are a member of. Never guess at trip IDs.
- Only the trip owner can delete the trip, add members, or remove members.
- Deleting a place removes all of its day assignments as well — warn the user before doing this.
- Trips created via MCP are capped at 90 days.
## Dates and times
- All dates use ISO format: **YYYY-MM-DD**.
- Times are strings like **"09:00"** or **"14:30"** (24-hour). Pass \`null\` to clear a time.
- When displaying dates to users, use a friendly human-readable format (e.g. "Mon, Apr 14").
## Add-on features
The following features are optional and may not be available on every TREK instance. Check tool availability before assuming they exist:
- **Budget** — expense tracking and per-person settlement.
- **Packing** — checklist with bags, categories, and templates.
- **Collab** — shared notes, polls, and chat messages for group trips.
- **Atlas** — bucket list and visited-country/region tracking.
- **Vacay** — team vacation-day planner with public holiday integration.
- **Journey** — cross-trip travel narrative with entries, contributors, and share links.
## Behavioral rules
- Prefer \`get_trip_summary\` over individual list tools when you need a full picture — it is one call instead of many.
- Use \`search_place\` before \`create_place\` so the app gets structured POI data (coordinates, address, opening hours). Do not skip this step.
- When the user asks to "add X to day Y", resolve both the place (search + create if needed) and the day ID before calling \`assign_place_to_day\`.
- Do not batch destructive operations (delete trip, delete day, delete place) without explicit user confirmation for each.
- Present budget amounts with the trip's currency. Use \`get_trip_summary\` to read the currency field.
- For group trips, always check member IDs via \`list_trip_members\` before calling tools that require a \`userId\` (e.g. budget splits, assignment participants).
`.trim();
export const STATIC_TOKEN_DEPRECATION_NOTICE =
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
'The actual tool result follows — answer the user\'s question as well.';
@@ -0,0 +1,34 @@
import { Controller, Delete, Get, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Public } from '../auth/public.decorator';
import { McpTransportService } from './mcp-transport.service';
/**
* Pure passthrough: every response (SDK JSON-RPC frames, auth challenges,
* bespoke error bodies) is written by the service through @Res(), never
* thrown, so the global filters never re-envelope an MCP response. No @Body()
* — /mcp bodies are raw by design (see bootstrap's parser wrappers) and the
* SDK transport reads the stream itself. @Public keeps GlobalAuthGuard from
* ever touching the request; bearer verification happens in the service, and
* the IdempotencyInterceptor no-ops without a resolved req.user.
*/
@Public('the MCP transport endpoint — bearer tokens (OAuth 2.1 / static / JWT) are verified in the handler')
@Controller('mcp')
export class McpTransportController {
constructor(private readonly transport: McpTransportService) {}
@Post()
post(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.transport.handle(req, res);
}
@Get()
get(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.transport.handle(req, res);
}
@Delete()
delete(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.transport.handle(req, res);
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { McpTransportController } from './mcp-transport.controller';
import { McpTransportService } from './mcp-transport.service';
import { AuthModule } from '../auth/auth.module';
import { TokensModule } from '../tokens/tokens.module';
import { OauthModule } from '../oauth/oauth.module';
import { AddonsModule } from '../addons/addons.module';
/**
* The /mcp transport endpoint — the OAuth 2.1-authenticated MCP server the
* assistants speak to. The tools/resources/prompts themselves stay decorator-
* registered on the domain modules and are attached per session through the
* (global) McpRegistryService; this module owns only transport concerns:
* bearer verification, session create/resume, rate limiting, SSE keep-alive.
*/
@Module({
imports: [AuthModule, TokensModule, OauthModule, AddonsModule],
controllers: [McpTransportController],
providers: [McpTransportService],
})
export class McpTransportModule {}
@@ -0,0 +1,279 @@
import { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
import { McpRegistryService } from '@trek/nest-mcp';
import type { User } from '../../types';
import { ADDON_IDS } from '../../addons';
import { getMcpSafeUrl } from '../../app-config';
import { registerTools } from '../../mcp/tools';
import { sessions, evictOldestSessionForUser } from '../../mcp/sessionManager';
import { SESSION_TTL_MS, MAX_SESSIONS_PER_USER, KEEPALIVE_MS, isRateLimited } from '../../mcp';
import { BASE_MCP_INSTRUCTIONS, STATIC_TOKEN_DEPRECATION_NOTICE } from './mcp-transport.constants';
import { AuthService } from '../auth/auth.service';
import { TokenService } from '../tokens/token.service';
import { OauthService } from '../oauth/oauth.service';
import { AddonsService } from '../addons/addons.service';
/**
* The MCP transport handler behind the container — the former non-Nest
* mcpHandler (src/mcp/index.ts) with its bridge imports turned into
* injections. The controller passes every request straight through; all
* responses are written by hand (SDK JSON-RPC frames or the bespoke error
* bodies below), never thrown — nothing here may reach the global filters.
*
* /mcp bodies are RAW by design: bootstrap's parser wrappers skip the path,
* the SDK transport reads the stream itself (its own size handling and
* -32700/406/415 bodies), and `req.body` stays undefined — which is also why
* handleRequest's parsedBody argument is passed as-is. Never @Body() on /mcp.
*
* Process-wide state (session map, rate-limit map, sweep interval, tuning
* knobs) stays module-scoped in src/mcp — one instance per process no matter
* how many app instances the test workers build.
*/
/**
* Write SSE comment frames on an interval while the response is an open
* event stream. A no-op for JSON/error responses (content-type gate) and for
* per-POST streams that end quickly (writableEnded gate). `touch` refreshes
* the session's lastActivity so the sweep never evicts a session whose GET
* stream is still connected.
*/
export function armSseKeepalive(res: Response, touch?: () => void): void {
// With pings disabled (MCP_SSE_KEEPALIVE=0) an open stream must STILL count
// as session activity, or the sweep would evict a live idle client — keep
// the interval for touch() and only skip the writes.
const writePings = KEEPALIVE_MS > 0;
if (!writePings && !touch) return;
const intervalMs = writePings ? KEEPALIVE_MS : 25_000;
const timer = setInterval(() => {
if (!res.headersSent) return;
const ct = String(res.getHeader('content-type') ?? '');
if (!ct.includes('text/event-stream') || res.writableEnded || res.destroyed) {
clearInterval(timer);
return;
}
if (writePings) res.write(': keepalive\n\n');
touch?.();
}, intervalMs);
timer.unref();
res.once('close', () => clearInterval(timer));
}
export function countSessionsForUser(userId: number): number {
const cutoff = Date.now() - SESSION_TTL_MS;
let count = 0;
for (const session of sessions.values()) {
if (session.userId === userId && session.lastActivity >= cutoff) count++;
}
return count;
}
/** JSON-RPC 2.0 error body. MCP clients parse this and show `message`; a bare `{ error }`
* envelope isn't valid JSON-RPC, so they fall back to a generic "tool execution failed". */
export function jsonRpcError(message: string, code = -32000): object {
return { jsonrpc: '2.0', error: { code, message }, id: null };
}
export function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
res.set('WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
}
export interface VerifyTokenResult {
user: User;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
scopes: string[] | null;
/** OAuth client_id when authenticated via OAuth 2.1; null otherwise */
clientId: string | null;
isStaticToken: boolean;
}
@Injectable()
export class McpTransportService {
constructor(
private readonly auth: AuthService,
private readonly tokens: TokenService,
private readonly oauth: OauthService,
private readonly addons: AddonsService,
private readonly registry: McpRegistryService,
) {}
verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
if (!authHeader) return null;
// M8: strictly require "Bearer" scheme (RFC 6750)
const spaceIdx = authHeader.indexOf(' ');
if (spaceIdx === -1) return null;
const scheme = authHeader.slice(0, spaceIdx);
const token = authHeader.slice(spaceIdx + 1);
if (scheme.toLowerCase() !== 'bearer' || !token) return null;
// OAuth 2.1 access token (trekoa_...)
if (token.startsWith('trekoa_')) {
const result = this.oauth.getUserByAccessToken(token);
if (!result) return null;
// RFC 8707: audience must always match this resource endpoint.
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
// Long-lived static MCP token (trek_...) — full access + deprecation notice
if (token.startsWith('trek_')) {
const user = this.tokens.verifyMcpToken(token);
if (!user) return null;
return { user, scopes: null, clientId: null, isStaticToken: true };
}
// Short-lived JWT (TREK web session used directly) — full access, no notice
const user = this.auth.verifyJwtToken(token);
if (!user) return null;
return { user, scopes: null, clientId: null, isStaticToken: false };
}
async handle(req: Request, res: Response): Promise<void> {
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
res.status(403).json({ error: 'MCP is not enabled' });
return;
}
const tokenResult = this.verifyToken(req.headers['authorization']);
if (!tokenResult) {
setAuthChallenge(res);
res.status(401).json({ error: 'Access token required' });
return;
}
const { user, scopes, clientId, isStaticToken } = tokenResult;
if (isRateLimited(user.id, clientId)) {
res.status(429).json({ error: 'Too many requests. Please slow down.' });
return;
}
const sessionId = req.headers['mcp-session-id'] as string | undefined;
// Resume an existing session
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
if (session.userId !== user.id) {
setAuthChallenge(res);
res.status(403).json({ error: 'Session belongs to a different user' });
return;
}
if (session.clientId !== clientId) {
setAuthChallenge(res);
res.status(403).json({ error: 'Session was created with a different OAuth client' });
return;
}
session.lastActivity = Date.now();
armSseKeepalive(res, () => { session.lastActivity = Date.now(); });
try {
await session.transport.handleRequest(req, res, req.body);
} catch (err) {
console.error('[MCP] transport.handleRequest error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error' });
}
}
return;
}
// Only POST can initialize a new session
if (req.method !== 'POST') {
res.status(400).json({ error: 'Missing mcp-session-id header' });
return;
}
// A client that reuses its session sends the header on every call after initialize, so a
// steady stream of session-less POSTs means the id isn't making it back to the client —
// usually a reverse proxy dropping Mcp-Session-Id, or a client that ignores it. Say so,
// because the visible symptom (sessions piling up to the cap) points nowhere near the cause.
console.warn(
`[MCP] POST without mcp-session-id for user ${user.id} — starting a new session. ` +
'If this repeats on every tool call, the Mcp-Session-Id response header is not reaching ' +
'the client (check that your reverse proxy forwards it).',
);
// At the cap, evict this user's coldest session rather than refusing the request: a client
// stuck re-initializing would otherwise be locked out until the process restarted.
if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
const evicted = evictOldestSessionForUser(user.id);
if (!evicted) {
res.status(429).json(jsonRpcError('Session limit reached. Close an existing session before opening a new one.'));
return;
}
console.log(`[MCP] Session limit (${MAX_SESSIONS_PER_USER}) reached for user ${user.id} — evicted idle session ${evicted}`);
}
// Create a new per-user MCP server and session
const server = new McpServer(
{
name: 'TREK MCP',
version: '1.0.0',
},
{
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
},
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
}
);
// Per-session closure: fires the deprecation notice once, on the first tool call.
// Tool results are the only mechanism Claude reliably surfaces to the user;
// the instructions field is only background context and won't trigger a proactive warning.
let _noticeEmitted = false;
const getDeprecationNotice = (): string | null => {
if (!isStaticToken || _noticeEmitted) return null;
_noticeEmitted = true;
return STATIC_TOKEN_DEPRECATION_NOTICE;
};
registerTools(this.registry, server, user.id, scopes, isStaticToken, getDeprecationNotice);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { server, transport, userId: user.id, scopes, clientId, isStaticToken, lastActivity: Date.now() });
const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt';
console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`);
},
onsessionclosed: (sid) => {
sessions.delete(sid);
},
});
// Belt-and-braces: whenever the SDK closes the transport for any reason, drop the map entry.
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId);
};
armSseKeepalive(res);
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error('[MCP] transport.handleRequest error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error' });
}
} finally {
// Only an `initialize` request assigns a sessionId (via onsessioninitialized). Any other
// session-less POST is rejected by the SDK with "Server not initialized" — and this server,
// with its ~200 registered tools, would otherwise be orphaned: never in `sessions`, never
// swept, never closed. Reap it here.
if (!transport.sessionId) {
try { server.close(); } catch { /* ignore */ }
try { transport.close(); } catch { /* ignore */ }
}
}
}
}
-25
View File
@@ -1,25 +0,0 @@
import { db } from '../../db/database';
import { AddonsService } from '../addons/addons.service';
import { AuditService } from '../audit/audit.service';
import { DatabaseService } from '../database/database.service';
import { OauthService } from './oauth.service';
/**
* Non-Nest entry point for the OAuth domain — down to its last consumer: the
* MCP transport's token verification (src/mcp/index.ts), which
* platform.routes.ts mounts on the Express app BEFORE app.init(), so
* `app.get(OauthService)` is not available to it. The SDK provider adapter
* moved behind the container (oauth-sdk.provider.ts) and dropped the other six
* exports. Inside the container, inject OauthService instead. Delete this file
* when the /mcp mount moves behind the container.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton, and getUserByAccessToken touches no
* module-scoped state beyond it.
*/
const dbs = new DatabaseService(db);
const oauth = new OauthService(dbs, new AddonsService(dbs), new AuditService(dbs));
export function getUserByAccessToken(rawToken: string) {
return oauth.getUserByAccessToken(rawToken);
}
@@ -5,7 +5,6 @@ import fs from 'node:fs';
import { readEnv } from '../../app-config';
import { verifyJwtAndLoadUser } from '../auth/jwt-verify';
import { db } from '../../db/database';
import { mcpHandler } from '../../mcp';
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
@@ -94,21 +93,6 @@ export function applyPlatformUploads(app: express.Application): void {
});
}
/**
* The /mcp transport mounts — the last pre-init platform routes. Everything
* else that used to live here is behind the container now: /api/health
* (FeaturesController), OAuth discovery (DiscoveryController + the metadata
* middleware bootstrap applies pre-init), /oauth/authorize + /oauth/register
* (OauthModule.configure over the injected SDK adapters), and the
* /oauth/consent COOP override (ConsentCoopMiddleware).
*/
export function applyPlatformTransport(app: express.Application): void {
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
app.delete('/mcp', mcpHandler);
}
/**
* Production SPA serving: the built client static assets + the index.html catch-all
* for client-side routes. This is the LEGACY (plain Express 4) form — a real