Files
TREK/server/src/mcp/tools/collab.ts
T
jubnl dd90c6d424 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
2026-04-20 07:34:38 +02:00

276 lines
11 KiB
TypeScript

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote,
listPolls, createPoll, votePoll, closePoll, deletePoll,
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
} from '../../services/collabService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerCollabTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'collab');
const W = canWrite(scopes, 'collab');
if (!isAddonEnabled(ADDON_IDS.COLLAB)) return;
const features = getCollabFeatures();
// --- COLLAB NOTES ---
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).',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().default(false).describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
safeBroadcast(tripId, 'collab:note:created', { note });
return ok({ note });
}
);
if (features.notes && W) server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, noteId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:updated', { note });
return ok({ note });
}
);
if (features.notes && W) server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteCollabNote(tripId, noteId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:deleted', { noteId });
return ok({ success: true });
}
);
// --- COLLAB POLLS & CHAT ---
if (features.polls && R) server.registerTool(
'list_collab_polls',
{
description: 'List all polls for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const polls = listPolls(tripId);
return ok({ polls });
}
);
if (features.polls && W) server.registerTool(
'create_collab_poll',
{
description: 'Create a new poll in the collab panel.',
inputSchema: {
tripId: z.number().int().positive(),
question: z.string().min(1),
options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'),
multiple: z.boolean().optional().describe('Allow multiple choice'),
deadline: z.string().optional().describe('ISO date string for poll deadline'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, question, options, multiple, deadline }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const poll = createPoll(tripId, userId, { question, options, multiple, deadline });
safeBroadcast(tripId, 'collab:poll:created', { poll });
return ok({ poll });
}
);
if (features.polls && W) server.registerTool(
'vote_collab_poll',
{
description: 'Vote on a poll option (or remove vote if already voted for that option).',
inputSchema: {
tripId: z.number().int().positive(),
pollId: z.number().int().positive(),
optionIndex: z.number().int().min(0).describe('Zero-based index of the option to vote for'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, pollId, optionIndex }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = votePoll(tripId, pollId, userId, optionIndex);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll });
return ok({ poll: result.poll });
}
);
if (features.polls && W) server.registerTool(
'close_collab_poll',
{
description: 'Close a poll so no more votes can be cast.',
inputSchema: {
tripId: z.number().int().positive(),
pollId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, pollId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const poll = closePoll(tripId, pollId);
if (!poll) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
safeBroadcast(tripId, 'collab:poll:closed', { poll });
return ok({ poll });
}
);
if (features.polls && W) server.registerTool(
'delete_collab_poll',
{
description: 'Delete a poll and all its votes.',
inputSchema: {
tripId: z.number().int().positive(),
pollId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, pollId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePoll(tripId, pollId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
safeBroadcast(tripId, 'collab:poll:deleted', { pollId });
return ok({ success: true });
}
);
if (features.chat && R) server.registerTool(
'list_collab_messages',
{
description: 'List chat messages for a trip (most recent 100, oldest-first).',
inputSchema: {
tripId: z.number().int().positive(),
before: z.number().int().positive().optional().describe('Load messages with ID less than this (pagination)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, before }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const messages = listMessages(tripId, before);
return ok({ messages });
}
);
if (features.chat && W) server.registerTool(
'send_collab_message',
{
description: "Send a chat message to a trip's collab channel.",
inputSchema: {
tripId: z.number().int().positive(),
text: z.string().min(1),
replyTo: z.number().int().positive().optional().describe('Reply to a specific message ID'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, text, replyTo }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = createMessage(tripId, userId, text, replyTo ?? null);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:message:created', { message: result.message });
return ok({ message: result.message });
}
);
if (features.chat && W) server.registerTool(
'delete_collab_message',
{
description: 'Delete a chat message (only the message owner can delete their own messages).',
inputSchema: {
tripId: z.number().int().positive(),
messageId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, messageId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = deleteMessage(tripId, messageId, userId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username });
return ok({ success: true });
}
);
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).',
inputSchema: {
tripId: z.number().int().positive(),
messageId: z.number().int().positive(),
emoji: z.string().describe('Single emoji character'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, messageId, emoji }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = addOrRemoveReaction(messageId, tripId, userId, emoji);
if (!result.found) return { content: [{ type: 'text' as const, text: 'Message not found.' }], isError: true };
safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions });
return ok({ reactions: result.reactions });
}
);
}