mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
refactor(server): serve /mcp from a Nest controller; delete auth, audit, addons and oauth bridges
This commit is contained in:
+27
-15
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user