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
+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);
}
);