mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
157 lines
7.0 KiB
TypeScript
157 lines
7.0 KiB
TypeScript
import {
|
|
McpController, Tool, ResourceTemplate, type McpContext,
|
|
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
ok,
|
|
} from '../../nest-mcp';
|
|
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
|
import { z } from 'zod';
|
|
import { idSchema, NOTE_COLORS, type NoteColor } from '@trek/shared';
|
|
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
|
import { DayNotesService } from './day-notes.service';
|
|
|
|
/**
|
|
* The palette itself rather than the REST contract's `z.string().max(9)`.
|
|
* normalizeNoteColor() stores anything off-palette as "no colour", so a loose
|
|
* string would let a caller send #ff0000, get a success back and never learn the
|
|
* colour was dropped. An enum is also how the tool tells a model what it may pick.
|
|
*/
|
|
const noteColorSchema = z.enum(NOTE_COLORS);
|
|
|
|
function parseId(value: string | string[]): number | null {
|
|
const n = Number(Array.isArray(value) ? value[0] : value);
|
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
}
|
|
|
|
/**
|
|
* Day-note MCP surface — ported 1:1 from the legacy registrars: the three
|
|
* tools from the DAY NOTES block of src/mcp/tools/days.ts and the
|
|
* trek://trips/{tripId}/days/{dayId}/notes resource from src/mcp/resources.ts
|
|
* (identical names, descriptions, schemas, annotations, error/payload shapes
|
|
* and broadcasts). The legacy registration-time gates map to the declarative
|
|
* trips write/read access markers (registerDayTools' whole-registrar
|
|
* `canWrite(scopes, 'trips')` early return and the resource's canReadTrips
|
|
* check, resolved by trekMcpAccessPolicy). No addon gate — day notes are core.
|
|
*
|
|
* The write tools have since gained `color` and `sort_order`, which the REST
|
|
* contract carried all along: without them an MCP note could not be coloured
|
|
* and landed at the bottom of the day's merged timeline whatever the caller
|
|
* meant.
|
|
*/
|
|
@McpController()
|
|
export class DayNotesMcp {
|
|
constructor(
|
|
private readonly notes: DayNotesService,
|
|
private readonly guards: McpToolGuardsService,
|
|
) {}
|
|
|
|
@Tool({
|
|
name: 'create_day_note',
|
|
description: 'Add a note to a specific day in a trip.',
|
|
inputSchema: {
|
|
tripId: idSchema,
|
|
dayId: idSchema,
|
|
text: z.string().min(1).max(500),
|
|
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
|
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
|
|
color: noteColorSchema.nullable().optional().describe('Card colour from the note palette; null or omitted leaves the neutral card'),
|
|
sort_order: z.number().optional().describe('Position in the day, lowest first, interleaved with the places of that day. Omit to append at the bottom'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
access: { group: 'trips', mode: 'write' },
|
|
})
|
|
async createDayNote(
|
|
{ tripId, dayId, text, time, icon, color, sort_order }: {
|
|
tripId: number; dayId: number; text: string; time?: string; icon?: string; color?: NoteColor | null; sort_order?: number;
|
|
},
|
|
ctx: McpContext,
|
|
) {
|
|
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
|
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
|
if (!(await this.notes.dayExists(dayId, tripId))) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
|
const note = await this.notes.create(dayId, tripId, text, time, icon, sort_order, color);
|
|
this.guards.safeBroadcast(tripId, 'dayNote:created', { dayId, note });
|
|
return ok({ note });
|
|
}
|
|
|
|
@Tool({
|
|
name: 'update_day_note',
|
|
description: 'Edit an existing note on a specific day.',
|
|
inputSchema: {
|
|
tripId: idSchema,
|
|
dayId: idSchema,
|
|
noteId: idSchema,
|
|
text: z.string().min(1).max(500).optional(),
|
|
time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
|
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
|
|
color: noteColorSchema.nullable().optional().describe('Card colour from the note palette, or null to go back to the neutral card'),
|
|
sort_order: z.number().optional().describe('New position in the day, lowest first, interleaved with the places of that day'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
access: { group: 'trips', mode: 'write' },
|
|
})
|
|
async updateDayNote(
|
|
{ tripId, dayId, noteId, text, time, icon, color, sort_order }: {
|
|
tripId: number; dayId: number; noteId: number; text?: string; time?: string | null; icon?: string; color?: NoteColor | null; sort_order?: number;
|
|
},
|
|
ctx: McpContext,
|
|
) {
|
|
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
|
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
|
const existing = await this.notes.getNote(noteId, dayId, tripId);
|
|
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
|
const note = await this.notes.update(noteId, existing, { text, time: time !== undefined ? time : undefined, icon, color, sort_order });
|
|
this.guards.safeBroadcast(tripId, 'dayNote:updated', { dayId, note });
|
|
return ok({ note });
|
|
}
|
|
|
|
@Tool({
|
|
name: 'delete_day_note',
|
|
description: 'Delete a note from a specific day.',
|
|
inputSchema: {
|
|
tripId: idSchema,
|
|
dayId: idSchema,
|
|
noteId: idSchema,
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_DELETE,
|
|
access: { group: 'trips', mode: 'write' },
|
|
})
|
|
async deleteDayNote({ tripId, dayId, noteId }: { tripId: number; dayId: number; noteId: number }, ctx: McpContext) {
|
|
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
|
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
|
const note = await this.notes.getNote(noteId, dayId, tripId);
|
|
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
|
await this.notes.remove(noteId);
|
|
this.guards.safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId });
|
|
return ok({ success: true });
|
|
}
|
|
|
|
@ResourceTemplate({
|
|
name: 'day-notes',
|
|
uriTemplate: 'trek://trips/{tripId}/days/{dayId}/notes',
|
|
description: 'Notes for a specific day in a trip',
|
|
mimeType: 'application/json',
|
|
access: { group: 'trips', mode: 'read' },
|
|
})
|
|
async dayNotesResource(uri: URL, { tripId, dayId }: { tripId: string | string[]; dayId: string | string[] }, ctx: McpContext) {
|
|
const tId = parseId(tripId);
|
|
const dId = parseId(dayId);
|
|
if (tId === null || dId === null || !(await this.notes.verifyTripAccess(tId, ctx.userId))) {
|
|
return {
|
|
contents: [{
|
|
uri: uri.href,
|
|
mimeType: 'application/json',
|
|
text: JSON.stringify({ error: 'Trip not found or access denied' }),
|
|
}],
|
|
};
|
|
}
|
|
const notes = await this.notes.list(dId, tId);
|
|
return {
|
|
contents: [{
|
|
uri: uri.href,
|
|
mimeType: 'application/json',
|
|
text: JSON.stringify(notes, null, 2),
|
|
}],
|
|
};
|
|
}
|
|
}
|