feat(mcp): note colours, note order, a note website, and category CRUD

A day note could not be coloured or positioned, so the service default of
9999 pinned every note an assistant wrote to the bottom of the timeline. A
collab note could not carry the website the card renders.

Categories could be assigned by bulk_update_places but never created: the
whole file was one read tool and a resource. The three new tools are
admin-only, matching the REST routes exactly.
This commit is contained in:
Maurice
2026-08-28 13:07:40 +02:00
committed by Maurice
parent 5d0fac3026
commit 9e21eb5d10
6 changed files with 570 additions and 21 deletions
+76 -2
View File
@@ -1,7 +1,14 @@
import {
McpController, Tool, Resource, type McpContext,
TOOL_ANNOTATIONS_READONLY, ok,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { createCategoryRequestSchema, updateCategoryRequestSchema } from '@trek/shared';
import { AuthService } from '../auth/auth.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { adminRequired } from '../../mcp/tools/_shared';
import { CategoriesService } from './categories.service';
/**
@@ -12,10 +19,21 @@ import { CategoriesService } from './categories.service';
* src/mcp/resources.ts (first production @Resource; intentionally ungated —
* the legacy registration was unconditional, safe for any authenticated
* session — and the payload reproduces the legacy jsonContent shape verbatim).
*
* The three write tools mirror POST/PUT/DELETE /api/categories, which sit
* behind JwtAuthGuard + AdminGuard: the palette is instance-wide, not
* trip-scoped, so there is no trip to check access against and the admin role
* is the whole gate. Bodies reuse the same @trek/shared contracts the
* controller's DTOs wrap, so `color` stays hex-only on both surfaces (it is
* interpolated into a style="…" attribute of hand-built marker HTML).
*/
@McpController()
export class CategoriesMcp {
constructor(private readonly categories: CategoriesService) {}
constructor(
private readonly categories: CategoriesService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
) {}
@Tool({
name: 'list_categories',
@@ -29,6 +47,62 @@ export class CategoriesMcp {
return ok({ categories });
}
@Tool({
name: 'create_category',
description: 'Add a new place category to the instance-wide palette. Admin only. Prefer an existing category from list_categories: this mints one every trip on the instance will see, so only reach for it when nothing in the palette fits.',
inputSchema: {
name: createCategoryRequestSchema.shape.name.describe('Category label, e.g. "Street food"'),
color: createCategoryRequestSchema.shape.color.describe('Hex colour for the map marker (defaults to #6366f1)'),
icon: createCategoryRequestSchema.shape.icon.describe('Emoji shown on the marker (defaults to 📍)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'places', mode: 'write' },
})
async createCategory({ name, color, icon }: { name: string; color?: string; icon?: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
// The palette is instance-wide; the REST route restricts management to admins. Match it.
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
const category = this.categories.create(ctx.userId, name, color, icon);
return ok({ category });
}
@Tool({
name: 'update_category',
description: 'Rename an existing place category or change its colour or icon. Admin only. Every place already carrying the category follows the change, so use this to fix a palette entry rather than to reclassify places.',
inputSchema: {
categoryId: z.number().int().positive().describe('Category ID from list_categories'),
name: updateCategoryRequestSchema.shape.name,
color: updateCategoryRequestSchema.shape.color.describe('Hex colour for the map marker'),
icon: updateCategoryRequestSchema.shape.icon.describe('Emoji shown on the marker'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async updateCategory({ categoryId, name, color, icon }: { categoryId: number; name?: string; color?: string; icon?: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
if (!this.categories.getById(categoryId)) return errorResult('Category not found');
const category = this.categories.update(categoryId, name, color, icon);
return ok({ category });
}
@Tool({
name: 'delete_category',
description: 'Remove a place category from the instance-wide palette. Admin only. Places keep their data but lose the category, across every trip on the instance. Use update_category when the entry only needs fixing.',
inputSchema: {
categoryId: z.number().int().positive().describe('Category ID from list_categories'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: { group: 'places', mode: 'write' },
})
async deleteCategory({ categoryId }: { categoryId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
if (!this.categories.getById(categoryId)) return errorResult('Category not found');
this.categories.remove(categoryId);
return ok({ success: true });
}
@Resource({
name: 'categories',
uri: 'trek://categories',
@@ -1,4 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { CategoriesController } from './categories.controller';
import { CategoriesMcp } from './categories.mcp';
import { CategoriesService } from './categories.service';
@@ -6,6 +8,9 @@ import { CategoriesRpc } from './categories.rpc';
/** Categories domain (L4 leaf module). Registered in AppModule. */
@Module({
// CategoriesMcp gates its three admin tools on AuthService, and McpSharedModule
// is deliberately not @Global, so both are imported rather than inherited.
imports: [AuthModule, McpSharedModule],
controllers: [CategoriesController],
providers: [CategoriesService, CategoriesMcp, CategoriesRpc],
// For in-container consumers (CategoriesRpc).
+11 -7
View File
@@ -57,7 +57,9 @@ function jsonContent(uri: string, data: unknown) {
* read/write access markers (the legacy `if (R)` / `if (W)` checks, resolved
* by trekMcpAccessPolicy). The list tools check only trip access (as legacy);
* vote_collab_poll gained the demo-user gate the legacy registrar was missing,
* matching every other collab write tool.
* matching every other collab write tool. The two note write tools have since
* gained `website`, which the REST route has always accepted and the note card
* renders as a link preview.
*/
@McpController()
export class CollabMcp {
@@ -79,6 +81,7 @@ export class CollabMcp {
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'),
website: z.string().max(500).nullable().optional().describe('Link to attach to the note; the card renders it as a preview thumbnail. Pass null to remove it'),
pinned: z.boolean().optional().default(false).describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -86,15 +89,15 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async createCollabNote(
{ tripId, title, content, category, color, pinned }: {
tripId: number; title: string; content?: string; category?: string; color?: string; pinned?: boolean;
{ tripId, title, content, category, color, website, pinned }: {
tripId: number; title: string; content?: string; category?: string; color?: string; website?: string | null; pinned?: boolean;
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, pinned });
const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned });
this.guards.safeBroadcast(tripId, 'collab:note:created', { note });
return ok({ note });
}
@@ -109,6 +112,7 @@ export class CollabMcp {
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'),
website: z.string().max(500).nullable().optional().describe('Link to attach to the note, or null to remove the one it has'),
pinned: z.boolean().optional().describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
@@ -116,15 +120,15 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async updateCollabNote(
{ tripId, noteId, title, content, category, color, pinned }: {
tripId: number; noteId: number; title?: string; content?: string; category?: string; color?: string; pinned?: boolean;
{ tripId, noteId, title, content, category, color, website, pinned }: {
tripId: number; noteId: number; title?: string; content?: string; category?: string; color?: string; website?: string | null; pinned?: boolean;
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, pinned });
const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, website, pinned });
if (!note) return errorResult('Note not found.');
this.guards.safeBroadcast(tripId, 'collab:note:updated', { note });
return ok({ note });
+26 -4
View File
@@ -5,10 +5,19 @@ import {
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { NOTE_COLORS, type NoteColor } from '@trek/shared';
import { AuthService } from '../auth/auth.service';
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;
@@ -23,6 +32,11 @@ function parseId(value: string | string[]): number | null {
* 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 {
@@ -41,19 +55,23 @@ export class DayNotesMcp {
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 }: { tripId: number; dayId: number; text: string; time?: string; icon?: string },
{ 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 (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.notes.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const note = this.notes.create(dayId, tripId, text, time, icon);
const note = this.notes.create(dayId, tripId, text, time, icon, sort_order, color);
this.guards.safeBroadcast(tripId, 'dayNote:created', { dayId, note });
return ok({ note });
}
@@ -68,12 +86,16 @@ export class DayNotesMcp {
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 }: { tripId: number; dayId: number; noteId: number; text?: string; time?: string | null; icon?: string },
{ 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 (this.auth.isDemoUser(ctx.userId)) return demoDenied();
@@ -81,7 +103,7 @@ export class DayNotesMcp {
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
const existing = this.notes.getNote(noteId, dayId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
const note = this.notes.update(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
const note = 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 });
}