mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
0f0232491b
create_day_note and update_day_note described `time` as a clock label and capped it at 250 characters, while the planner shows it as the Markdown body under the title and allows 2000. The assistant put long text in the title and had to cut notes it was asked to edit. Both tools now take text and time from the REST contract with descriptions naming title and body, and an update still refuses an empty title. The assistant's change card labels the two fields title and content instead of text and time. Closes #2840
241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
|
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 { DayNotesService } from './day-notes.service';
|
|
import {
|
|
dayNoteCreateRequestSchema,
|
|
dayNoteUpdateRequestSchema,
|
|
idSchema,
|
|
NOTE_COLORS,
|
|
type NoteColor,
|
|
} from '@trek/shared';
|
|
|
|
import { z } from 'zod';
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/**
|
|
* The note's two text fields carry their names from the time a note was a line
|
|
* with a clock time: `text` is the heading and `time` the Markdown body under it.
|
|
* The REST contract holds the bounds, so a note written in the planner can always
|
|
* be edited here; the descriptions are what tells a model which field is which.
|
|
*/
|
|
const NOTE_TITLE = 'The note title, shown as its heading in the day plan. Keep it short';
|
|
const NOTE_BODY =
|
|
'The note body shown under the title: details, links, reminders, Markdown allowed. ' +
|
|
'Despite the field name this is the content, not a clock time';
|
|
const noteTitleSchema = dayNoteCreateRequestSchema.shape.text.describe(NOTE_TITLE);
|
|
|
|
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: a title in `text` and its body in `time`.',
|
|
inputSchema: {
|
|
tripId: idSchema,
|
|
dayId: idSchema,
|
|
text: noteTitleSchema,
|
|
time: dayNoteCreateRequestSchema.shape.time.describe(NOTE_BODY),
|
|
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 | 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();
|
|
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: its title is `text`, its body `time`.',
|
|
inputSchema: {
|
|
tripId: idSchema,
|
|
dayId: idSchema,
|
|
noteId: idSchema,
|
|
// The create bound, so an update cannot blank the title the planner requires.
|
|
text: noteTitleSchema.optional(),
|
|
time: dayNoteUpdateRequestSchema.shape.time.describe(`${NOTE_BODY}, or null to clear it`),
|
|
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),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|