fix(mcp): add RFC 9728 PRM, RFC 8707 audience binding, and collab sub-feature gating

Root cause: claude.ai's MCP connector (spec 2025-06-18) requires the resource server
to publish Protected Resource Metadata and return WWW-Authenticate on 401s to bind
the /mcp endpoint to its AS. Without these, it silently shows no tools after OAuth.

- Add /.well-known/oauth-protected-resource (RFC 9728) with addon gating
- Emit WWW-Authenticate: Bearer resource_metadata=... on 401/auth-failure 403s
- Open CORS (origin: *) on both .well-known/* endpoints per RFC 8414/9728
- Accept resource parameter at authorize + token endpoints (RFC 8707)
- Store audience on oauth_tokens; validate on every MCP request
- Refresh tokens inherit audience; add resource_parameter_supported to AS metadata
- DB migration: ADD COLUMN audience TEXT to oauth_tokens
- Gate collab MCP tools/resources by chat/notes/polls sub-features individually
- Invalidate MCP sessions when collab sub-features are toggled in admin
- Update test mocks and MCP.md
This commit is contained in:
jubnl
2026-04-20 07:34:38 +02:00
parent 0d534f13cf
commit dd90c6d424
16 changed files with 125 additions and 51 deletions
+15
View File
@@ -11,6 +11,7 @@ import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { writeAudit, getClientIp } from '../services/auditLog';
import { getAppUrl } from '../services/oidcService';
export { revokeUserSessions, revokeUserSessionsForClient };
@@ -151,6 +152,12 @@ const sessionSweepInterval = setInterval(() => {
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.set('WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`);
}
interface VerifyTokenResult {
user: User;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
@@ -173,6 +180,11 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
if (token.startsWith('trekoa_')) {
const result = getUserByAccessToken(token);
if (!result) return null;
// RFC 8707: if the token carries an audience, it must match this resource endpoint
if (result.audience !== null) {
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
}
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
@@ -211,6 +223,7 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
const tokenResult = verifyToken(req.headers['authorization']);
if (!tokenResult) {
setAuthChallenge(res);
res.status(401).json({ error: 'Access token required' });
return;
}
@@ -231,10 +244,12 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
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;
}
+8 -4
View File
@@ -13,7 +13,7 @@ import { listCategories } from '../services/categoryService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications';
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
import { isAddonEnabled } from '../services/adminService';
import { isAddonEnabled, getCollabFeatures } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
import { canRead, canReadTrips } from './scopes';
@@ -188,7 +188,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
);
// Collab notes for a trip
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) server.registerResource(
const collabFeatures = isAddonEnabled(ADDON_IDS.COLLAB) ? getCollabFeatures() : null;
if (collabFeatures?.notes && canRead(scopes, 'collab')) server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
@@ -319,8 +320,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
);
}
// Collab polls & messages (addon-gated)
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) {
// Collab polls (addon + sub-feature gated)
if (collabFeatures?.polls && canRead(scopes, 'collab')) {
server.registerResource(
'trip-collab-polls',
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
@@ -332,7 +333,10 @@ export function registerResources(server: McpServer, userId: number, scopes: str
return jsonContent(uri.href, polls);
}
);
}
// Collab messages (addon + sub-feature gated)
if (collabFeatures?.chat && canRead(scopes, 'collab')) {
server.registerResource(
'trip-collab-messages',
new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }),
+15 -13
View File
@@ -7,7 +7,7 @@ import {
listPolls, createPoll, votePoll, closePoll, deletePoll,
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
} from '../../services/collabService';
import { isAddonEnabled } from '../../services/adminService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
@@ -22,9 +22,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (!isAddonEnabled(ADDON_IDS.COLLAB)) return;
const features = getCollabFeatures();
// --- COLLAB NOTES ---
if (W) server.registerTool(
if (features.notes && W) server.registerTool(
'create_collab_note',
{
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
@@ -47,7 +49,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.notes && W) server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
@@ -72,7 +74,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.notes && W) server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
@@ -94,7 +96,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
// --- COLLAB POLLS & CHAT ---
if (R) server.registerTool(
if (features.polls && R) server.registerTool(
'list_collab_polls',
{
description: 'List all polls for a trip.',
@@ -110,7 +112,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.polls && W) server.registerTool(
'create_collab_poll',
{
description: 'Create a new poll in the collab panel.',
@@ -132,7 +134,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.polls && W) server.registerTool(
'vote_collab_poll',
{
description: 'Vote on a poll option (or remove vote if already voted for that option).',
@@ -152,7 +154,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.polls && W) server.registerTool(
'close_collab_poll',
{
description: 'Close a poll so no more votes can be cast.',
@@ -172,7 +174,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.polls && W) server.registerTool(
'delete_collab_poll',
{
description: 'Delete a poll and all its votes.',
@@ -192,7 +194,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (R) server.registerTool(
if (features.chat && R) server.registerTool(
'list_collab_messages',
{
description: 'List chat messages for a trip (most recent 100, oldest-first).',
@@ -209,7 +211,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.chat && W) server.registerTool(
'send_collab_message',
{
description: "Send a chat message to a trip's collab channel.",
@@ -230,7 +232,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.chat && W) server.registerTool(
'delete_collab_message',
{
description: 'Delete a chat message (only the message owner can delete their own messages).',
@@ -250,7 +252,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (W) server.registerTool(
if (features.chat && W) server.registerTool(
'react_collab_message',
{
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
+11 -19
View File
@@ -12,7 +12,7 @@ import {
import {
createOrUpdateShareLink, getShareLink, deleteShareLink,
} from '../../services/shareService';
import { isAddonEnabled } from '../../services/adminService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { countMessages, listPolls } from '../../services/collabService';
import {
@@ -161,6 +161,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
@@ -173,16 +174,16 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
let pollCount = 0;
let messageCount = 0;
if (canReadCollab) {
pollCount = listPolls(tripId).length;
messageCount = countMessages(tripId);
if (collabFeatures?.polls) pollCount = listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = countMessages(tripId);
}
const notice = getDeprecationNotice();
const data = {
const summaryData = {
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
@@ -191,19 +192,10 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(data, null, 2) },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
],
};
return ok({
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
});
return ok(summaryData);
}
);