feat(mcp): the bundled help pages, and which addons are on

131 of 201 tools carry an addon gate, and the registry drops an entry when
either the addon gate or the scope gate fails: one filter, one outcome. The
session instructions tell the model to check what is available before
assuming a tool exists while giving it nothing to check with. list_addons is
that something, and it is the difference between a confused apology and
'ask your admin to enable Budget'.

The help routes were already public, so the two reader tools add no scope
surface at all. The asset route stays out: it serves bytes to an img tag.
This commit is contained in:
Maurice
2026-08-28 13:45:30 +02:00
committed by Maurice
parent ed67a5e9f8
commit 9ed35d182f
5 changed files with 562 additions and 2 deletions
+43
View File
@@ -0,0 +1,43 @@
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
import { AddonsService } from './addons.service';
/**
* Instance-capability MCP surface, reading through the same AddonsService.list()
* that GET /api/addons returns.
*
* Why it exists: the registry drops an entry when either its addon gate or its
* scope gate says no, one filter with one outcome, so a session that finds no
* budget tools cannot tell "the admin switched budget off" from "this token was
* never granted budget:read". The session instructions tell the model to check
* availability before assuming a tool exists, which until now it had nothing to
* check with.
*
* No `access` marker, so it is registered for every session. The REST route is
* gated by JwtAuthGuard and nothing else, so there is no scope to mirror; the
* payload is instance configuration rather than anybody's data; and a scope gate
* would withhold the availability answer from exactly the sessions whose thin
* tool list needs explaining. settings:read is not the honest fit either, it
* covers a user's own preferences.
*/
@McpController()
export class AddonsMcp {
constructor(private readonly addons: AddonsService) {}
@Tool({
name: 'list_addons',
description:
'Report which optional add-ons are enabled on this TREK instance, which collab sub-features (chat, notes, polls, whatsnext) are switched on, and whether packing bag tracking is on. Only enabled entries are listed, so anything absent is off here. Tools belonging to a disabled add-on are never registered, so call this to tell "the feature is off on this instance" apart from "the tool call failed", and before promising a user that budget, packing, collab, atlas, vacay, journey or collections will work.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
})
listAddons(_args: Record<string, never>, _ctx: McpContext) {
const { addons, collabFeatures, bagTracking } = this.addons.list();
// icon, config and fields are admin-panel chrome: an icon name and the
// photo-provider settings form say nothing about what a tool can do.
return ok({
addons: addons.map(({ id, name, type, enabled }) => ({ id, name, type, enabled })),
collabFeatures,
bagTracking,
});
}
}
+6 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AddonsController } from './addons.controller';
import { AddonsService } from './addons.service';
import { AddonGuard } from './addon.guard';
import { AddonsMcp } from './addons.mcp';
/**
* GET /api/addons — enabled add-ons + photo providers (was an inline handler in
@@ -13,10 +14,14 @@ import { AddonGuard } from './addon.guard';
* adminService per finding `admin-1`) — for the in-container consumers.
* Deliberately NOT @Global (permissions precedent): e2e TestingModules resolve
* it transitively through each consumer's explicit import.
*
* AddonsMcp puts the same listing on the MCP surface, so a model can read which
* add-ons this instance has switched on instead of guessing from the tools it
* was handed.
*/
@Module({
controllers: [AddonsController],
providers: [AddonsService, AddonGuard],
providers: [AddonsService, AddonGuard, AddonsMcp],
exports: [AddonsService, AddonGuard],
})
export class AddonsModule {}
+95
View File
@@ -0,0 +1,95 @@
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '../../nest-mcp';
import { z } from 'zod';
import { getWikiIndex, getWikiPage, WikiNotFound, type WikiPage } from './wiki';
/**
* Help MCP surface over the bundled wiki, reading through the same functions
* HelpController does (GET /api/help/index, GET /api/help/page/:slug).
*
* Neither tool declares an `access` marker, so both stay registered for every
* session. Both routes are @Public and serve versioned product documentation
* with no user data in it, so there is no permission to mirror; borrowing some
* other domain's read scope would hide the manual from tokens that were never
* meant to be denied it, and protect nothing. list_trips and get_trip_summary
* are registered the same way, for the same reason: a client needs them to find
* its bearings before it knows what else to ask for.
*
* No injected service, like airports.mcp.ts: the help domain is a set of plain
* readers over the `wiki/` directory rather than a provider.
*
* GET /api/help/asset/* has no tool on purpose. It answers with image bytes for
* an <img> tag, which is not something a tool result carries.
*/
/**
* Plugin-Development.md alone is 100 KB, and a tool result lands directly in a
* model's context window. Hand back a slice and let the caller ask for the rest
* rather than deciding on its behalf that the tail does not matter.
*/
const HELP_PAGE_CHARS = 40_000;
@McpController()
export class HelpMcp {
@Tool({
name: 'list_help_topics',
description:
'List the table of contents of the TREK user manual bundled with this instance: sections, page titles, and the slugs get_help_page takes. Start here when the user asks how something in TREK works or how to do something in the app, then read the matching page. This is documentation about the product, never the user\'s own data: for that use list_trips or get_trip_summary.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
})
async listHelpTopics(_args: Record<string, never>, _ctx: McpContext) {
try {
const { sections } = await getWikiIndex();
return ok({ sections });
} catch {
// The route lets this reach the global error envelope; a tool has to
// answer, and a missing sidebar reads to a model like any other
// unavailability.
return errorResult('Help contents unavailable.');
}
}
@Tool({
name: 'get_help_page',
description:
'Read one page of the bundled TREK user manual as markdown, addressed by a slug from list_help_topics. Prefer it over answering from memory whenever the user asks how a TREK feature behaves: the pages ship with the running version, so they describe this instance rather than some other release. Long pages arrive in chunks, so when the result says truncated, call again with next_offset.',
inputSchema: {
slug: z.string().min(1).max(120).describe('Page slug as reported by list_help_topics, e.g. "Quick-Start"'),
offset: z
.number()
.int()
.min(0)
.optional()
.describe('Character offset to resume from after a truncated result. Defaults to 0, the start of the page.'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
})
async getHelpPage({ slug, offset }: { slug: string; offset?: number }, _ctx: McpContext) {
const from = offset ?? 0;
let page: WikiPage;
try {
page = await getWikiPage(slug);
} catch (err) {
// The route collapses both into "Help page unavailable" with a 404/502
// split in the status. A model cannot read the status, and only one of
// the two is worth retrying with a different slug, so say which it is.
if (err instanceof WikiNotFound)
return errorResult(`No help page named "${slug}". Call list_help_topics for the slugs this instance ships.`);
return errorResult('Help page unavailable.');
}
const full = page.markdown;
if (from > 0 && from >= full.length)
return errorResult(`Offset ${from} is past the end of "${page.slug}", which is ${full.length} characters long.`);
const markdown = full.slice(from, from + HELP_PAGE_CHARS);
const truncated = from + markdown.length < full.length;
return ok({
slug: page.slug,
title: page.title,
markdown,
truncated,
next_offset: truncated ? from + markdown.length : null,
});
}
}
+7 -1
View File
@@ -1,8 +1,14 @@
import { Module } from '@nestjs/common';
import { HelpController } from './help.controller';
import { HelpMcp } from './help.mcp';
/** /api/help — the bundled `wiki/` directory, read via ./wiki. */
/**
* /api/help serves the bundled `wiki/` directory, read via ./wiki. HelpMcp puts the
* same index and page reads on the MCP surface; it injects nothing, so the
* module stays a leaf.
*/
@Module({
controllers: [HelpController],
providers: [HelpMcp],
})
export class HelpModule {}
@@ -0,0 +1,411 @@
/**
* Unit tests for the MCP help and addons tools:
* list_help_topics, get_help_page, list_addons.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
// The wiki reader is stubbed at the module boundary, the way the weather tools
// stub weather.impl: help.mcp.ts calls these functions directly, and the reader
// has its own tests in tests/unit/nest/wiki.test.ts. Stubbing keeps these cases
// off the 94 real pages in wiki/ and reaches the failure branches, which a
// directory on disk cannot produce on demand.
const { wiki } = vi.hoisted(() => {
class WikiNotFound extends Error {
status = 404;
}
return {
wiki: {
WikiNotFound,
isLocalWiki: vi.fn(() => true),
getWikiIndex: vi.fn(),
getWikiPage: vi.fn(),
getWikiAsset: vi.fn(),
},
};
});
vi.mock('../../../src/nest/help/wiki', () => wiki);
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { ADDON_IDS } from '../../../src/addons';
const SECTIONS = [
{ title: 'Getting Started', pages: [{ title: 'Quick Start', slug: 'Quick-Start' }] },
{ title: 'Planning', pages: [{ title: 'Currencies', slug: 'Currencies' }] },
];
// 40004 characters: four over the 40000-character cap, so the second chunk is
// short enough to assert on in full.
const LONG_MARKDOWN = 'a'.repeat(40_000) + 'TAIL';
const PAGES: Record<string, { slug: string; title: string; markdown: string }> = {
'Quick-Start': {
slug: 'Quick-Start',
title: 'Quick Start',
markdown: '# Quick Start\n\nCreate a trip, then add days to it.',
},
'Plugin-Development': {
slug: 'Plugin-Development',
title: 'Plugin Development',
markdown: LONG_MARKDOWN,
},
};
interface HelpIndexPayload {
sections: { title: string; pages: { title: string; slug: string }[] }[];
}
interface HelpPagePayload {
slug: string;
title: string;
markdown: string;
truncated: boolean;
next_offset: number | null;
}
interface AddonEntry {
id: string;
name: string;
type: string;
enabled: boolean;
}
interface AddonsPayload {
addons: AddonEntry[];
collabFeatures: { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean };
bagTracking: boolean;
}
/** First text block of a tool result, for asserting on refusal wording. */
function toolText(result: unknown): string {
const { content } = result as { content: { type: string; text?: string }[] };
return content.find((c) => c.type === 'text')?.text ?? '';
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
wiki.getWikiIndex.mockReset();
wiki.getWikiPage.mockReset();
wiki.getWikiIndex.mockResolvedValue({ sections: SECTIONS });
wiki.getWikiPage.mockImplementation(async (slug: string) => {
const page = PAGES[slug];
if (!page) throw new wiki.WikiNotFound(slug);
return page;
});
// resetTestDb leaves the addons table alone, so a case that flips a toggle
// would otherwise leak into the next one.
for (const id of [ADDON_IDS.BUDGET, ADDON_IDS.PACKING, ADDON_IDS.COLLAB]) {
setAddonEnabled(testDb, id, true);
}
});
afterAll(() => {
testDb.close();
});
async function withHarness(
userId: number,
fn: (h: McpHarness) => Promise<void>,
scopes?: string[] | null,
) {
const h = await createMcpHarness({ userId, withResources: false, scopes: scopes ?? null });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_help_topics
// ---------------------------------------------------------------------------
describe('Tool: list_help_topics', () => {
it('returns the sections the wiki reader parsed out of the sidebar', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_help_topics', arguments: {} });
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as HelpIndexPayload;
expect(data.sections).toEqual(SECTIONS);
expect(wiki.getWikiIndex).toHaveBeenCalledTimes(1);
});
});
it('answers with a refusal instead of throwing when the reader fails', async () => {
wiki.getWikiIndex.mockRejectedValue(new Error('ENOENT'));
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_help_topics', arguments: {} });
expect(result.isError).toBe(true);
expect(toolText(result)).toBe('Help contents unavailable.');
});
});
it('stays registered for a token holding an unrelated scope', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const names = (await h.client.listTools()).tools.map((t) => t.name);
expect(names).toContain('list_help_topics');
expect(names).toContain('get_help_page');
const result = await h.client.callTool({ name: 'list_help_topics', arguments: {} });
expect(result.isError).toBeFalsy();
}, ['weather:read']);
});
});
// ---------------------------------------------------------------------------
// get_help_page
// ---------------------------------------------------------------------------
describe('Tool: get_help_page', () => {
it('returns a short page whole, with nothing left to fetch', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_help_page',
arguments: { slug: 'Quick-Start' },
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as HelpPagePayload;
expect(data).toEqual({
slug: 'Quick-Start',
title: 'Quick Start',
markdown: PAGES['Quick-Start'].markdown,
truncated: false,
next_offset: null,
});
expect(wiki.getWikiPage).toHaveBeenCalledWith('Quick-Start');
});
});
it('refuses a slug this instance does not ship, and says where to look', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_help_page',
arguments: { slug: 'No-Such-Page' },
});
expect(result.isError).toBe(true);
expect(toolText(result)).toContain('No help page named "No-Such-Page"');
expect(toolText(result)).toContain('list_help_topics');
});
});
it('separates an unavailable reader from a missing page', async () => {
wiki.getWikiPage.mockRejectedValue(new Error('socket hang up'));
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_help_page',
arguments: { slug: 'Quick-Start' },
});
expect(result.isError).toBe(true);
expect(toolText(result)).toBe('Help page unavailable.');
});
});
it('caps a long page and hands back the offset to resume from', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_help_page',
arguments: { slug: 'Plugin-Development' },
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as HelpPagePayload;
expect(data.markdown).toHaveLength(40_000);
expect(data.markdown).toBe(LONG_MARKDOWN.slice(0, 40_000));
expect(data.truncated).toBe(true);
expect(data.next_offset).toBe(40_000);
});
});
it('resumes from the offset the previous chunk reported', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_help_page',
arguments: { slug: 'Plugin-Development', offset: 40_000 },
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as HelpPagePayload;
expect(data.markdown).toBe('TAIL');
expect(data.truncated).toBe(false);
expect(data.next_offset).toBeNull();
});
});
it('refuses an offset past the end of the page', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_help_page',
arguments: { slug: 'Quick-Start', offset: 5000 },
});
expect(result.isError).toBe(true);
expect(toolText(result)).toContain('past the end of "Quick-Start"');
});
});
it('serves a demo account, the way the public REST route does', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@trek.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_help_page',
arguments: { slug: 'Quick-Start' },
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as HelpPagePayload;
expect(data.title).toBe('Quick Start');
});
});
});
// ---------------------------------------------------------------------------
// list_addons
// ---------------------------------------------------------------------------
describe('Tool: list_addons', () => {
it('lists the enabled addons with the collab sub-features and bag tracking', async () => {
const { user } = createUser(testDb);
const row = testDb.prepare('SELECT name, type FROM addons WHERE id = ?').get('budget') as {
name: string;
type: string;
};
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as AddonsPayload;
// The whole entry, so the admin-panel chrome (icon, plus config and fields
// on a photo provider) has to be absent rather than merely unasserted.
expect(data.addons).toContainEqual({
id: 'budget',
name: row.name,
type: row.type,
enabled: true,
});
expect(data.collabFeatures).toEqual({ chat: true, notes: true, polls: true, whatsnext: true });
expect(data.bagTracking).toBe(false);
});
});
it('lists the enabled photo providers alongside the addons', async () => {
const { user } = createUser(testDb);
const row = testDb.prepare('SELECT name FROM photo_providers WHERE id = ?').get('immich') as {
name: string;
};
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
const data = parseToolResult(result) as AddonsPayload;
expect(data.addons).toContainEqual({
id: 'immich',
name: row.name,
type: 'photo_provider',
enabled: true,
});
});
});
it('drops an addon the admin switched off, so absence means disabled', async () => {
setAddonEnabled(testDb, ADDON_IDS.BUDGET, false);
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
const data = parseToolResult(result) as AddonsPayload;
const ids = data.addons.map((a) => a.id);
expect(ids).not.toContain(ADDON_IDS.BUDGET);
expect(ids).toContain(ADDON_IDS.PACKING);
});
});
it('follows a collab sub-feature the admin toggles, in both directions', async () => {
// Written through the same service the admin panel writes through rather
// than through a hand-rolled app_settings row, so the tool is checked
// against the real writer and not against a restatement of it.
const addonsService = new AddonsService(new DatabaseService(testDb));
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
addonsService.updateCollabFeatures({ polls: false });
const off = parseToolResult(
await h.client.callTool({ name: 'list_addons', arguments: {} }),
) as AddonsPayload;
expect(off.collabFeatures.polls).toBe(false);
expect(off.collabFeatures.chat).toBe(true);
addonsService.updateCollabFeatures({ polls: true });
const on = parseToolResult(
await h.client.callTool({ name: 'list_addons', arguments: {} }),
) as AddonsPayload;
expect(on.collabFeatures.polls).toBe(true);
});
});
it('reports bag tracking once it is switched on', async () => {
testDb
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')")
.run();
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
const data = parseToolResult(result) as AddonsPayload;
expect(data.bagTracking).toBe(true);
});
});
it('stays registered for a token holding an unrelated scope', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const names = (await h.client.listTools()).tools.map((t) => t.name);
expect(names).toContain('list_addons');
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
expect(result.isError).toBeFalsy();
}, ['weather:read']);
});
it('serves a demo account, the way the authenticated REST route does', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@trek.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as AddonsPayload;
expect(data.addons.map((a) => a.id)).toContain(ADDON_IDS.PACKING);
});
});
});