diff --git a/client/src/api/oauthScopes.test.ts b/client/src/api/oauthScopes.test.ts index 443c30b64e..b80a2d2357 100644 --- a/client/src/api/oauthScopes.test.ts +++ b/client/src/api/oauthScopes.test.ts @@ -34,8 +34,8 @@ describe('SCOPE_GROUPS', () => { }) describe('ALL_SCOPES', () => { - it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => { - expect(ALL_SCOPES).toHaveLength(29) + it('FE-OAUTH-SCOPES-003: contains exactly 30 scopes', () => { + expect(ALL_SCOPES).toHaveLength(30) }) it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => { diff --git a/client/src/api/oauthScopes.ts b/client/src/api/oauthScopes.ts index d11bf853e5..cdd32008e5 100644 --- a/client/src/api/oauthScopes.ts +++ b/client/src/api/oauthScopes.ts @@ -43,6 +43,7 @@ export const SCOPE_GROUPS: Record = { 'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' }, 'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' }, 'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' }, + 'plugins:use': { labelKey: 'oauth.scope.plugins:use.label', descriptionKey: 'oauth.scope.plugins:use.description', groupKey: 'oauth.scope.group.plugins' }, } export const ALL_SCOPES = Object.keys(SCOPE_GROUPS) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index de5d42c54f..4174171749 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -4,7 +4,7 @@ import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, - Radio, Luggage, Globe, Image, CalendarDays, Bell, + Radio, Luggage, Globe, Image, CalendarDays, Bell, Bot, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' @@ -189,7 +189,7 @@ const PERM_KEYS = [ 'db:create:trips', 'db:meta', 'notify:send', 'ai:invoke', 'oauth:client', - 'events:subscribe', 'jobs:run', + 'events:subscribe', 'jobs:run', 'mcp:tools', 'ws:broadcast:trip', 'ws:broadcast:user', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'geolocation:read', @@ -250,6 +250,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') }) if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) + if (perms.includes('mcp:tools')) out.push({ icon: Bot, label: t('admin.plugins.cap.mcpTools') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { out.push({ icon: ArrowRight, label: h, net: true }) } diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index bee362cb0f..bbc91f8781 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -3,7 +3,7 @@ import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, - Radio, Luggage, Globe, Image, CalendarDays, Bell, + Radio, Luggage, Globe, Image, CalendarDays, Bell, Bot, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, } from 'lucide-react' import PluginIcon from '../../../components/shared/PluginIcon' @@ -189,7 +189,7 @@ const PERM_KEYS = [ 'db:create:trips', 'db:meta', 'notify:send', 'ai:invoke', 'oauth:client', - 'events:subscribe', 'jobs:run', + 'events:subscribe', 'jobs:run', 'mcp:tools', 'ws:broadcast:trip', 'ws:broadcast:user', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'geolocation:read', @@ -250,6 +250,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') }) if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) + if (perms.includes('mcp:tools')) out.push({ icon: Bot, label: t('admin.plugins.cap.mcpTools') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { out.push({ icon: ArrowRight, label: h, net: true }) } diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts index 2f3d1e21bf..4470fcdfb8 100644 --- a/plugin-sdk/src/cli/ui.ts +++ b/plugin-sdk/src/cli/ui.ts @@ -233,6 +233,7 @@ export const PERMISSION_FAMILIES: PermissionFamily[] = [ permissions: [ { value: 'jobs:run', hint: 'Run cron jobs and ctx.scheduler timers (no acting user)' }, { value: 'events:subscribe', hint: 'Receive TREK events (place:created, trip:updated…) as they happen' }, + { value: 'mcp:tools', hint: 'Offer your own tools to an AI assistant connected to TREK over MCP' }, ], }, { diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 635f8e4c1a..51ac09b4d6 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -350,6 +350,66 @@ export interface PluginJob { handler(ctx: PluginContext): Promise; } +// ── MCP tools ──────────────────────────────────────────────────────────────── + +/** Behaviour hints an MCP client may show the model when it weighs calling a tool. + * Purely advisory — TREK enforces none of them; your handler is still responsible + * for refusing what it should not do. */ +export interface McpToolAnnotations { + /** The tool only reads; it changes nothing. */ + readOnlyHint?: boolean; + /** The tool may delete or overwrite data. */ + destructiveHint?: boolean; + /** Calling it twice with the same input is the same as calling it once. */ + idempotentHint?: boolean; + /** The tool reaches outside TREK (one of your declared egress hosts). */ + openWorldHint?: boolean; +} + +/** + * A tool this plugin adds to TREK's MCP server, next to the built-in ones — so an + * assistant connected over MCP can call into your plugin. + * + * Declarative + invocable, like a route: the name, description and schema are + * reported to the host once at load and served from memory, while the handler runs + * per call. Clients see the tool namespaced as `plugin__`, and only + * over a session whose token carries the `plugins:use` OAuth scope (a full-access + * token qualifies). Needs `mcp:tools` — without that grant the tool is never + * advertised and never callable. + * + * The host caps what it will advertise: 16 tools per plugin, a 64-character public + * name, an 80-character title, a 4096-character description and a 16 KB schema. + * Anything over a cap is dropped rather than truncated into something misleading. + */ +export interface PluginMcpTool { + /** snake_case, unique within your plugin: `/^[a-z][a-z0-9_]{0,47}$/`. */ + name: string; + /** Human-readable display name. Falls back to `name`. */ + title?: string; + /** What the tool does and when to call it. This and the `description` on each + * schema property are ALL the model reads before choosing — write both. */ + description: string; + /** JSON Schema for the arguments — a plain `{ type: 'object', properties, required }` + * object. TREK understands objects, strings, numbers, integers, booleans, arrays, + * enums and nesting; anything more exotic is advertised as unconstrained rather + * than rejected. Omit it for a tool that takes no arguments. */ + inputSchema?: Record; + annotations?: McpToolAnnotations; + /** + * Run one call. `input` is NOT GUARANTEED to be validated: TREK checks arguments + * against the parts of your schema it understands, but anything it converted to + * "unconstrained" passes through as-is (and the mock host validates nothing) — so + * check it yourself. + * + * Runs with the calling user bound, exactly like a route: trip reads are + * membership-checked against them. The call is given 30 s. Return any + * JSON-serialisable value (a string is passed through verbatim, anything else is + * JSON-rendered for the model); throw to fail the call — your message goes back to + * the model that called you, never to another user. Needs `mcp:tools`. + */ + handler(input: unknown, ctx: PluginContext): Promise | unknown; +} + // ── integration hook interfaces ────────────────────────────────────────────── export interface Photo { id: string; @@ -672,6 +732,9 @@ export interface PluginDefinition { onUnload?(ctx: PluginContext): Promise | void; routes?: PluginRoute[]; jobs?: PluginJob[]; + /** Tools this plugin adds to TREK's MCP server, so a connected assistant can call + * into it. Advertised as `plugin__`. Needs `mcp:tools`. */ + mcpTools?: PluginMcpTool[]; /** Handles a callback registered via ctx.scheduler (userless, like a job). The * `name` identifies which scheduled task fired; `payload` is what you passed. */ scheduled?(input: { name: string; payload: unknown }, ctx: PluginContext): Promise | void; @@ -727,7 +790,7 @@ export { createMockHost, type MockHostOptions } from './mock-host.js'; // are what `dev` and the mock driver use to make that loud. export { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, - grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints, + MCP_TOOLS_PERMISSION, grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints, } from './permissions.js'; /** Scope for host-managed, per-user session state in a sandboxed plugin UI. */ diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 48aa057877..8d38139cff 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -91,7 +91,7 @@ export const KNOWN_PERMISSIONS = [ 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', - 'events:subscribe', 'jobs:run', 'http:outbound', + 'events:subscribe', 'jobs:run', 'mcp:tools', 'http:outbound', 'weather:read', 'rates:read', 'notify:send', 'ai:invoke', 'oauth:client', 'geolocation:read', ]; diff --git a/plugin-sdk/src/mock-host.ts b/plugin-sdk/src/mock-host.ts index 5d20b0ab9b..058ecd16d6 100644 --- a/plugin-sdk/src/mock-host.ts +++ b/plugin-sdk/src/mock-host.ts @@ -1,7 +1,7 @@ import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES } from './index.js'; import type { PluginContext, PluginDefinition, PluginRequest, PluginResponse, Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, User, NotificationMessage, PluginActionResult, PluginSessionStorage } from './index.js'; import { CHANNEL_EVENTS } from './manifest.js'; -import { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION } from './permissions.js'; +import { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, MCP_TOOLS_PERMISSION } from './permissions.js'; /** * A mock PluginContext for unit-testing a plugin without a running TREK @@ -133,6 +133,15 @@ export interface PluginDriver { exportUserData(userId: number): Promise; /** Invoke a provider hook, e.g. hook('tripCardProvider', 'getCards', [1, 2]). */ hook(name: string, fn: string, ...args: unknown[]): Promise; + /** Call one of the plugin's `mcpTools` by its own (un-namespaced) name, the way an + * assistant connected over MCP would. USER-INITIATED like a route, so the handler + * gets the acting-user ctx. `input` is passed through untouched — MORE permissive + * than the real host, which checks arguments against the parts of the declared + * schema it understands (unrecognized constructs pass anything). Schema-shaped + * input behaves identically in both; input the mock lets through here may be + * rejected before the handler in production, never the reverse — so a handler that + * survives the mock's raw input is the one worth having. Needs `mcp:tools`. */ + mcpTool(name: string, input?: unknown): Promise; /** Click one of the plugin's settings-page buttons ("Test connection"). USER-INITIATED, * so the handler gets the acting-user ctx — ctx.settings.get() returns the host's * `userSettings`. Returns the normalized result the user would actually see: a handler @@ -1507,6 +1516,14 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { // that reads ctx.settings.get() and then deliver to nobody in production. return impl[fn](...args, name === 'notificationChannel' ? userlessCtx : ctx) as never; }, + mcpTool: async (name, input) => { + needEntry(MCP_TOOLS_PERMISSION, `mcpTool "${name}"`); + const tool = (def.mcpTools ?? []).find((t) => t.name === name); + if (!tool) throw new Error(`no mcpTool "${name}"`); + // The acting-user ctx: an MCP call is made on behalf of the token's user, so the + // handler's trip reads are membership-checked exactly like a route's. + return (await tool.handler(input, ctx)) as never; + }, action: async (key) => { if (opts.declaredActions && !opts.declaredActions.includes(key)) { throw new Error(`RESOURCE_FORBIDDEN: plugin did not declare action "${key}"`); @@ -1550,4 +1567,4 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { // `trek-plugin-sdk/testing` resolves to this module, so re-export what a test needs to // assert on a denial: `await expect(h.run(def).job('x')).rejects.toThrow(PermissionDenied)`. -export { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION } from './permissions.js'; +export { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, MCP_TOOLS_PERMISSION } from './permissions.js'; diff --git a/plugin-sdk/src/permissions.ts b/plugin-sdk/src/permissions.ts index 2a282a389a..90004e181d 100644 --- a/plugin-sdk/src/permissions.ts +++ b/plugin-sdk/src/permissions.ts @@ -41,6 +41,8 @@ export const USER_DATA_PERMISSION = 'hook:user-data'; export const EVENTS_PERMISSION = 'events:subscribe'; /** Gates jobs, and the ctx.scheduler timers that fire `scheduled`. */ export const JOBS_PERMISSION = 'jobs:run'; +/** Gates `mcpTools` — without it the host never advertises them to an MCP client. */ +export const MCP_TOOLS_PERMISSION = 'mcp:tools'; const HTTP_OUTBOUND = 'http:outbound:'; @@ -52,6 +54,7 @@ export interface PluginEntryPoints { deleteUserData?: unknown; exportUserData?: unknown; hooks?: Record; + mcpTools?: unknown[]; } /** An entry point the plugin implements but has no permission to actually run. */ @@ -86,6 +89,7 @@ export function grantGaps(plugin: PluginEntryPoints, grants: ReadonlySet if (plugin.jobs?.length) gap('jobs', JOBS_PERMISSION, 'schedule your jobs'); if (typeof plugin.scheduled === 'function') gap('scheduled', JOBS_PERMISSION, 'let you arm a timer (ctx.scheduler is denied)'); if (plugin.events?.length) gap('events', EVENTS_PERMISSION, 'deliver you any event'); + if (plugin.mcpTools?.length) gap('mcpTools', MCP_TOOLS_PERMISSION, 'advertise your tools to a connected assistant'); if (typeof plugin.deleteUserData === 'function') gap('deleteUserData', USER_DATA_PERMISSION, 'call your GDPR erasure handler'); if (typeof plugin.exportUserData === 'function') gap('exportUserData', USER_DATA_PERMISSION, 'call your GDPR export handler'); return gaps; diff --git a/plugin-sdk/test/permissions.test.ts b/plugin-sdk/test/permissions.test.ts index 06fd605aa8..54fc96f08d 100644 --- a/plugin-sdk/test/permissions.test.ts +++ b/plugin-sdk/test/permissions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - grantGaps, grantedHosts, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, + grantGaps, grantedHosts, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, MCP_TOOLS_PERMISSION, } from '../src/permissions.js'; const noop = () => {}; @@ -31,6 +31,7 @@ describe('grantGaps — entry points TREK would never run', () => { ['events', { events: [{ on: 'place:created', handler: noop }] }, EVENTS_PERMISSION], ['deleteUserData', { deleteUserData: noop }, USER_DATA_PERMISSION], ['exportUserData', { exportUserData: noop }, USER_DATA_PERMISSION], + ['mcpTools', { mcpTools: [{ name: 'lookup', description: 'd', handler: noop }] }, MCP_TOOLS_PERMISSION], ])('flags %s without its grant, and clears once granted', (entryPoint, plugin, permission) => { const gaps = grantGaps(plugin, new Set()); expect(gaps).toHaveLength(1); @@ -39,7 +40,7 @@ describe('grantGaps — entry points TREK would never run', () => { }); it('does not flag an EMPTY jobs/events array — nothing is implemented', () => { - expect(grantGaps({ jobs: [], events: [] }, new Set())).toEqual([]); + expect(grantGaps({ jobs: [], events: [], mcpTools: [] }, new Set())).toEqual([]); }); it('ignores an unknown hooks.* key (the host ignores it too, so it is not a gap)', () => { diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 9ceecdc292..be3d6d0051 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -92,6 +92,12 @@ describe('validateManifest', () => { expect(r.ok).toBe(true); }); + it('accepts mcp:tools', () => { + const r = validateManifest({ ...base, permissions: ['mcp:tools'] }); + expect(r.errors).toEqual([]); + expect(r.ok).toBe(true); + }); + it('validates capabilities.routeProfiles and ties it to hook:route-provider', () => { const profiles = [{ id: 'ev', label: 'EV' }]; const ok = validateManifest({ ...base, permissions: ['hook:route-provider'], capabilities: { routeProfiles: profiles } }); @@ -373,6 +379,7 @@ describe('createMockHost', () => { events: [{ on: 'place:created', handler() { ran.push('event'); } }], async deleteUserData() { ran.push('delete'); }, async exportUserData() { ran.push('export'); return {}; }, + mcpTools: [{ name: 'lookup', description: 'Look something up', async handler() { ran.push('mcpTool'); } }], hooks: { warningProvider: { async getWarnings() { ran.push('warn'); return []; } }, notificationChannel: { async send() { ran.push('send'); }, async test() { ran.push('test'); } }, @@ -389,6 +396,7 @@ describe('createMockHost', () => { await expect(d.hook('warningProvider', 'getWarnings', 1)).rejects.toThrow(/requires hook:trip-warning-provider/); await expect(d.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).rejects.toThrow(/requires hook:notification-channel/); await expect(d.channel.test()).rejects.toThrow(/requires hook:notification-channel/); + await expect(d.mcpTool('lookup', {})).rejects.toThrow(/requires mcp:tools/); // Not one handler ran. That is the production behaviour this mirrors. expect(ran).toEqual([]); @@ -408,6 +416,42 @@ describe('createMockHost', () => { expect(ran).toEqual(['job', 'event', 'warn']); }); + // An MCP tool is called on behalf of the token's user, so it gets the acting-user ctx + // (like a route, unlike a job) — and the mock passes input through raw. That is MORE + // permissive than production (which checks arguments against the parts of the schema + // it understands), so a handler that survives this sees no surprises there. + it('runs an mcpTool with the acting user bound and the input untouched', async () => { + let seen: unknown; + const def = definePlugin({ + mcpTools: [{ + name: 'lookup', + description: 'Look something up', + inputSchema: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] }, + async handler(input, ctx) { + seen = input; + return { trip: await ctx.trips.getById(1) }; + }, + }], + }); + const d = createMockHost({ + grants: ['mcp:tools', 'db:read:trips'], + actingUserId: 7, + trips: { 1: { members: [7], data: { id: 1, title: 'Rome' } } }, + }).run(def); + + // Deliberately not schema-shaped: the mock validates nothing, so the handler's own + // input checking is what gets exercised. + const out = await d.mcpTool('lookup', { q: 42, extra: true }); + expect(seen).toEqual({ q: 42, extra: true }); + expect(out).toMatchObject({ trip: { id: 1, title: 'Rome' } }); + }); + + it('rejects an mcpTool name the plugin never declared', async () => { + const def = definePlugin({ mcpTools: [{ name: 'lookup', description: 'd', async handler() { return 1; } }] }); + const d = createMockHost({ grants: ['mcp:tools'] }).run(def); + await expect(d.mcpTool('nope')).rejects.toThrow(/no mcpTool "nope"/); + }); + it('exposes the scheduler timers a plugin armed via ctx.scheduler', async () => { const def = definePlugin({ async onLoad(ctx) { await ctx.scheduler.every(3_600_000, 'sync', { n: 1 }); } }); const host = createMockHost({ grants: ['jobs:run'] }); diff --git a/server/src/mcp/json-schema-to-zod.ts b/server/src/mcp/json-schema-to-zod.ts new file mode 100644 index 0000000000..bb20192ad7 --- /dev/null +++ b/server/src/mcp/json-schema-to-zod.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat'; + +/** + * Converts a plugin's declared JSON Schema into the Zod shape the MCP SDK needs. + * + * A plugin lives in another process, so it can only ship its tool schema as plain + * JSON — but `McpServer.registerTool` takes Zod (it owns the JSON-Schema rendering in + * tools/list, and the low-level alternative would mean re-implementing the SDK's own + * tools/list + tools/call handlers). This bridges the two. + * + * PERMISSIVE BY DESIGN. The output is what the SDK validates incoming arguments + * against before the plugin sees them, so a strict reading of a schema this converter + * only half-understands would reject calls the plugin would have handled fine. + * Anything unrecognized therefore becomes `z.unknown()` — advertised to the model as + * an unconstrained value rather than dropped or guessed at. The plugin is documented + * to validate its own input regardless; this is about advertising a useful schema, + * not about enforcing one. + * + * The input is untrusted (a plugin can report any JSON at all), hence the depth and + * property ceilings — a deeply nested or enormous schema is bounded, not trusted. + */ + +const MAX_DEPTH = 8; +const MAX_PROPERTIES = 64; +const MAX_ENUM_VALUES = 64; + +type JsonObject = Record; + +function isPlainObject(v: unknown): v is JsonObject { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function described(schema: z.ZodTypeAny, node: JsonObject): z.ZodTypeAny { + // Property descriptions are half of what a model reads before it fills a tool call + // in, so they have to survive the round-trip back out through tools/list. + return typeof node.description === 'string' && node.description ? schema.describe(node.description) : schema; +} + +function convertNode(node: unknown, depth: number): z.ZodTypeAny { + if (depth > MAX_DEPTH || !isPlainObject(node)) return z.unknown(); + + // An enum pins the value far more usefully than its type does, so it wins. + if (Array.isArray(node.enum)) { + const values = node.enum.slice(0, MAX_ENUM_VALUES); + if (values.length && values.every((v): v is string => typeof v === 'string')) { + return described(z.enum(values as [string, ...string[]]), node); + } + return described(z.unknown(), node); + } + + switch (node.type) { + case 'string': + return described(z.string(), node); + case 'number': + return described(z.number(), node); + case 'integer': + return described(z.number().int(), node); + case 'boolean': + return described(z.boolean(), node); + case 'array': + return described(z.array(convertNode(node.items, depth + 1)), node); + case 'object': { + const shape = shapeOf(node, depth + 1); + // An object with no usable `properties` is a free-form bag. z.object({}) would + // STRIP every key on the way to the handler, so keep it open instead. + return described(shape ? z.object(shape) : z.record(z.string(), z.unknown()), node); + } + default: + // No type, a union of types, $ref, oneOf/anyOf, … — unconstrained. + return described(z.unknown(), node); + } +} + +/** The property map of an object node, or undefined when it declares none. */ +function shapeOf(node: JsonObject, depth: number): Record | undefined { + if (!isPlainObject(node.properties)) return undefined; + const required = new Set( + Array.isArray(node.required) ? node.required.filter((r): r is string => typeof r === 'string') : [], + ); + const shape: Record = {}; + for (const [key, value] of Object.entries(node.properties).slice(0, MAX_PROPERTIES)) { + const converted = convertNode(value, depth); + shape[key] = required.has(key) ? converted : converted.optional(); + } + return Object.keys(shape).length ? shape : undefined; +} + +/** + * The raw shape for a tool's arguments, or undefined for a tool that takes none. + * + * Undefined is also the answer for a schema that isn't a usable object schema at all + * (MCP requires the top level to be an object): a zero-argument tool is the honest, + * safe reading of "the plugin declared something we cannot interpret". + */ +export function jsonSchemaToZodShape(schema: unknown): ZodRawShapeCompat | undefined { + if (!isPlainObject(schema)) return undefined; + return shapeOf(schema, 1); +} diff --git a/server/src/mcp/plugin-tools-handoff.ts b/server/src/mcp/plugin-tools-handoff.ts new file mode 100644 index 0000000000..8c818b41e3 --- /dev/null +++ b/server/src/mcp/plugin-tools-handoff.ts @@ -0,0 +1,34 @@ +import type { PluginMcpToolReport } from '../nest/plugins/mcp-tool-report'; + +/** + * Hands the plugin runtime to the non-Nest MCP handler, so a session can advertise + * the tools active plugins expose. + * + * Same seam as registry-handoff.ts, for the same reason: mcpHandler is mounted on the + * raw Express instance BEFORE app.init() and cannot reach the DI container. The + * difference is who pushes — PluginRuntimeService wires itself here in onModuleInit + * (like setPluginEventSink / setPluginChannelSource), so bootstrap.ts stays out of it + * and a context WITHOUT the plugin module (a slim test app, or an instance with the + * plugins kill switch off) simply leaves this unset. + * + * Deliberately narrow: the MCP layer gets exactly the three calls it needs, not the + * whole service, so this stays a one-way dependency on plugin internals. + */ +export interface PluginToolsRuntime { + /** Ids of active plugins advertising at least one tool (grant-checked). */ + mcpToolProviders(): string[]; + /** One plugin's advertised tools — already normalized and capped. */ + mcpToolsOf(id: string): PluginMcpToolReport[]; + /** Run a tool as `actingUserId`. Rejects if the plugin is no longer a granted provider. */ + invokeMcpTool(id: string, tool: string, input: unknown, actingUserId: number): Promise; +} + +let runtime: PluginToolsRuntime | null = null; + +export function setPluginToolsRuntime(value: PluginToolsRuntime | null): void { + runtime = value; +} + +export function getPluginToolsRuntime(): PluginToolsRuntime | null { + return runtime; +} diff --git a/server/src/mcp/plugin-tools.ts b/server/src/mcp/plugin-tools.ts new file mode 100644 index 0000000000..da42228933 --- /dev/null +++ b/server/src/mcp/plugin-tools.ts @@ -0,0 +1,97 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { errorResult } from '@trek/nest-mcp'; +import { pluginsEnabled } from '../nest/plugins/kill-switch'; +import { publicMcpToolName } from '../nest/plugins/mcp-tool-report'; +import { canUsePluginTools } from './scopes'; +import { jsonSchemaToZodShape } from './json-schema-to-zod'; +import { getPluginToolsRuntime, type PluginToolsRuntime } from './plugin-tools-handoff'; + +/** + * Advertises the tools installed plugins expose, alongside TREK's built-in ones. + * + * Three gates have to be open for a tool to appear here: the plugin system is on, the + * plugin holds `mcp:tools` and is active (the supervisor enforces that — see + * mcpToolsOf), and the session's token carries `plugins:use`. The scope is deliberately + * one gate for all plugin tools: what any individual tool can actually reach is decided + * by that plugin's own granted permissions, acting as the user behind the token, so + * per-plugin OAuth scopes would imply a second, finer-grained consent model that does + * not exist. + * + * Everything here is fail-open on the session and fail-closed on the tool: a plugin + * that cannot be registered is skipped, never allowed to break session creation for + * the ~200 built-in tools. + */ + +/** A tool result is read into a model's context — bound it. */ +const MAX_RESULT_CHARS = 100_000; + +export function registerPluginTools(server: McpServer, userId: number, scopes: string[] | null): void { + if (!pluginsEnabled()) return; + if (!canUsePluginTools(scopes)) return; + // Unset in any context without the plugin module (unit tests, a slim app) — no tools. + const runtime = getPluginToolsRuntime(); + if (!runtime) return; + + for (const pluginId of runtime.mcpToolProviders()) { + for (const tool of runtime.mcpToolsOf(pluginId)) { + const publicName = publicMcpToolName(pluginId, tool.name); + const config = { title: tool.title, description: tool.description, annotations: tool.annotations }; + const inputSchema = jsonSchemaToZodShape(tool.inputSchema); + try { + // Split on the schema so the SDK's callback type resolves: with a shape the + // handler is (args, extra), without one it is (extra) alone. + if (inputSchema) { + server.registerTool(publicName, { ...config, inputSchema }, (args) => + callPluginTool(runtime, pluginId, tool.name, publicName, args, userId)); + } else { + server.registerTool(publicName, config, () => + callPluginTool(runtime, pluginId, tool.name, publicName, undefined, userId)); + } + } catch (err) { + // Almost always a name already taken by a built-in tool. Skipping keeps the + // built-in authoritative and leaves every other tool in this session intact. + console.warn(`[MCP] skipped plugin tool ${publicName}:`, (err as Error)?.message ?? err); + } + } + } +} + +/** Run one tool in its plugin's child process and shape the reply for the model. */ +async function callPluginTool( + runtime: PluginToolsRuntime, + pluginId: string, + toolName: string, + publicName: string, + input: unknown, + userId: number, +) { + try { + return renderResult(await runtime.invokeMcpTool(pluginId, toolName, input, userId)); + } catch (err) { + // Everything lands here: a throw from the plugin, the 30 s timeout, a crashed + // child, or the plugin having been deactivated since this session listed its + // tools. The message is the plugin's own words to its caller — safe to surface, + // and the only way the model learns to try something else. + const message = (err as Error)?.message ?? String(err); + return errorResult(`Plugin tool "${publicName}" failed: ${message}`); + } +} + +function renderResult(value: unknown) { + let text: string; + if (typeof value === 'string') { + text = value; // a string is the plugin's finished answer — don't re-quote it + } else if (value === undefined || value === null) { + text = ''; + } else { + try { + text = JSON.stringify(value, null, 2) ?? ''; + } catch { + return errorResult('The plugin returned a value that could not be serialized.'); + } + } + if (text.length > MAX_RESULT_CHARS) { + text = `${text.slice(0, MAX_RESULT_CHARS)}\n… [truncated by TREK at ${MAX_RESULT_CHARS} characters]`; + } + return { content: [{ type: 'text' as const, text }] }; +} diff --git a/server/src/mcp/scopes.ts b/server/src/mcp/scopes.ts index 2ef5062a79..0df0409a82 100644 --- a/server/src/mcp/scopes.ts +++ b/server/src/mcp/scopes.ts @@ -32,6 +32,7 @@ export const SCOPES = { JOURNEY_READ: 'journey:read', JOURNEY_WRITE: 'journey:write', JOURNEY_SHARE: 'journey:share', + PLUGINS_USE: 'plugins:use', } as const; export type Scope = typeof SCOPES[keyof typeof SCOPES]; @@ -77,6 +78,7 @@ export const SCOPE_INFO: Record = { 'journey:read': { label: 'View journeys', description: 'Read journeys, entries, and contributor list', group: 'Journey' }, 'journey:write': { label: 'Manage journeys', description: 'Create, update, and delete journeys and their entries', group: 'Journey' }, 'journey:share': { label: 'Manage journey links', description: 'Create, update, and revoke public share links for journeys', group: 'Journey' }, + 'plugins:use': { label: 'Use plugin tools', description: 'Call tools added by installed TREK plugins — each one acts with the permissions an admin granted that plugin', group: 'Plugins' }, }; // --------------------------------------------------------------------------- @@ -114,6 +116,17 @@ export function canShareTrips(scopes: string[] | null): boolean { return scopes.includes('trips:share'); } +/** + * plugins:use gates every tool installed plugins expose. One scope covers all of them: + * what a given tool may touch is bounded by the permissions an admin granted that + * plugin, acting as this token's user — the scope is consent to reach plugin tools at + * all, not a second per-plugin permission model. + */ +export function canUsePluginTools(scopes: string[] | null): boolean { + if (!scopes) return true; + return scopes.includes('plugins:use'); +} + /** journey:share is a separate scope for managing public share links for journeys */ export function canShareJourneys(scopes: string[] | null): boolean { if (!scopes) return true; diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index de14bad6f2..e770a6d5e5 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -7,6 +7,7 @@ import { registerPlaceTools } from './tools/places'; import { registerCollectionTools } from './tools/collections'; import { registerTransportTools } from './tools/transports'; import { registerMcpPrompts } from './tools/prompts'; +import { registerPluginTools } from './plugin-tools'; import { getMcpRegistry } from './registry-handoff'; export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void { @@ -58,6 +59,11 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ registerMcpPrompts(server, userId, isStaticToken); + // Tools contributed by active plugins (namespaced plugin__, gated on the + // plugins:use scope). Registered after the built-ins so a plugin can never shadow + // one — a colliding name is skipped rather than allowed to win. + registerPluginTools(server, userId, scopes); + // Decorator-registered domains (@trek/nest-mcp) — migrating off the legacy // registrar fan-out above, one domain at a time. Unset registry (direct // callers without a Nest app, e.g. unit tests) ⇒ skip; the test harness diff --git a/server/src/nest/plugins/mcp-tool-report.ts b/server/src/nest/plugins/mcp-tool-report.ts new file mode 100644 index 0000000000..9b85891b0c --- /dev/null +++ b/server/src/nest/plugins/mcp-tool-report.ts @@ -0,0 +1,115 @@ +/** + * The MCP tool declarations a plugin reports at load, and the caps the host puts on + * them (#plugins). + * + * A child reports whatever its `mcpTools` array says — untrusted, unvalidated, and + * destined for a tools/list response that a model reads and acts on. So everything + * lands here first: names are shape-checked, text is capped and emoji-stripped like + * every other string TREK renders in its own chrome, and anything that fails is + * DROPPED WHOLE rather than truncated into a tool whose description no longer matches + * what it does. Pure (no imports with side effects) so the supervisor and the tests + * can both use it. + */ + +import { stripEmoji } from './text-sanitize'; + +/** The MCP behaviour hints, exactly as the protocol defines them. Advisory: TREK + * passes them through to the client and enforces none of them. */ +export interface McpToolAnnotationHints { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + +/** One tool a plugin advertises. The handler stays in the child — this is the + * declarative half, cached host-side and served from memory at session start. */ +export interface PluginMcpToolReport { + name: string; + title?: string; + description: string; + inputSchema?: Record; + annotations?: McpToolAnnotationHints; +} + +/** Tools one plugin may advertise. A model's tool list is finite attention — a plugin + * that wants 50 tools is a plugin that should want fewer. */ +export const MAX_MCP_TOOLS_PER_PLUGIN = 16; +/** snake_case, and short enough that `plugin__` stays inside MAX_PUBLIC_NAME. */ +export const MCP_TOOL_NAME_RE = /^[a-z][a-z0-9_]{0,47}$/; +/** MCP clients (Claude among them) reject a tool name longer than this. */ +export const MAX_PUBLIC_TOOL_NAME = 64; +const MAX_TITLE = 80; +const MAX_DESCRIPTION = 4096; +/** Serialized schema ceiling — a schema is sent to the model on every request. */ +const MAX_INPUT_SCHEMA_BYTES = 16 * 1024; + +/** The four MCP behaviour hints. Anything else a plugin invents is dropped. */ +const ANNOTATION_KEYS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const; + +/** + * The name an MCP client sees. Namespaced by plugin id, which makes a collision + * between two plugins impossible: ids match /^[a-z][a-z0-9-]{2,39}$/ (no underscore) + * and tool names carry no dash, so the first `_` after the id always terminates it. + */ +export function publicMcpToolName(pluginId: string, name: string): string { + return `plugin_${pluginId}_${name}`; +} + +function cappedText(v: unknown, max: number): string | undefined { + if (typeof v !== 'string') return undefined; + const s = stripEmoji(v).trim(); + return s.length > 0 && s.length <= max ? s : undefined; +} + +function normalizeSchema(v: unknown): Record | undefined { + if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined; + let serialized: string; + try { + serialized = JSON.stringify(v); + } catch { + return undefined; // circular / non-serialisable — it could never cross the wire anyway + } + if (serialized.length > MAX_INPUT_SCHEMA_BYTES) return undefined; + return v as Record; +} + +function normalizeAnnotations(v: unknown): McpToolAnnotationHints | undefined { + if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined; + const src = v as Record; + const out: McpToolAnnotationHints = {}; + for (const k of ANNOTATION_KEYS) if (typeof src[k] === 'boolean') out[k] = src[k]; + return Object.keys(out).length ? out : undefined; +} + +/** + * Validate + cap what a child reported. Invalid entries are dropped (never repaired): + * a tool with a 5000-character description truncated to 4096 would be advertised to a + * model with its instructions cut mid-sentence, which is worse than not existing. + * First-wins on a duplicate name, matching how the supervisor treats provider order. + */ +export function normalizeMcpToolReports(raw: unknown, pluginId: string): PluginMcpToolReport[] { + if (!Array.isArray(raw)) return []; + const out: PluginMcpToolReport[] = []; + const seen = new Set(); + for (const entry of raw.slice(0, MAX_MCP_TOOLS_PER_PLUGIN)) { + if (!entry || typeof entry !== 'object') continue; + const t = entry as Record; + const name = typeof t.name === 'string' ? t.name : ''; + if (!MCP_TOOL_NAME_RE.test(name) || seen.has(name)) continue; + if (publicMcpToolName(pluginId, name).length > MAX_PUBLIC_TOOL_NAME) continue; + // The description is the only thing a model reads to decide whether to call — + // a tool without one is not advertisable. + const description = cappedText(t.description, MAX_DESCRIPTION); + if (!description) continue; + seen.add(name); + out.push({ + name, + title: cappedText(t.title, MAX_TITLE), + description, + inputSchema: normalizeSchema(t.inputSchema), + annotations: normalizeAnnotations(t.annotations), + }); + } + return out; +} diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index aeeb84f1a7..769c5c40a3 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -10,7 +10,10 @@ import { PLUGIN_CHANNEL_EVENTS } from './install/manifest'; import { stripEmoji } from './text-sanitize'; import { applyStagedPluginTrees, setStagedRestoreApplier } from './plugin-backup'; import { decrypt_api_key } from '../../services/apiKeyCrypto'; -import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor'; +import { PluginSupervisor, type PluginRouteInfo, type PluginStatus } from './supervisor/plugin-supervisor'; +import type { PluginMcpToolReport } from './mcp-tool-report'; +import { invalidateMcpSessions } from '../../mcp'; +import { setPluginToolsRuntime } from '../../mcp/plugin-tools-handoff'; import fs from 'node:fs'; import path from 'node:path'; import { PluginHostDepsFactory } from './host/plugin-host-deps.factory'; @@ -109,6 +112,14 @@ export class PluginDependencyError extends Error { } } +/** + * How long a plugin MCP tool may run. Generous next to a provider hook's 5 s — an + * assistant is waiting on this call by itself, and a tool that reaches an external API + * is the normal case rather than the exception — but still bounded, so a wedged plugin + * surfaces a tool error instead of hanging the MCP session. + */ +const MCP_TOOL_TIMEOUT_MS = 30_000; + /** * Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2). * Bridges the DB registry (`plugins` rows) to the process supervisor: activate @@ -137,6 +148,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { try { this.db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, error ?? null, id); } catch { /* DB unavailable (e.g. mid-restore) — a status write must never crash the host */ } + this.syncMcpToolSurface(id, status); }, onLog: (id, level, msg) => { if (level !== 'error' && level !== 'warn') return; @@ -154,6 +166,10 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // author's source auto-reloads. Empty unless dev-link is used. private readonly linkWatchers = new Map(); + // Plugins whose MCP tools are currently advertised. Compared against on every status + // change so live MCP sessions are torn down only when the surface really moved. + private readonly mcpToolPlugins = new Set(); + // Sweeps plugin_scheduled_tasks for due callbacks and fires them on active plugins. private schedulerSweep: ReturnType | null = null; // Coalesces overlapping erasure drains (the sweep and enqueue both trigger one). @@ -202,6 +218,15 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // Fan a deleted account out to plugins so they can erase their own per-user data. // Enqueued durably (survives restart), so nothing is lost if a plugin is offline. setUserDeletedSink((userId) => this.enqueueUserErasure(userId)); + // Let the (plain, non-Nest) MCP handler advertise plugin tools. Pull-based like the + // notification-channel source: every new session reads the current providers, so + // there is no registration to keep in sync — only live sessions need invalidating, + // which syncMcpToolSurface does. + setPluginToolsRuntime({ + mcpToolProviders: () => this.mcpToolProviders(), + mcpToolsOf: (id) => this.mcpToolsOf(id), + invokeMcpTool: (id, tool, input, actingUserId) => this.invokeMcpTool(id, tool, input, actingUserId), + }); // Discover plugins placed on the volume (registers new ones inactive), then // boot the ones an admin had already activated — in dependency order so a // plugin's dependencies come up before it does. The whole block is defensive: @@ -424,6 +449,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { async onModuleDestroy(): Promise { setPluginEventSink(null); setUserDeletedSink(null); + setPluginToolsRuntime(null); setStagedRestoreApplier(null); if (this.schedulerSweep) { clearInterval(this.schedulerSweep); this.schedulerSweep = null; } for (const w of this.linkWatchers.values()) { @@ -927,6 +953,53 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { providersOf(hook: string): string[] { return this.supervisor.providersOf(hook); } + /** + * Keep live MCP sessions honest about which plugin tools exist. + * + * A session registers its tool list once, when it is created, and an MCP client that + * is already connected has no reason to re-list — so a plugin going up or down has to + * tear the sessions down to be reflected. Only an ACTUAL change to the advertised + * surface does that, though: a status flip on a plugin with no tools (or a crash-loop + * on one that was already down) must not kill every open session (#1414). + * + * Runs from a child-lifecycle callback, where a throw would be an uncaught exception + * with no handler — hence the blanket catch. + */ + private syncMcpToolSurface(id: string, status: PluginStatus): void { + try { + const exposesNow = status === 'active' && this.supervisor.mcpToolsOf(id).length > 0; + if (exposesNow === this.mcpToolPlugins.has(id)) return; + if (exposesNow) this.mcpToolPlugins.add(id); + else this.mcpToolPlugins.delete(id); + invalidateMcpSessions(); + } catch { /* never let MCP bookkeeping break a plugin lifecycle transition */ } + } + + /** Ids of active plugins advertising MCP tools (both declared and `mcp:tools`-granted). */ + mcpToolProviders(): string[] { + return this.supervisor.mcpToolProviders(); + } + /** One plugin's advertised MCP tools, normalized + capped at load. */ + mcpToolsOf(id: string): PluginMcpToolReport[] { + return this.supervisor.mcpToolsOf(id); + } + /** + * Run one plugin MCP tool on behalf of the user whose token made the MCP call + * (host→plugin). A longer timeout than a provider hook: a tool is an explicit, + * awaited request from an assistant rather than a garnish on a core response, and + * it may well call an external API — but still bounded, so a hung plugin returns a + * tool error instead of stalling the session. + */ + invokeMcpTool(id: string, tool: string, input: unknown, actingUserId: number): Promise { + // Defense in depth, exactly as invokeHook: re-check that the plugin is still an + // active, granted provider of THIS tool. A session lists tools once and may call + // minutes later, by which time the plugin could have been disabled or lost the grant. + if (!this.supervisor.mcpToolsOf(id).some((t) => t.name === tool)) { + return Promise.reject(new Error(`plugin ${id} does not expose the MCP tool ${tool}`)); + } + return this.supervisor.invoke(id, 'invoke.mcpTool', { name: tool, input }, { actingUserId, timeoutMs: MCP_TOOL_TIMEOUT_MS }); + } + /** * Ask ONE plugin's provider hook for data (host→plugin). A tighter default * timeout than a route call so a slow provider can't delay the core response; diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts index a1121db261..e8126afe3c 100644 --- a/server/src/nest/plugins/protocol/envelope.ts +++ b/server/src/nest/plugins/protocol/envelope.ts @@ -339,6 +339,12 @@ export const KNOWN_PERMISSIONS = [ 'hook:user-data', 'events:subscribe', 'jobs:run', + // Entry-point permission (no RPC method): lets the plugin's declared `mcpTools` be + // advertised on TREK's MCP server and called by a connected assistant. Ungranted, + // the tools are never listed and never invocable. It grants no data access of its + // own — a tool handler can still only reach what the plugin's OTHER grants allow, + // acting as the user whose token made the MCP call. + 'mcp:tools', 'http:outbound', 'notify:send', 'ai:invoke', diff --git a/server/src/nest/plugins/runtime/plugin-host-entry.ts b/server/src/nest/plugins/runtime/plugin-host-entry.ts index d44f3e53d7..a1444085cd 100644 --- a/server/src/nest/plugins/runtime/plugin-host-entry.ts +++ b/server/src/nest/plugins/runtime/plugin-host-entry.ts @@ -105,12 +105,17 @@ async function boot(config: Record): Promise { const routes = (def.routes ?? []).map((r, i) => ({ i, method: r.method, path: r.path, auth: r.auth !== false })); const jobs = (def.jobs ?? []).map((j) => ({ id: j.id, schedule: j.schedule })); const hooks = Object.keys((def.hooks ?? {}) as Record); + // MCP tool DECLARATIONS only — the handlers stay here in the child. The host caps + // and sanitizes these (mcp-tool-report.ts) before it ever advertises them. + const mcpTools = (def.mcpTools ?? []).map((t) => ({ + name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, annotations: t.annotations, + })); const events = (def.events ?? []).map((e) => e.on); // Inter-plugin surface: the callable exports this plugin implements, and the // other-plugin events it subscribes to (so the host can route fan-out). const exportNames = Object.keys((def.exports ?? {}) as Record); const subscriptions = (def.subscriptions ?? []).map((s) => ({ plugin: s.plugin, event: s.event })); - send({ k: 'evt', topic: 'loaded', data: { routes, jobs, hooks, events, exports: exportNames, subscriptions } }); + send({ k: 'evt', topic: 'loaded', data: { routes, jobs, hooks, mcpTools, events, exports: exportNames, subscriptions } }); activated = true; // past load: a later async throw is a runtime CRASH, not a load failure // An immediate first heartbeat confirms liveness without waiting a full interval. send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } }); @@ -177,6 +182,18 @@ async function handleInvoke(req: { id: string; method: string; params: Record t.name === name); + if (!tool) throw new Error(`no mcpTool ${name}`); + const result = await tool.handler(req.params.input, invCtx); + respond(true, result); } else if (req.method === 'invoke.action') { // A settings-page button the user clicked. USER-INITIATED: invCtx carries the // clicking user, so ctx.settings.get() returns THEIR value and trip reads are diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index 01a24be29e..76450d98db 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -339,6 +339,28 @@ export interface PluginJob { schedule: string; handler(ctx: PluginContext): Promise; } +/** Advisory behaviour hints for an MCP client; TREK enforces none of them. */ +export interface McpToolAnnotations { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} +/** A tool the plugin adds to TREK's MCP server, advertised as `plugin__`. + * The declarative half is reported at load and capped host-side (mcp-tool-report.ts); + * the handler runs per call with the CALLING user bound, like a route. Needs + * `mcp:tools`, and the session's token needs the `plugins:use` scope. */ +export interface PluginMcpTool { + name: string; + title?: string; + description: string; + /** Plain JSON Schema; the host converts it for the MCP client, which checks + * arguments against the parts it understands — permissively, so `input` is not + * guaranteed to be validated and the handler must check its own. */ + inputSchema?: Record; + annotations?: McpToolAnnotations; + handler(input: unknown, ctx: PluginContext): Promise | unknown; +} // ── Provider hooks (host→plugin): core asks a hook the plugin implements for data, // gated by the matching hook:* permission. Each method also receives the per- // invocation ctx, so any trip reads it makes bind to the authenticated user. ── @@ -628,6 +650,8 @@ export interface PluginDefinition { onUnload?(ctx: PluginContext): Promise | void; routes?: PluginRoute[]; jobs?: PluginJob[]; + /** Tools exposed on TREK's MCP server. Needs `mcp:tools`. */ + mcpTools?: PluginMcpTool[]; /** Handles a callback registered via ctx.scheduler (userless, like a job). The * `name` identifies which scheduled task fired; `payload` is what you passed. */ scheduled?(input: { name: string; payload: unknown }, ctx: PluginContext): Promise | void; diff --git a/server/src/nest/plugins/supervisor/plugin-supervisor.ts b/server/src/nest/plugins/supervisor/plugin-supervisor.ts index a77a8b5706..341ee370e4 100644 --- a/server/src/nest/plugins/supervisor/plugin-supervisor.ts +++ b/server/src/nest/plugins/supervisor/plugin-supervisor.ts @@ -7,6 +7,7 @@ import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope'; import type { PluginRpcHost } from '../host/rpc-host'; import { scheduleJobs, stopJobs, type ScheduledJob } from '../host/plugin-jobs'; import { SNAPSHOT_GRANT, type PluginEventMeta } from '../../../plugin-event-sink'; +import { normalizeMcpToolReports, type PluginMcpToolReport } from '../mcp-tool-report'; import { RpcRateLimiter, DEFAULT_RPC_LIMIT, TokenBucket, DEFAULT_LOG_LIMIT } from '../host/rate-limit'; export interface PluginRouteInfo { @@ -55,6 +56,7 @@ interface Supervised { jobs: ScheduledJob[]; // declared background jobs (id + cron schedule) jobTasks?: ReturnType; // live node-cron tasks (only when jobs:run granted) hooks: string[]; // provider hooks the plugin implements (e.g. 'placeDetailProvider') + mcpTools: PluginMcpToolReport[]; // MCP tool declarations (normalized + capped at load) events: string[]; // core events the plugin subscribes to (names or '*') exports: string[]; // functions the plugin exposes to dependents (ctx.plugins.call) subscriptions: Array<{ plugin: string; event: string }>; // other-plugin events it listens to @@ -162,6 +164,7 @@ export class PluginSupervisor { routes: [], jobs: [], hooks: [], + mcpTools: [], events: [], exports: [], subscriptions: [], @@ -269,6 +272,27 @@ export class PluginSupervisor { return out; } + /** + * The MCP tools an ACTIVE plugin may advertise — declared at load AND covered by the + * `mcp:tools` grant the admin consented to. Same host-side check as providersOf: the + * child reports its declarations knowing nothing about grants, so this is the only + * place the consent is actually enforced. + */ + mcpToolsOf(id: string): PluginMcpToolReport[] { + const sup = this.running.get(id); + if (!sup || sup.status !== 'active' || !sup.granted.has('mcp:tools')) return []; + return sup.mcpTools; + } + + /** Ids of every active plugin currently advertising at least one MCP tool. */ + mcpToolProviders(): string[] { + const out: string[] = []; + for (const [id, sup] of this.running) { + if (sup.status === 'active' && sup.granted.has('mcp:tools') && sup.mcpTools.length) out.push(id); + } + return out; + } + /** Callable export names an ACTIVE plugin reported at load (ctx.plugins.call target). */ exportsOf(id: string): string[] { const sup = this.running.get(id); @@ -583,12 +607,14 @@ export class PluginSupervisor { const d = msg.data as { routes?: PluginRouteInfo[]; jobs?: ScheduledJob[]; hooks?: string[]; events?: string[]; exports?: string[]; subscriptions?: Array<{ plugin: string; event: string }>; + mcpTools?: unknown; }; sup.routes = d.routes ?? []; sup.jobs = Array.isArray(d.jobs) ? d.jobs.filter((j): j is ScheduledJob => !!j && typeof j.id === 'string' && typeof j.schedule === 'string') : []; sup.hooks = d.hooks ?? []; + sup.mcpTools = normalizeMcpToolReports(d.mcpTools, sup.id); sup.events = d.events ?? []; sup.exports = Array.isArray(d.exports) ? d.exports.filter((e): e is string => typeof e === 'string') : []; sup.subscriptions = Array.isArray(d.subscriptions) diff --git a/server/tests/unit/mcp/json-schema-to-zod.test.ts b/server/tests/unit/mcp/json-schema-to-zod.test.ts new file mode 100644 index 0000000000..1cd98d4b50 --- /dev/null +++ b/server/tests/unit/mcp/json-schema-to-zod.test.ts @@ -0,0 +1,136 @@ +/** + * A plugin ships its tool schema as plain JSON (it lives in another process), but + * McpServer.registerTool takes Zod. This converter bridges the two, and its bias is + * PERMISSIVE: the result is what the SDK validates arguments against before the plugin + * sees them, so a strict reading of a schema it only half-understands would reject + * calls the plugin would have handled fine. + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { jsonSchemaToZodShape } from '../../../src/mcp/json-schema-to-zod'; + +/** Parse through the object the SDK would build from the shape. */ +const parse = (schema: unknown, input: unknown) => { + const shape = jsonSchemaToZodShape(schema); + if (!shape) throw new Error('expected a shape'); + return z.object(shape as z.ZodRawShape).safeParse(input); +}; + +describe('jsonSchemaToZodShape', () => { + it('converts the primitive types', () => { + const schema = { + type: 'object', + properties: { s: { type: 'string' }, n: { type: 'number' }, i: { type: 'integer' }, b: { type: 'boolean' } }, + required: ['s', 'n', 'i', 'b'], + }; + expect(parse(schema, { s: 'x', n: 1.5, i: 2, b: true }).success).toBe(true); + expect(parse(schema, { s: 1, n: 1.5, i: 2, b: true }).success).toBe(false); + expect(parse(schema, { s: 'x', n: 1.5, i: 2.5, b: true }).success).toBe(false); // integer + }); + + it('makes anything outside `required` optional', () => { + const schema = { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }; + expect(parse(schema, { a: 'x' }).success).toBe(true); + expect(parse(schema, { b: 'x' }).success).toBe(false); // a is required + }); + + it('treats a missing `required` as everything-optional', () => { + const schema = { type: 'object', properties: { a: { type: 'string' } } }; + expect(parse(schema, {}).success).toBe(true); + }); + + it('handles arrays, including arrays of objects', () => { + const schema = { + type: 'object', + properties: { tags: { type: 'array', items: { type: 'string' } } }, + required: ['tags'], + }; + expect(parse(schema, { tags: ['a', 'b'] }).success).toBe(true); + expect(parse(schema, { tags: [1] }).success).toBe(false); + // An array with no `items` accepts anything. + expect(parse({ type: 'object', properties: { x: { type: 'array' } }, required: ['x'] }, { x: [1, 'a'] }).success).toBe(true); + }); + + it('handles string enums and ignores non-string ones', () => { + const en = { type: 'object', properties: { mode: { enum: ['fast', 'slow'] } }, required: ['mode'] }; + expect(parse(en, { mode: 'fast' }).success).toBe(true); + expect(parse(en, { mode: 'other' }).success).toBe(false); + // A mixed enum is unconstrained rather than rejected. + const mixed = { type: 'object', properties: { m: { enum: [1, 'a'] } }, required: ['m'] }; + expect(parse(mixed, { m: 1 }).success).toBe(true); + }); + + it('recurses into nested objects and keeps their required-ness', () => { + const schema = { + type: 'object', + properties: { + who: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + }, + required: ['who'], + }; + expect(parse(schema, { who: { name: 'ada' } }).success).toBe(true); + expect(parse(schema, { who: {} }).success).toBe(false); + }); + + it('keeps a property-less object open instead of stripping it to nothing', () => { + // z.object({}) would silently drop every key on the way to the handler. + const schema = { type: 'object', properties: { bag: { type: 'object' } }, required: ['bag'] }; + const out = parse(schema, { bag: { anything: 1, nested: { deep: true } } }); + expect(out.success).toBe(true); + expect(out.success && out.data.bag).toEqual({ anything: 1, nested: { deep: true } }); + }); + + it('advertises unrecognized constructs as unconstrained rather than rejecting them', () => { + const schema = { + type: 'object', + properties: { + ref: { $ref: '#/definitions/Thing' }, + union: { type: ['string', 'null'] }, + any: {}, + }, + required: ['ref', 'union', 'any'], + }; + expect(parse(schema, { ref: { a: 1 }, union: null, any: 'whatever' }).success).toBe(true); + }); + + it('carries property descriptions through — the model reads them', () => { + const shape = jsonSchemaToZodShape({ + type: 'object', + properties: { q: { type: 'string', description: 'What to search for' } }, + required: ['q'], + }); + expect(z.toJSONSchema(z.object(shape as z.ZodRawShape))).toMatchObject({ + properties: { q: { description: 'What to search for' } }, + }); + }); + + it('returns undefined for anything that is not a usable object schema', () => { + for (const raw of [undefined, null, 'nope', 42, [], {}, { type: 'string' }, { type: 'object' }, { type: 'object', properties: {} }]) { + expect(jsonSchemaToZodShape(raw)).toBeUndefined(); + } + }); + + it('bounds hostile input — deep nesting degrades to unconstrained instead of recursing forever', () => { + let node: Record = { type: 'string' }; + for (let i = 0; i < 40; i++) node = { type: 'object', properties: { next: node }, required: ['next'] }; + const shape = jsonSchemaToZodShape(node); + expect(shape).toBeDefined(); + const object = z.object(shape as z.ZodRawShape); + + // Everything below the depth ceiling is z.unknown(), so a value nested far deeper + // than the converter ever walked is accepted wholesale rather than blowing the stack. + let deep: unknown = 'leaf'; + for (let i = 0; i < 40; i++) deep = { next: deep }; + expect(object.safeParse(deep).success).toBe(true); + + // …while the levels it DID walk are still real schemas. + expect(object.safeParse({ next: 'too shallow' }).success).toBe(false); + }); + + it('caps the number of properties it will convert', () => { + const properties: Record = {}; + for (let i = 0; i < 200; i++) properties[`p${i}`] = { type: 'string' }; + const shape = jsonSchemaToZodShape({ type: 'object', properties }); + expect(Object.keys(shape!).length).toBe(64); + }); +}); diff --git a/server/tests/unit/mcp/plugin-tools.test.ts b/server/tests/unit/mcp/plugin-tools.test.ts new file mode 100644 index 0000000000..1ef89e574a --- /dev/null +++ b/server/tests/unit/mcp/plugin-tools.test.ts @@ -0,0 +1,165 @@ +/** + * Plugin tools end to end, through a real MCP client and server: what a connected + * assistant actually sees in tools/list and gets back from tools/call. + * + * The plugin runtime is faked at the handoff seam (the same one PluginRuntimeService + * fills in onModuleInit), so this exercises the whole bridge — scope gate, namespacing, + * schema conversion, invocation, error mapping — without spawning a child process. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { createMcpHarness } from '../../helpers/mcp-harness'; +import { setPluginToolsRuntime, type PluginToolsRuntime } from '../../../src/mcp/plugin-tools-handoff'; +import type { PluginMcpToolReport } from '../../../src/nest/plugins/mcp-tool-report'; + +const LOOKUP: PluginMcpToolReport = { + name: 'lookup', + title: 'Lookup', + description: 'Look something up', + inputSchema: { type: 'object', properties: { q: { type: 'string', description: 'The query' } }, required: ['q'] }, + annotations: { readOnlyHint: true }, +}; + +/** A runtime exposing `tools` on one plugin; invoke is a spy you can steer. */ +function fakeRuntime( + tools: PluginMcpToolReport[], + invoke: PluginToolsRuntime['invokeMcpTool'] = async () => 'ok', + pluginId = 'trip-doctor', +): PluginToolsRuntime { + return { + mcpToolProviders: () => (tools.length ? [pluginId] : []), + mcpToolsOf: (id) => (id === pluginId ? tools : []), + invokeMcpTool: invoke, + }; +} + +afterEach(() => setPluginToolsRuntime(null)); + +async function listTools(scopes: string[] | null) { + const h = await createMcpHarness({ userId: 1, scopes, withResources: false }); + const { tools } = await h.client.listTools(); + await h.cleanup(); + return tools; +} + +describe('plugin tools in tools/list', () => { + it('advertises a plugin tool namespaced, with its title, description and schema', async () => { + setPluginToolsRuntime(fakeRuntime([LOOKUP])); + const tool = (await listTools(null)).find((t) => t.name === 'plugin_trip-doctor_lookup'); + + expect(tool).toBeDefined(); + expect(tool!.title).toBe('Lookup'); + expect(tool!.description).toBe('Look something up'); + expect(tool!.annotations).toMatchObject({ readOnlyHint: true }); + // The schema has to survive the JSON→Zod→JSON round trip, descriptions included: + // it and the description are all the model reads before deciding to call. + expect(tool!.inputSchema).toMatchObject({ + type: 'object', + properties: { q: { type: 'string', description: 'The query' } }, + required: ['q'], + }); + }); + + it('advertises a no-argument tool with an empty schema', async () => { + setPluginToolsRuntime(fakeRuntime([{ name: 'ping', description: 'Ping' }])); + const tool = (await listTools(null)).find((t) => t.name === 'plugin_trip-doctor_ping'); + expect(tool).toBeDefined(); + expect(tool!.inputSchema).toMatchObject({ type: 'object' }); + expect(tool!.inputSchema.properties ?? {}).toEqual({}); + }); + + it('never shadows a built-in tool — a colliding name is skipped, the session survives', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Two plugins, same tool name: the namespacing makes them distinct, so force a + // real collision by having the runtime report the same plugin id twice. + const dupe: PluginToolsRuntime = { + mcpToolProviders: () => ['dup', 'dup'], + mcpToolsOf: () => [LOOKUP], + invokeMcpTool: async () => 'ok', + }; + setPluginToolsRuntime(dupe); + const tools = await listTools(null); + expect(tools.filter((t) => t.name === 'plugin_dup_lookup')).toHaveLength(1); + // and the built-ins are all still there + expect(tools.length).toBeGreaterThan(10); + warn.mockRestore(); + }); + + it('shows nothing when no plugin runtime was ever handed over', async () => { + const tools = await listTools(null); + expect(tools.some((t) => t.name.startsWith('plugin_'))).toBe(false); + }); +}); + +describe('the plugins:use scope gate', () => { + it('hides plugin tools from a scoped token that lacks plugins:use', async () => { + setPluginToolsRuntime(fakeRuntime([LOOKUP])); + const tools = await listTools(['trips:read']); + expect(tools.some((t) => t.name.startsWith('plugin_'))).toBe(false); + }); + + it('shows them once the token carries plugins:use', async () => { + setPluginToolsRuntime(fakeRuntime([LOOKUP])); + const tools = await listTools(['trips:read', 'plugins:use']); + expect(tools.some((t) => t.name === 'plugin_trip-doctor_lookup')).toBe(true); + }); + + it('shows them to a full-access (unscoped) token', async () => { + setPluginToolsRuntime(fakeRuntime([LOOKUP])); + expect((await listTools(null)).some((t) => t.name === 'plugin_trip-doctor_lookup')).toBe(true); + }); +}); + +describe('calling a plugin tool', () => { + it('forwards the arguments and the acting user, and returns the plugin result', async () => { + const invoke = vi.fn(async () => ({ found: 3 })); + setPluginToolsRuntime(fakeRuntime([LOOKUP], invoke)); + const h = await createMcpHarness({ userId: 42, scopes: null, withResources: false }); + + const res = await h.client.callTool({ name: 'plugin_trip-doctor_lookup', arguments: { q: 'hotels' } }); + + expect(invoke).toHaveBeenCalledWith('trip-doctor', 'lookup', { q: 'hotels' }, 42); + expect(res.isError).toBeFalsy(); + expect(JSON.parse((res.content as { text: string }[])[0].text)).toEqual({ found: 3 }); + await h.cleanup(); + }); + + it('passes a string result through verbatim instead of re-quoting it', async () => { + setPluginToolsRuntime(fakeRuntime([LOOKUP], async () => 'All good.')); + const h = await createMcpHarness({ userId: 1, scopes: null, withResources: false }); + const res = await h.client.callTool({ name: 'plugin_trip-doctor_lookup', arguments: { q: 'x' } }); + expect((res.content as { text: string }[])[0].text).toBe('All good.'); + await h.cleanup(); + }); + + it('turns a plugin throw into a tool error the model can read', async () => { + setPluginToolsRuntime(fakeRuntime([LOOKUP], async () => { throw new Error('upstream API is down'); })); + const h = await createMcpHarness({ userId: 1, scopes: null, withResources: false }); + + const res = await h.client.callTool({ name: 'plugin_trip-doctor_lookup', arguments: { q: 'x' } }); + + expect(res.isError).toBe(true); + expect((res.content as { text: string }[])[0].text).toContain('upstream API is down'); + await h.cleanup(); + }); + + it('reports a plugin deactivated since the session listed its tools as a clean error', async () => { + // What invokeMcpTool's defense-in-depth check rejects with once the plugin is gone. + setPluginToolsRuntime(fakeRuntime([LOOKUP], async () => { + throw new Error('plugin trip-doctor does not expose the MCP tool lookup'); + })); + const h = await createMcpHarness({ userId: 1, scopes: null, withResources: false }); + const res = await h.client.callTool({ name: 'plugin_trip-doctor_lookup', arguments: { q: 'x' } }); + expect(res.isError).toBe(true); + await h.cleanup(); + }); + + it('truncates an enormous result rather than flooding the model context', async () => { + setPluginToolsRuntime(fakeRuntime([LOOKUP], async () => 'x'.repeat(200_000))); + const h = await createMcpHarness({ userId: 1, scopes: null, withResources: false }); + const text = (await h.client.callTool({ name: 'plugin_trip-doctor_lookup', arguments: { q: 'x' } }) + .then((r) => (r.content as { text: string }[])[0].text)); + expect(text.length).toBeLessThan(200_000); + expect(text).toContain('truncated by TREK'); + await h.cleanup(); + }); +}); diff --git a/server/tests/unit/mcp/scopes.test.ts b/server/tests/unit/mcp/scopes.test.ts index 15a310eb15..575a6cce1c 100644 --- a/server/tests/unit/mcp/scopes.test.ts +++ b/server/tests/unit/mcp/scopes.test.ts @@ -46,7 +46,7 @@ describe('ALL_SCOPES', () => { expect(ALL_SCOPES.length).toBeGreaterThan(0); }); - it('derives exactly the 14 known scope groups (ScopeGroup lockstep)', () => { + it('derives exactly the 15 known scope groups (ScopeGroup lockstep)', () => { // The runtime half of the ScopeGroup lockstep — the type half is // MCP_ACCESS_GROUPS_MATCH_SCOPE_GROUPS in src/mcp/nest-mcp-policy.ts, // covered by `npm run typecheck`. If this list changes, the MCP @@ -62,6 +62,7 @@ describe('ALL_SCOPES', () => { 'notifications', 'packing', 'places', + 'plugins', 'reservations', 'todos', 'trips', diff --git a/server/tests/unit/plugins/mcp-tool-report.test.ts b/server/tests/unit/plugins/mcp-tool-report.test.ts new file mode 100644 index 0000000000..3f31c1d377 --- /dev/null +++ b/server/tests/unit/plugins/mcp-tool-report.test.ts @@ -0,0 +1,108 @@ +/** + * The declarations a plugin reports for its `mcpTools` are untrusted input that ends + * up in a tools/list response a model reads and acts on. normalizeMcpToolReports is + * the only thing standing between the two, so it drops anything malformed WHOLE + * rather than repairing it into a tool whose description no longer describes it. + */ +import { describe, it, expect } from 'vitest'; +import { + normalizeMcpToolReports, + publicMcpToolName, + MAX_MCP_TOOLS_PER_PLUGIN, +} from '../../../src/nest/plugins/mcp-tool-report'; + +const tool = (over: Record = {}) => ({ name: 'lookup', description: 'Look something up', ...over }); + +describe('normalizeMcpToolReports', () => { + it('keeps a well-formed tool intact', () => { + const schema = { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] }; + expect(normalizeMcpToolReports([tool({ title: 'Lookup', inputSchema: schema })], 'my-plugin')).toEqual([ + { name: 'lookup', title: 'Lookup', description: 'Look something up', inputSchema: schema, annotations: undefined }, + ]); + }); + + it('drops names that are not snake_case identifiers', () => { + const bad = ['Lookup', 'look-up', '1lookup', 'look up', '', 'look.up', 'a'.repeat(49)]; + for (const name of bad) { + expect(normalizeMcpToolReports([tool({ name })], 'p1'), name).toEqual([]); + } + }); + + it('drops a tool whose namespaced name would exceed the 64-char client limit', () => { + const longId = 'a'.repeat(40); + const name = 'b'.repeat(30); + expect(publicMcpToolName(longId, name).length).toBeGreaterThan(64); + expect(normalizeMcpToolReports([tool({ name })], longId)).toEqual([]); + // The same tool on a short plugin id is fine — it is the composite that is capped. + expect(normalizeMcpToolReports([tool({ name })], 'p1')).toHaveLength(1); + }); + + it('requires a description — it is all the model reads before calling', () => { + expect(normalizeMcpToolReports([{ name: 'lookup' }], 'p1')).toEqual([]); + expect(normalizeMcpToolReports([tool({ description: '' })], 'p1')).toEqual([]); + expect(normalizeMcpToolReports([tool({ description: 42 })], 'p1')).toEqual([]); + }); + + it('drops rather than truncates an over-long description', () => { + expect(normalizeMcpToolReports([tool({ description: 'x'.repeat(4097) })], 'p1')).toEqual([]); + expect(normalizeMcpToolReports([tool({ description: 'x'.repeat(4096) })], 'p1')).toHaveLength(1); + }); + + it('drops an over-long title but keeps the tool', () => { + const [t] = normalizeMcpToolReports([tool({ title: 'x'.repeat(81) })], 'p1'); + expect(t.title).toBeUndefined(); + expect(t.name).toBe('lookup'); + }); + + it('strips emoji from the rendered text, like every other plugin-supplied string', () => { + const [t] = normalizeMcpToolReports([tool({ title: '🔍 Lookup', description: '🚀 Look it up' })], 'p1'); + expect(t.title).toBe('Lookup'); + expect(t.description).toBe('Look it up'); + }); + + it('drops a schema that is not an object, or is too large to ship on every request', () => { + expect(normalizeMcpToolReports([tool({ inputSchema: 'nope' })], 'p1')[0].inputSchema).toBeUndefined(); + expect(normalizeMcpToolReports([tool({ inputSchema: [1, 2] })], 'p1')[0].inputSchema).toBeUndefined(); + const huge = { type: 'object', properties: { q: { description: 'x'.repeat(17_000) } } }; + expect(normalizeMcpToolReports([tool({ inputSchema: huge })], 'p1')[0].inputSchema).toBeUndefined(); + }); + + it('keeps only the four real MCP annotation hints, and only booleans', () => { + const [t] = normalizeMcpToolReports( + [tool({ annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: 'yes', invented: true } })], + 'p1', + ); + expect(t.annotations).toEqual({ readOnlyHint: true, openWorldHint: false }); + }); + + it('drops an annotations bag with nothing usable in it', () => { + expect(normalizeMcpToolReports([tool({ annotations: { nope: 1 } })], 'p1')[0].annotations).toBeUndefined(); + }); + + it('keeps the first of a duplicated name', () => { + const out = normalizeMcpToolReports( + [tool({ description: 'first' }), tool({ description: 'second' })], + 'p1', + ); + expect(out).toHaveLength(1); + expect(out[0].description).toBe('first'); + }); + + it('caps how many tools one plugin may advertise', () => { + const many = Array.from({ length: MAX_MCP_TOOLS_PER_PLUGIN + 5 }, (_, i) => tool({ name: `tool_${i}` })); + expect(normalizeMcpToolReports(many, 'p1')).toHaveLength(MAX_MCP_TOOLS_PER_PLUGIN); + }); + + it('treats anything that is not an array of objects as no tools at all', () => { + for (const raw of [undefined, null, 'tools', 42, {}, [null, 'x', 7]]) { + expect(normalizeMcpToolReports(raw, 'p1')).toEqual([]); + } + }); +}); + +describe('publicMcpToolName', () => { + it('namespaces by plugin id so two plugins can never collide', () => { + expect(publicMcpToolName('trip-doctor', 'check')).toBe('plugin_trip-doctor_check'); + expect(publicMcpToolName('other', 'check')).not.toBe(publicMcpToolName('trip-doctor', 'check')); + }); +}); diff --git a/server/tests/unit/plugins/mcp-tools-grant.test.ts b/server/tests/unit/plugins/mcp-tools-grant.test.ts new file mode 100644 index 0000000000..0ebde32aca --- /dev/null +++ b/server/tests/unit/plugins/mcp-tools-grant.test.ts @@ -0,0 +1,74 @@ +/** + * A plugin's MCP tools are only advertised if it BOTH declared them (reported by the + * child at load) AND holds the `mcp:tools` grant the admin consented to. The child + * reports its declarations knowing nothing about grants, so mcpToolsOf() host-side is + * where that consent is actually enforced — without it the permission is dead code. + * + * Same shape as provider-hook-grant.test.ts: mcpToolsOf only reads status/mcpTools/ + * granted, so bare Supervised entries go into the private map rather than real children. + */ +import { describe, it, expect } from 'vitest'; +import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor'; +import type { PluginMcpToolReport } from '../../../src/nest/plugins/mcp-tool-report'; +import { createPluginRuntime } from '../../helpers/plugin-host'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { db } from '../../../src/db/database'; + +const TOOL: PluginMcpToolReport = { name: 'lookup', description: 'Look something up' }; + +function makeSupervisor(): PluginSupervisor { + // createRpcHost is never called on this path (no spawn). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new PluginSupervisor((() => ({})) as any, {}, {}); +} +function put(s: PluginSupervisor, id: string, status: string, mcpTools: PluginMcpToolReport[], granted: string[]): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s as any).running.set(id, { id, status, mcpTools, granted: new Set(granted) }); +} + +describe('mcpToolsOf enforces the mcp:tools grant', () => { + it('returns tools only for an active plugin holding the grant', () => { + const s = makeSupervisor(); + put(s, 'granted', 'active', [TOOL], ['mcp:tools']); + put(s, 'ungranted', 'active', [TOOL], ['db:own']); // declared them, never consented to + put(s, 'starting', 'starting', [TOOL], ['mcp:tools']); // granted, not up yet + put(s, 'stopped', 'stopped', [TOOL], ['mcp:tools']); // granted, deliberately disabled + + expect(s.mcpToolsOf('granted')).toEqual([TOOL]); + expect(s.mcpToolsOf('ungranted')).toEqual([]); + expect(s.mcpToolsOf('starting')).toEqual([]); + expect(s.mcpToolsOf('stopped')).toEqual([]); + expect(s.mcpToolsOf('never-installed')).toEqual([]); + }); + + it('a hook grant does not bleed into the MCP tool surface', () => { + const s = makeSupervisor(); + put(s, 'hooky', 'active', [TOOL], ['hook:place-detail-provider', 'ai:invoke']); + expect(s.mcpToolsOf('hooky')).toEqual([]); + }); + + it('mcpToolProviders lists only plugins actually advertising something', () => { + const s = makeSupervisor(); + put(s, 'tools', 'active', [TOOL], ['mcp:tools']); + put(s, 'granted-but-empty', 'active', [], ['mcp:tools']); // grant without declarations + put(s, 'ungranted', 'active', [TOOL], ['db:own']); + put(s, 'down', 'error', [TOOL], ['mcp:tools']); + expect(s.mcpToolProviders()).toEqual(['tools']); + }); +}); + +describe('runtime.invokeMcpTool defense-in-depth', () => { + it('refuses a plugin that is not a granted provider, even when the id is passed directly', async () => { + const rt = createPluginRuntime(new DatabaseService(db)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', mcpTools: [TOOL], granted: new Set(['mcp:tools']) }); + await expect(rt.invokeMcpTool('other', 'lookup', {}, 1)).rejects.toThrow(/does not expose the MCP tool/); + }); + + it('refuses a tool name the plugin never declared', async () => { + const rt = createPluginRuntime(new DatabaseService(db)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', mcpTools: [TOOL], granted: new Set(['mcp:tools']) }); + await expect(rt.invokeMcpTool('ok', 'not_a_tool', {}, 1)).rejects.toThrow(/does not expose the MCP tool/); + }); +}); diff --git a/server/tests/unit/plugins/protocol-paths.test.ts b/server/tests/unit/plugins/protocol-paths.test.ts index e2e1d5f50e..3dc0dd6944 100644 --- a/server/tests/unit/plugins/protocol-paths.test.ts +++ b/server/tests/unit/plugins/protocol-paths.test.ts @@ -26,6 +26,14 @@ describe('envelope helpers', () => { expect(METHOD_PERMISSION[m]).toBeTruthy(); } }); + + it('knows mcp:tools — an entry-point permission, so it maps to no ctx method', () => { + // Gated like jobs:run / events:subscribe: the host simply never advertises an + // ungranted plugin's tools. It unlocks no RPC method, so it must NOT appear in + // METHOD_PERMISSION — if it ever does, a plugin gained a ctx call from it. + expect(isKnownPermission('mcp:tools')).toBe(true); + expect(Object.values(METHOD_PERMISSION)).not.toContain('mcp:tools'); + }); }); describe('paths', () => { diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 08dac899dc..a09ba15afa 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -342,6 +342,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:trip-card-provider': 'إضافة شارات صغيرة (الحالة، الأعداد) إلى بطاقات الرحلات في لوحة التحكم', 'admin.plugins.perm.hook:notification-channel': 'تسليم إشعاراتك عبر قناة إضافية', 'admin.plugins.perm.events:subscribe': 'التفاعل مع أحداث النشاط الأساسية (اسم الحدث والرحلة فقط، دون المحتوى مطلقًا)', + 'admin.plugins.perm.mcp:tools': 'تقديم أدواته الخاصة لمساعدي الذكاء الاصطناعي المتصلين بـ TREK عبر MCP', 'admin.plugins.perm.http:outbound': 'إجراء طلبات صادرة إلى المضيفات المُعلنة الخاصة بها', 'admin.plugins.perm.db:read:collab': 'قراءة الملاحظات والاستطلاعات ورسائل الدردشة في الرحلات التي يمكن للمستخدم الحالي الوصول إليها (يتطلب إضافة Collab)', @@ -454,6 +455,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'يضيف أوقات الخطة', 'admin.plugins.cap.geolocation': 'يقرأ موقعك', 'admin.plugins.cap.events': 'يتفاعل مع النشاط', + 'admin.plugins.cap.mcpTools': 'يضيف أدوات ذكاء اصطناعي', 'admin.plugins.cap.requiresAddon': 'يتطلب {addon}', 'admin.plugins.cap.dependsOn': 'يحتاج إلى {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'فعّل الإضافات المطلوبة أولاً: {addons}', diff --git a/shared/src/i18n/ar/oauth.ts b/shared/src/i18n/ar/oauth.ts index d097cc7f87..c4d8a7bd1f 100644 --- a/shared/src/i18n/ar/oauth.ts +++ b/shared/src/i18n/ar/oauth.ts @@ -13,6 +13,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.vacay': 'الإجازة', 'oauth.scope.group.weather': 'الطقس', 'oauth.scope.group.journey': 'مذكرة السفر', + 'oauth.scope.group.plugins': 'الإضافات', 'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر', 'oauth.scope.trips:read.description': 'قراءة الرحلات والأيام والملاحظات والأعضاء', 'oauth.scope.trips:write.label': 'تحرير الرحلات وخطط السفر', @@ -72,6 +73,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'إنشاء مذكرات السفر وتحديثها وحذفها وإدخالاتها', 'oauth.scope.journey:share.label': 'إدارة روابط مذكرات السفر', 'oauth.scope.journey:share.description': 'إنشاء روابط مشاركة عامة لمذكرات السفر وتحديثها وإلغاؤها', + 'oauth.scope.plugins:use.label': 'استخدام أدوات الإضافات', + 'oauth.scope.plugins:use.description': + 'استدعاء الأدوات التي تضيفها الإضافات المثبَّتة — تعمل كل أداة بالصلاحيات التي منحها المسؤول لتلك الإضافة', 'oauth.scope.group.atlas': 'Atlas', // en-fallback 'oauth.scope.group.geo': 'Geo', // en-fallback 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 115b093079..44d30c6fa5 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -331,6 +331,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Entregar suas notificações por um canal adicional', 'admin.plugins.perm.events:subscribe': 'Reagir a eventos de atividade do núcleo (apenas nome do evento + viagem, nunca o conteúdo)', + 'admin.plugins.perm.mcp:tools': 'Oferecer suas próprias ferramentas a assistentes de IA conectados ao TREK via MCP', 'admin.plugins.perm.http:outbound': 'Fazer requisições de saída para os hosts declarados', 'admin.plugins.perm.db:read:collab': 'Ler notas, enquetes e mensagens de chat de viagens às quais o usuário atual tem acesso (requer o complemento Collab)', @@ -443,6 +444,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Adiciona horários ao plano', 'admin.plugins.cap.geolocation': 'Lê sua localização', 'admin.plugins.cap.events': 'Reage a atividades', + 'admin.plugins.cap.mcpTools': 'Adiciona ferramentas de IA', 'admin.plugins.cap.requiresAddon': 'Requer {addon}', 'admin.plugins.cap.dependsOn': 'Requer {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Ative primeiro os complementos necessários: {addons}', diff --git a/shared/src/i18n/br/oauth.ts b/shared/src/i18n/br/oauth.ts index 7a8456907a..9c8693f537 100644 --- a/shared/src/i18n/br/oauth.ts +++ b/shared/src/i18n/br/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Clima', 'oauth.scope.group.journey': 'Jornada', + 'oauth.scope.group.plugins': 'Plugins', 'oauth.scope.trips:read.label': 'Ver viagens e itinerários', 'oauth.scope.trips:read.description': 'Ler viagens, dias, notas e membros', 'oauth.scope.trips:write.label': 'Editar viagens e itinerários', @@ -76,6 +77,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:share.label': 'Gerenciar links de jornadas', 'oauth.scope.journey:share.description': 'Criar, atualizar e revogar links de compartilhamento públicos para jornadas', + 'oauth.scope.plugins:use.label': 'Usar ferramentas de plugins', + 'oauth.scope.plugins:use.description': + 'Chamar ferramentas adicionadas pelos plugins instalados — cada uma age com as permissões que um administrador concedeu àquele plugin', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 0c046c52ac..ad6c484401 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -540,6 +540,7 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Cap connector del registre coincideix amb la teva cerca.', 'admin.plugins.restart': 'Reinicia', 'admin.plugins.restarted': 'Connector reiniciat', + 'admin.plugins.perm.mcp:tools': "Oferir les seves pròpies eines als assistents d'IA connectats a TREK per MCP", 'admin.plugins.cap.readsTrips': 'Llegeix els teus viatges', 'admin.plugins.cap.readsUsers': 'Llegeix els perfils bàsics', 'admin.plugins.cap.readsCosts': 'Llegeix les teves despeses', @@ -568,6 +569,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Afegeix horaris al pla', 'admin.plugins.cap.geolocation': 'Llegeix la teva ubicació', 'admin.plugins.cap.events': "Reacciona a l'activitat", + 'admin.plugins.cap.mcpTools': "Afegeix eines d'IA", 'admin.plugins.cap.requiresAddon': 'Requereix {addon}', 'admin.plugins.cap.dependsOn': 'Necessita {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Activa primer els complements necessaris: {addons}', diff --git a/shared/src/i18n/ca/oauth.ts b/shared/src/i18n/ca/oauth.ts index a434960e4c..32fd28856e 100644 --- a/shared/src/i18n/ca/oauth.ts +++ b/shared/src/i18n/ca/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Clima', 'oauth.scope.group.journey': 'Travesia', + 'oauth.scope.group.plugins': 'Connectors', 'oauth.scope.trips:read.label': 'Mostra els viatges i itineraris', 'oauth.scope.trips:read.description': 'Llegeix viatges, dies, notes i membres', 'oauth.scope.trips:write.label': 'Edita els viatges i itineraris', @@ -75,6 +76,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Crea, actualitza i elimina travesies i les seves entrades', 'oauth.scope.journey:share.label': 'Gestiona els enllaços de travesies', 'oauth.scope.journey:share.description': 'Crea, actualitza i revoca enllaços públics per compartir per a travesies', + 'oauth.scope.plugins:use.label': 'Usar eines de connectors', + 'oauth.scope.plugins:use.description': + 'Cridar eines afegides pels connectors instal·lats: cadascuna actua amb els permisos que un administrador ha concedit a aquell connector', 'oauth.authorize.authorizing': 'Autoritzant…', 'oauth.authorize.loading': 'Carregant…', 'oauth.authorize.errorTitle': "Error d'autorització", diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 80b1c51a48..1b0700faf6 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -330,6 +330,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Doručovat vaše oznámení dalším kanálem', 'admin.plugins.perm.events:subscribe': 'Reagovat na základní události aktivity (pouze název události a cesta, nikdy obsah)', + 'admin.plugins.perm.mcp:tools': 'Nabízet vlastní nástroje AI asistentům připojeným k TREKu přes MCP', 'admin.plugins.perm.http:outbound': 'Odesílat odchozí požadavky na deklarované hostitele', 'admin.plugins.perm.db:read:collab': 'Číst poznámky, ankety a zprávy chatu cest, ke kterým má aktuální uživatel přístup (vyžaduje doplněk Collab)', @@ -441,6 +442,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Doplňuje časy plánu', 'admin.plugins.cap.geolocation': 'Čte vaši polohu', 'admin.plugins.cap.events': 'Reaguje na aktivitu', + 'admin.plugins.cap.mcpTools': 'Přidává AI nástroje', 'admin.plugins.cap.requiresAddon': 'Vyžaduje {addon}', 'admin.plugins.cap.dependsOn': 'Vyžaduje {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Nejprve povolte potřebné doplňky: {addons}', diff --git a/shared/src/i18n/cs/oauth.ts b/shared/src/i18n/cs/oauth.ts index 5d456cb455..b1bcde9716 100644 --- a/shared/src/i18n/cs/oauth.ts +++ b/shared/src/i18n/cs/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Počasí', 'oauth.scope.group.journey': 'Cestovní deník', + 'oauth.scope.group.plugins': 'Pluginy', 'oauth.scope.trips:read.label': 'Zobrazit výlety a itineráře', 'oauth.scope.trips:read.description': 'Číst výlety, dny, poznámky a členy', 'oauth.scope.trips:write.label': 'Upravit výlety a itineráře', @@ -74,6 +75,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Vytvářet, aktualizovat a mazat cestovní deníky a jejich záznamy', 'oauth.scope.journey:share.label': 'Spravovat odkazy na cestovní deníky', 'oauth.scope.journey:share.description': 'Vytvářet, aktualizovat a rušit veřejné sdílené odkazy na cestovní deníky', + 'oauth.scope.plugins:use.label': 'Používat nástroje pluginů', + 'oauth.scope.plugins:use.description': + 'Volat nástroje přidané nainstalovanými pluginy — každý jedná s oprávněními, která tomuto pluginu udělil správce', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index a2d316d3ee..797f0fc023 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -338,6 +338,7 @@ const admin: TranslationStrings = { 'Auf Kern-Aktivitäts-Events reagieren (nur Event-Name + Reise, nie der Inhalt)', 'admin.plugins.perm.jobs:run': 'Deklarierte Hintergrund-Jobs zeitgesteuert ausführen (kein Nutzerkontext — kann keine Nutzerdaten lesen)', + 'admin.plugins.perm.mcp:tools': 'Eigene Tools für KI-Assistenten anbieten, die über MCP mit TREK verbunden sind', 'admin.plugins.perm.http:outbound': 'Ausgehende Anfragen an deklarierte Hosts stellen', 'admin.plugins.perm.db:read:collab': 'Notizen, Umfragen und Chat-Nachrichten von Reisen lesen, auf die der handelnde Nutzer Zugriff hat (benötigt das Collab-Addon)', @@ -450,6 +451,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Ergänzt Planzeiten', 'admin.plugins.cap.geolocation': 'Liest deinen Standort', 'admin.plugins.cap.events': 'Reagiert auf Aktivität', + 'admin.plugins.cap.mcpTools': 'Fügt KI-Tools hinzu', 'admin.plugins.cap.requiresAddon': 'Benötigt {addon}', 'admin.plugins.cap.dependsOn': 'Benötigt {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Aktiviere zuerst die benötigten Addons: {addons}', diff --git a/shared/src/i18n/de/oauth.ts b/shared/src/i18n/de/oauth.ts index aa875e99df..9bfa27b7dd 100644 --- a/shared/src/i18n/de/oauth.ts +++ b/shared/src/i18n/de/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Wetter', 'oauth.scope.group.journey': 'Journey', + 'oauth.scope.group.plugins': 'Plugins', 'oauth.scope.trips:read.label': 'Reisen und Reisepläne anzeigen', 'oauth.scope.trips:read.description': 'Reisen, Tage, Tagesnotizen und Mitglieder lesen', 'oauth.scope.trips:write.label': 'Reisen und Reisepläne bearbeiten', @@ -78,6 +79,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:share.label': 'Journey-Links verwalten', 'oauth.scope.journey:share.description': 'Öffentliche Freigabelinks für Journeys erstellen, aktualisieren und widerrufen', + 'oauth.scope.plugins:use.label': 'Plugin-Tools verwenden', + 'oauth.scope.plugins:use.description': + 'Tools aufrufen, die installierte Plugins bereitstellen — jedes handelt mit den Rechten, die ein Administrator diesem Plugin erteilt hat', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index e107cb8bd5..f02ef0fd86 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -374,6 +374,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.events:subscribe': 'React to core activity events (event name + trip only, never the content)', 'admin.plugins.perm.jobs:run': 'Run its declared background jobs on a schedule (no user context — cannot read user data)', + 'admin.plugins.perm.mcp:tools': 'Offer its own tools to AI assistants connected to TREK over MCP', 'admin.plugins.perm.http:outbound': 'Make outbound requests to its declared hosts', 'admin.plugins.perm.db:read:collab': 'Read notes, polls and chat messages of trips the acting user can access (needs the Collab addon)', @@ -485,6 +486,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Adds plan times', 'admin.plugins.cap.geolocation': 'Reads your position', 'admin.plugins.cap.events': 'Reacts to activity', + 'admin.plugins.cap.mcpTools': 'Adds AI tools', 'admin.plugins.cap.requiresAddon': 'Requires {addon}', 'admin.plugins.cap.dependsOn': 'Needs {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Enable the required addon(s) first: {addons}', diff --git a/shared/src/i18n/en/oauth.ts b/shared/src/i18n/en/oauth.ts index 8271153cbd..82b843c7eb 100644 --- a/shared/src/i18n/en/oauth.ts +++ b/shared/src/i18n/en/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Weather', 'oauth.scope.group.journey': 'Journey', + 'oauth.scope.group.plugins': 'Plugins', 'oauth.scope.trips:read.label': 'View trips & itineraries', 'oauth.scope.trips:read.description': 'Read trips, days, day notes, and members', 'oauth.scope.trips:write.label': 'Edit trips & itineraries', @@ -75,6 +76,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Create, update, and delete journeys and their entries', 'oauth.scope.journey:share.label': 'Manage journey links', 'oauth.scope.journey:share.description': 'Create, update, and revoke public share links for journeys', + 'oauth.scope.plugins:use.label': 'Use plugin tools', + 'oauth.scope.plugins:use.description': + 'Call tools added by installed TREK plugins — each one acts with the permissions an admin granted that plugin', 'oauth.authorize.authorizing': 'Authorizing…', 'oauth.authorize.loading': 'Loading…', 'oauth.authorize.errorTitle': 'Authorization Error', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index ec89cf7082..c40557ec43 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -340,6 +340,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Enviar tus notificaciones a través de un canal adicional', 'admin.plugins.perm.events:subscribe': 'Reaccionar a eventos de actividad del núcleo (solo el nombre del evento y el viaje, nunca el contenido)', + 'admin.plugins.perm.mcp:tools': 'Ofrecer sus propias herramientas a los asistentes de IA conectados a TREK por MCP', 'admin.plugins.perm.http:outbound': 'Realizar solicitudes salientes a sus hosts declarados', 'admin.plugins.perm.db:read:collab': 'Leer las notas, encuestas y mensajes de chat de los viajes a los que el usuario activo tiene acceso (requiere el complemento Collab)', @@ -452,6 +453,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Añade horarios al plan', 'admin.plugins.cap.geolocation': 'Lee tu ubicación', 'admin.plugins.cap.events': 'Reacciona a la actividad', + 'admin.plugins.cap.mcpTools': 'Añade herramientas de IA', 'admin.plugins.cap.requiresAddon': 'Requiere {addon}', 'admin.plugins.cap.dependsOn': 'Necesita {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Activa primero los complementos necesarios: {addons}', diff --git a/shared/src/i18n/es/oauth.ts b/shared/src/i18n/es/oauth.ts index 8794a2b2eb..89a380d412 100644 --- a/shared/src/i18n/es/oauth.ts +++ b/shared/src/i18n/es/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Clima', 'oauth.scope.group.journey': 'Travesía', + 'oauth.scope.group.plugins': 'Complementos', 'oauth.scope.trips:read.label': 'Ver viajes e itinerarios', 'oauth.scope.trips:read.description': 'Leer viajes, días, notas y miembros', 'oauth.scope.trips:write.label': 'Editar viajes e itinerarios', @@ -75,6 +76,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Crear, actualizar y eliminar travesías y sus entradas', 'oauth.scope.journey:share.label': 'Gestionar enlaces de travesías', 'oauth.scope.journey:share.description': 'Crear, actualizar y revocar enlaces públicos de compartir para travesías', + 'oauth.scope.plugins:use.label': 'Usar herramientas de complementos', + 'oauth.scope.plugins:use.description': + 'Llamar a herramientas añadidas por los complementos instalados: cada una actúa con los permisos que un administrador concedió a ese complemento', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 50a09fa7d6..c1bd2912c6 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -342,6 +342,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Envoyer vos notifications via un canal supplémentaire', 'admin.plugins.perm.events:subscribe': 'Réagir aux événements d’activité principaux (nom de l’événement et voyage uniquement, jamais le contenu)', + 'admin.plugins.perm.mcp:tools': 'Proposer ses propres outils aux assistants IA connectés à TREK via MCP', 'admin.plugins.perm.http:outbound': 'Effectuer des requêtes sortantes vers ses hôtes déclarés', 'admin.plugins.perm.db:read:collab': "Lire les notes, sondages et messages de chat des voyages accessibles à l'utilisateur actif (nécessite le module Collab)", @@ -455,6 +456,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Ajoute des horaires', 'admin.plugins.cap.geolocation': 'Lit votre position', 'admin.plugins.cap.events': 'Réagit à l’activité', + 'admin.plugins.cap.mcpTools': 'Ajoute des outils IA', 'admin.plugins.cap.requiresAddon': 'Nécessite {addon}', 'admin.plugins.cap.dependsOn': 'Requiert {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Activez d’abord le ou les modules requis : {addons}', diff --git a/shared/src/i18n/fr/oauth.ts b/shared/src/i18n/fr/oauth.ts index 645b6f537f..36cb2e5e4e 100644 --- a/shared/src/i18n/fr/oauth.ts +++ b/shared/src/i18n/fr/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Géo', 'oauth.scope.group.weather': 'Météo', 'oauth.scope.group.journey': 'Journal de voyage', + 'oauth.scope.group.plugins': 'Extensions', 'oauth.scope.trips:read.label': 'Voir les voyages et itinéraires', 'oauth.scope.trips:read.description': 'Lire les voyages, jours, notes et membres', 'oauth.scope.trips:write.label': 'Modifier les voyages et itinéraires', @@ -78,6 +79,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:share.label': 'Gérer les liens de journaux de voyage', 'oauth.scope.journey:share.description': 'Créer, modifier et révoquer des liens de partage publics pour les journaux de voyage', + 'oauth.scope.plugins:use.label': 'Utiliser les outils des extensions', + 'oauth.scope.plugins:use.description': + "Appeler les outils ajoutés par les extensions installées — chacun agit avec les permissions qu'un administrateur a accordées à cette extension", 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 772821f894..ce67d4e64b 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -390,6 +390,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Παράδοση των ειδοποιήσεών σας μέσω πρόσθετου καναλιού', 'admin.plugins.perm.events:subscribe': 'Αντιδρά σε βασικά συμβάντα δραστηριότητας (όνομα συμβάντος + ταξίδι μόνο, ποτέ το περιεχόμενο)', + 'admin.plugins.perm.mcp:tools': 'Παροχή δικών του εργαλείων σε βοηθούς AI συνδεδεμένους στο TREK μέσω MCP', 'admin.plugins.perm.http:outbound': 'Εκτέλεση εξερχόμενων αιτημάτων προς τους δηλωμένους hosts του', 'admin.plugins.perm.db:read:collab': 'Ανάγνωση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)', @@ -503,6 +504,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Προσθέτει χρόνους πλάνου', 'admin.plugins.cap.geolocation': 'Διαβάζει την τοποθεσία σας', 'admin.plugins.cap.events': 'Αντιδρά σε δραστηριότητα', + 'admin.plugins.cap.mcpTools': 'Προσθέτει εργαλεία AI', 'admin.plugins.cap.requiresAddon': 'Απαιτεί {addon}', 'admin.plugins.cap.dependsOn': 'Χρειάζεται {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Ενεργοποιήστε πρώτα τα απαιτούμενα πρόσθετα: {addons}', diff --git a/shared/src/i18n/gr/oauth.ts b/shared/src/i18n/gr/oauth.ts index 54dbcea48d..88ffab21b9 100644 --- a/shared/src/i18n/gr/oauth.ts +++ b/shared/src/i18n/gr/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Γεωγραφικά', 'oauth.scope.group.weather': 'Καιρός', 'oauth.scope.group.journey': 'Ταξίδι', + 'oauth.scope.group.plugins': 'Πρόσθετα', 'oauth.scope.trips:read.label': 'Προβολή ταξιδιών & δρομολογίων', 'oauth.scope.trips:read.description': 'Ανάγνωση ταξιδιών, ημερών, σημειώσεων και μελών', 'oauth.scope.trips:write.label': 'Επεξεργασία ταξιδιών & δρομολογίων', @@ -80,6 +81,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:share.label': 'Διαχείριση συνδέσμων ταξιδιών', 'oauth.scope.journey:share.description': 'Δημιουργία, ενημέρωση και ανάκληση δημόσιων συνδέσμων κοινής χρήσης για ταξίδια', + 'oauth.scope.plugins:use.label': 'Χρήση εργαλείων πρόσθετων', + 'oauth.scope.plugins:use.description': + 'Κλήση εργαλείων που προσθέτουν τα εγκατεστημένα πρόσθετα — καθένα ενεργεί με τα δικαιώματα που έδωσε ένας διαχειριστής σε αυτό το πρόσθετο', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 91bdf4eaea..32e22ecfa3 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -343,6 +343,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Értesítéseid kézbesítése egy további csatornán', 'admin.plugins.perm.events:subscribe': 'Reagál az alapvető tevékenységi eseményekre (csak eseménynév + utazás, soha nem a tartalom)', + 'admin.plugins.perm.mcp:tools': + 'Saját eszközök felkínálása a TREK-hez MCP-n keresztül csatlakozó MI-asszisztenseknek', 'admin.plugins.perm.http:outbound': 'Kimenő kérések küldése a bejelentett kiszolgálók felé', 'admin.plugins.perm.db:read:collab': 'Jegyzetek, szavazások és csevegőüzenetek olvasása az aktuális felhasználó számára elérhető utazásokon (a Collab bővítmény szükséges)', @@ -455,6 +457,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Időpontokat ad a tervhez', 'admin.plugins.cap.geolocation': 'Lekéri a tartózkodási helyedet', 'admin.plugins.cap.events': 'Reagál a tevékenységre', + 'admin.plugins.cap.mcpTools': 'MI-eszközöket ad hozzá', 'admin.plugins.cap.requiresAddon': '{addon} szükséges', 'admin.plugins.cap.dependsOn': '{id} {version} szükséges', 'admin.plugins.dep.addonDisabledToast': 'Előbb engedélyezze a szükséges bővítmény(eke)t: {addons}', diff --git a/shared/src/i18n/hu/oauth.ts b/shared/src/i18n/hu/oauth.ts index 1ae2caa305..117e821ea6 100644 --- a/shared/src/i18n/hu/oauth.ts +++ b/shared/src/i18n/hu/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Időjárás', 'oauth.scope.group.journey': 'Útinaplók', + 'oauth.scope.group.plugins': 'Bővítmények', 'oauth.scope.trips:read.label': 'Utazások és útvonalak megtekintése', 'oauth.scope.trips:read.description': 'Utazások, napok, napi feljegyzések és tagok olvasása', 'oauth.scope.trips:write.label': 'Utazások és útvonalak szerkesztése', @@ -78,6 +79,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:share.label': 'Útinapló-linkek kezelése', 'oauth.scope.journey:share.description': 'Nyilvános megosztási linkek létrehozása, frissítése és visszavonása útinaplókhoz', + 'oauth.scope.plugins:use.label': 'Bővítményeszközök használata', + 'oauth.scope.plugins:use.description': + 'A telepített bővítmények által hozzáadott eszközök hívása — mindegyik azokkal a jogokkal fut, amelyeket egy rendszergazda az adott bővítménynek adott', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 299f6a14ab..6b932ff4d8 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -385,6 +385,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Mengirim notifikasi Anda melalui saluran tambahan', 'admin.plugins.perm.events:subscribe': 'Bereaksi terhadap peristiwa aktivitas inti (hanya nama peristiwa + perjalanan, tidak pernah kontennya)', + 'admin.plugins.perm.mcp:tools': 'Menawarkan alatnya sendiri ke asisten AI yang terhubung ke TREK melalui MCP', 'admin.plugins.perm.http:outbound': 'Membuat permintaan keluar ke host yang telah dideklarasikannya', 'admin.plugins.perm.db:read:collab': 'Membaca catatan, jajak pendapat, dan pesan obrolan dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan (memerlukan add-on Collab)', @@ -497,6 +498,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Menambahkan waktu rencana', 'admin.plugins.cap.geolocation': 'Membaca lokasimu', 'admin.plugins.cap.events': 'Bereaksi terhadap aktivitas', + 'admin.plugins.cap.mcpTools': 'Menambah alat AI', 'admin.plugins.cap.requiresAddon': 'Membutuhkan {addon}', 'admin.plugins.cap.dependsOn': 'Membutuhkan {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Aktifkan dulu addon yang diperlukan: {addons}', diff --git a/shared/src/i18n/id/oauth.ts b/shared/src/i18n/id/oauth.ts index 0e2918e1ba..e1aeace64a 100644 --- a/shared/src/i18n/id/oauth.ts +++ b/shared/src/i18n/id/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Cuaca', 'oauth.scope.group.journey': 'Journey', + 'oauth.scope.group.plugins': 'Plugin', 'oauth.scope.trips:read.label': 'Lihat perjalanan & itinerari', 'oauth.scope.trips:read.description': 'Baca perjalanan, hari, catatan harian, dan anggota', 'oauth.scope.trips:write.label': 'Edit perjalanan & itinerari', @@ -75,6 +76,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Buat, perbarui, dan hapus Journey beserta entrinya', 'oauth.scope.journey:share.label': 'Kelola tautan Journey', 'oauth.scope.journey:share.description': 'Buat, perbarui, dan cabut tautan berbagi publik untuk Journey', + 'oauth.scope.plugins:use.label': 'Gunakan alat plugin', + 'oauth.scope.plugins:use.description': + 'Memanggil alat yang ditambahkan plugin terpasang — masing-masing bertindak dengan izin yang diberikan admin ke plugin tersebut', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 70ddfa5f52..a57d339fc0 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -338,6 +338,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Inviare le tue notifiche tramite un canale aggiuntivo', 'admin.plugins.perm.events:subscribe': "Reagisce agli eventi principali dell'attività (solo nome evento e viaggio, mai il contenuto)", + 'admin.plugins.perm.mcp:tools': 'Offrire i propri strumenti agli assistenti IA collegati a TREK tramite MCP', 'admin.plugins.perm.http:outbound': 'Effettuare richieste in uscita verso gli host dichiarati', 'admin.plugins.perm.db:read:collab': "Leggere note, sondaggi e messaggi di chat dei viaggi a cui l'utente attivo ha accesso (richiede il componente aggiuntivo Collab)", @@ -450,6 +451,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Aggiunge orari al piano', 'admin.plugins.cap.geolocation': 'Legge la tua posizione', 'admin.plugins.cap.events': "Reagisce all'attività", + 'admin.plugins.cap.mcpTools': 'Aggiunge strumenti IA', 'admin.plugins.cap.requiresAddon': 'Richiede {addon}', 'admin.plugins.cap.dependsOn': 'Richiede {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Abilita prima gli addon richiesti: {addons}', diff --git a/shared/src/i18n/it/oauth.ts b/shared/src/i18n/it/oauth.ts index 3f730e874c..f07aa995c7 100644 --- a/shared/src/i18n/it/oauth.ts +++ b/shared/src/i18n/it/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Meteo', 'oauth.scope.group.journey': 'Diario di viaggio', + 'oauth.scope.group.plugins': 'Plugin', 'oauth.scope.trips:read.label': 'Visualizza viaggi e itinerari', 'oauth.scope.trips:read.description': 'Leggi viaggi, giorni, note giornaliere e membri', 'oauth.scope.trips:write.label': 'Modifica viaggi e itinerari', @@ -76,6 +77,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:share.label': 'Gestisci link diari di viaggio', 'oauth.scope.journey:share.description': 'Crea, aggiorna e revoca link di condivisione pubblici per i diari di viaggio', + 'oauth.scope.plugins:use.label': 'Usare gli strumenti dei plugin', + 'oauth.scope.plugins:use.description': + 'Richiamare gli strumenti aggiunti dai plugin installati: ognuno agisce con i permessi che un amministratore ha concesso a quel plugin', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index e19384ce61..66274b22a9 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -352,6 +352,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': '追加のチャンネルで通知を配信する', 'admin.plugins.perm.events:subscribe': 'コアのアクティビティイベントに反応(イベント名と旅程のみで、内容には一切アクセスしません)', + 'admin.plugins.perm.mcp:tools': 'MCP で TREK に接続した AI アシスタントに独自のツールを提供する', 'admin.plugins.perm.http:outbound': '宣言済みホストへの外部リクエストの送信', 'admin.plugins.perm.db:read:collab': '操作中のユーザーがアクセスできる旅行のメモ・アンケート・チャットメッセージの読み取り(Collab アドオンが必要)', @@ -463,6 +464,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': '予定時刻を追加', 'admin.plugins.cap.geolocation': '現在地を読み取り', 'admin.plugins.cap.events': 'アクティビティに反応', + 'admin.plugins.cap.mcpTools': 'AI ツールを追加', 'admin.plugins.cap.requiresAddon': '{addon}が必要', 'admin.plugins.cap.dependsOn': '{id} {version}が必要', 'admin.plugins.dep.addonDisabledToast': '先に必要なアドオンを有効にしてください:{addons}', diff --git a/shared/src/i18n/ja/oauth.ts b/shared/src/i18n/ja/oauth.ts index cf4d8dfcac..05b480c054 100644 --- a/shared/src/i18n/ja/oauth.ts +++ b/shared/src/i18n/ja/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': '地図', 'oauth.scope.group.weather': '天気', 'oauth.scope.group.journey': '日記', + 'oauth.scope.group.plugins': 'プラグイン', 'oauth.scope.trips:read.label': '旅行・旅程を表示', 'oauth.scope.trips:read.description': '旅行、日程、メモ、メンバーを閲覧', 'oauth.scope.trips:write.label': '旅行・旅程を編集', @@ -75,6 +76,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': '日記やエントリーの作成・編集・削除', 'oauth.scope.journey:share.label': '日記共有を管理', 'oauth.scope.journey:share.description': '公開共有リンクの作成・更新・無効化', + 'oauth.scope.plugins:use.label': 'プラグインのツールを使用', + 'oauth.scope.plugins:use.description': + 'インストール済みプラグインが追加したツールを呼び出します。各ツールは管理者がそのプラグインに与えた権限の範囲で動作します', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 927638ad5c..f5113dd630 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -352,6 +352,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:trip-card-provider': '대시보드 여행 카드에 작은 배지(상태, 개수) 추가', 'admin.plugins.perm.hook:notification-channel': '추가 채널을 통해 알림을 전달합니다', 'admin.plugins.perm.events:subscribe': '핵심 활동 이벤트에 반응 (이벤트 이름과 여행만, 내용은 절대 표시 안 함)', + 'admin.plugins.perm.mcp:tools': 'MCP로 TREK에 연결된 AI 어시스턴트에 자체 도구 제공', 'admin.plugins.perm.http:outbound': '선언된 호스트로 아웃바운드 요청 전송', 'admin.plugins.perm.db:read:collab': '현재 사용자가 접근할 수 있는 여행의 메모, 투표 및 채팅 메시지 읽기(Collab 애드온 필요)', @@ -463,6 +464,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': '계획 시간 추가', 'admin.plugins.cap.geolocation': '위치 읽기', 'admin.plugins.cap.events': '활동에 반응', + 'admin.plugins.cap.mcpTools': 'AI 도구 추가', 'admin.plugins.cap.requiresAddon': '{addon} 필요', 'admin.plugins.cap.dependsOn': '{id} {version} 필요', 'admin.plugins.dep.addonDisabledToast': '필요한 애드온을 먼저 활성화하세요: {addons}', diff --git a/shared/src/i18n/ko/oauth.ts b/shared/src/i18n/ko/oauth.ts index 356810d386..b3acb66823 100644 --- a/shared/src/i18n/ko/oauth.ts +++ b/shared/src/i18n/ko/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': '지리', 'oauth.scope.group.weather': '날씨', 'oauth.scope.group.journey': 'Journey', + 'oauth.scope.group.plugins': '플러그인', 'oauth.scope.trips:read.label': '여행 및 일정 보기', 'oauth.scope.trips:read.description': '여행, 날, 일별 메모, 멤버 읽기', 'oauth.scope.trips:write.label': '여행 및 일정 편집', @@ -73,6 +74,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Journey 및 항목 만들기, 업데이트, 삭제', 'oauth.scope.journey:share.label': 'Journey 링크 관리', 'oauth.scope.journey:share.description': 'Journey의 공개 공유 링크 만들기, 업데이트, 취소', + 'oauth.scope.plugins:use.label': '플러그인 도구 사용', + 'oauth.scope.plugins:use.description': + '설치된 플러그인이 추가한 도구를 호출합니다. 각 도구는 관리자가 해당 플러그인에 부여한 권한으로 동작합니다', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 40083b4755..0d99eb4b33 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -337,6 +337,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Je meldingen via een extra kanaal bezorgen', 'admin.plugins.perm.events:subscribe': 'Reageert op kernactiviteitsgebeurtenissen (alleen gebeurtenisnaam + reis, nooit de inhoud)', + 'admin.plugins.perm.mcp:tools': 'Eigen tools aanbieden aan AI-assistenten die via MCP met TREK zijn verbonden', 'admin.plugins.perm.http:outbound': 'Uitgaande verzoeken doen naar de opgegeven hosts', 'admin.plugins.perm.db:read:collab': 'Notities, peilingen en chatberichten lezen van reizen waartoe de actieve gebruiker toegang heeft (vereist de Collab-add-on)', @@ -449,6 +450,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Voegt plantijden toe', 'admin.plugins.cap.geolocation': 'Leest je locatie', 'admin.plugins.cap.events': 'Reageert op activiteit', + 'admin.plugins.cap.mcpTools': 'Voegt AI-tools toe', 'admin.plugins.cap.requiresAddon': 'Vereist {addon}', 'admin.plugins.cap.dependsOn': 'Vereist {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Schakel eerst de vereiste add-on(s) in: {addons}', diff --git a/shared/src/i18n/nl/oauth.ts b/shared/src/i18n/nl/oauth.ts index 976ef00069..8edd213cef 100644 --- a/shared/src/i18n/nl/oauth.ts +++ b/shared/src/i18n/nl/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Weer', 'oauth.scope.group.journey': 'Reisverslag', + 'oauth.scope.group.plugins': 'Plug-ins', 'oauth.scope.trips:read.label': 'Reizen en reisplannen bekijken', 'oauth.scope.trips:read.description': 'Reizen, dagen, notities en leden lezen', 'oauth.scope.trips:write.label': 'Reizen en reisplannen bewerken', @@ -77,6 +78,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Reisverslagen en hun vermeldingen aanmaken, bijwerken en verwijderen', 'oauth.scope.journey:share.label': 'Reisverslag-links beheren', 'oauth.scope.journey:share.description': 'Publieke deellinks voor reisverslagen aanmaken, bijwerken en intrekken', + 'oauth.scope.plugins:use.label': 'Plug-intools gebruiken', + 'oauth.scope.plugins:use.description': + 'Tools aanroepen die geïnstalleerde plug-ins toevoegen — elke tool handelt met de rechten die een beheerder aan die plug-in heeft gegeven', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 314fe34770..de01dfb821 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -334,6 +334,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Dostarczać Twoje powiadomienia dodatkowym kanałem', 'admin.plugins.perm.events:subscribe': 'Reagowanie na podstawowe zdarzenia aktywności (tylko nazwa zdarzenia i podróż, nigdy treść)', + 'admin.plugins.perm.mcp:tools': 'Udostępnianie własnych narzędzi asystentom AI połączonym z TREK przez MCP', 'admin.plugins.perm.http:outbound': 'Wykonywanie wychodzących zapytań do zadeklarowanych hostów', 'admin.plugins.perm.db:read:collab': 'Odczyt notatek, ankiet i wiadomości czatu w podróżach, do których działający użytkownik ma dostęp (wymaga dodatku Collab)', @@ -447,6 +448,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Uzupełnia czasy planu', 'admin.plugins.cap.geolocation': 'Odczytuje Twoją lokalizację', 'admin.plugins.cap.events': 'Reaguje na aktywność', + 'admin.plugins.cap.mcpTools': 'Dodaje narzędzia AI', 'admin.plugins.cap.requiresAddon': 'Wymaga {addon}', 'admin.plugins.cap.dependsOn': 'Wymaga {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Najpierw włącz wymagane dodatki: {addons}', diff --git a/shared/src/i18n/pl/oauth.ts b/shared/src/i18n/pl/oauth.ts index 5481512e5a..f6052bb426 100644 --- a/shared/src/i18n/pl/oauth.ts +++ b/shared/src/i18n/pl/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Pogoda', 'oauth.scope.group.journey': 'Dziennik podróży', + 'oauth.scope.group.plugins': 'Wtyczki', 'oauth.scope.trips:read.label': 'Przeglądaj podróże i itineraria', 'oauth.scope.trips:read.description': 'Odczytuj podróże, dni, notatki i członków', 'oauth.scope.trips:write.label': 'Edytuj podróże i itineraria', @@ -76,6 +77,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:share.label': 'Zarządzaj linkami dzienników podróży', 'oauth.scope.journey:share.description': 'Twórz, aktualizuj i unieważniaj publiczne linki udostępniania dzienników podróży', + 'oauth.scope.plugins:use.label': 'Używanie narzędzi wtyczek', + 'oauth.scope.plugins:use.description': + 'Wywoływanie narzędzi dodanych przez zainstalowane wtyczki — każde działa z uprawnieniami, które administrator przyznał tej wtyczce', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index de0976dcb2..d96b5be40f 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -339,6 +339,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Доставлять ваши уведомления через дополнительный канал', 'admin.plugins.perm.events:subscribe': 'Реагировать на основные события активности (только название события и поездка, никогда содержимое)', + 'admin.plugins.perm.mcp:tools': 'Предоставлять свои инструменты ИИ-ассистентам, подключённым к TREK по MCP', 'admin.plugins.perm.http:outbound': 'Выполнять исходящие запросы к заявленным хостам', 'admin.plugins.perm.db:read:collab': 'Читать заметки, опросы и сообщения чата поездок, доступных текущему пользователю (требуется дополнение Collab)', @@ -451,6 +452,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Добавляет время в план', 'admin.plugins.cap.geolocation': 'Считывает ваше местоположение', 'admin.plugins.cap.events': 'Реагирует на активность', + 'admin.plugins.cap.mcpTools': 'Добавляет ИИ-инструменты', 'admin.plugins.cap.requiresAddon': 'Требуется {addon}', 'admin.plugins.cap.dependsOn': 'Нужен {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Сначала включите необходимые дополнения: {addons}', diff --git a/shared/src/i18n/ru/oauth.ts b/shared/src/i18n/ru/oauth.ts index 9e71181ce9..fff41b0a00 100644 --- a/shared/src/i18n/ru/oauth.ts +++ b/shared/src/i18n/ru/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Погода', 'oauth.scope.group.journey': 'Путешествия', + 'oauth.scope.group.plugins': 'Плагины', 'oauth.scope.trips:read.label': 'Просмотр поездок и маршрутов', 'oauth.scope.trips:read.description': 'Чтение поездок, дней, заметок и участников', 'oauth.scope.trips:write.label': 'Редактирование поездок и маршрутов', @@ -76,6 +77,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Создание, обновление и удаление путешествий и их записей', 'oauth.scope.journey:share.label': 'Управление ссылками на путешествия', 'oauth.scope.journey:share.description': 'Создание, обновление и отзыв публичных ссылок для путешествий', + 'oauth.scope.plugins:use.label': 'Использование инструментов плагинов', + 'oauth.scope.plugins:use.description': + 'Вызов инструментов, добавленных установленными плагинами, — каждый действует с правами, которые администратор выдал этому плагину', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 04c77b23fa..24746abc14 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -392,6 +392,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Leverera dina aviseringar via en ytterligare kanal', 'admin.plugins.perm.events:subscribe': 'Reagera på grundläggande aktivitetshändelser (endast händelsenamn + resa, aldrig innehållet)', + 'admin.plugins.perm.mcp:tools': 'Erbjuda egna verktyg till AI-assistenter som är anslutna till TREK via MCP', 'admin.plugins.perm.http:outbound': 'Göra utgående anrop till sina deklarerade värdar', 'admin.plugins.perm.db:read:collab': 'Läsa anteckningar, omröstningar och chattmeddelanden för resor som den aktiva användaren har åtkomst till (kräver tillägget Collab)', @@ -505,6 +506,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Lägger till tider i planen', 'admin.plugins.cap.geolocation': 'Läser din position', 'admin.plugins.cap.events': 'Reagerar på aktivitet', + 'admin.plugins.cap.mcpTools': 'Lägger till AI-verktyg', 'admin.plugins.cap.requiresAddon': 'Kräver {addon}', 'admin.plugins.cap.dependsOn': 'Kräver {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Aktivera de nödvändiga tilläggen först: {addons}', diff --git a/shared/src/i18n/sv/oauth.ts b/shared/src/i18n/sv/oauth.ts index 614093af6b..50129be235 100644 --- a/shared/src/i18n/sv/oauth.ts +++ b/shared/src/i18n/sv/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Väder', 'oauth.scope.group.journey': 'Journey', + 'oauth.scope.group.plugins': 'Tillägg', 'oauth.scope.trips:read.label': 'Visa resor och resplaner', 'oauth.scope.trips:read.description': 'Läs om resor, dagar, daganteckningar och medlemmar', 'oauth.scope.trips:write.label': 'Redigera resor och resplaner', @@ -80,6 +81,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Skapa, uppdatera och ta bort journeys och deras poster', 'oauth.scope.journey:share.label': 'Hantera journey länkar', 'oauth.scope.journey:share.description': 'Skapa, uppdatera och återkalla offentliga delningslänkar för journeys', + 'oauth.scope.plugins:use.label': 'Använda tilläggens verktyg', + 'oauth.scope.plugins:use.description': + 'Anropa verktyg som installerade tillägg lägger till — varje verktyg agerar med de rättigheter en administratör gav det tillägget', 'oauth.authorize.authorizing': 'Autentiserar…', 'oauth.authorize.loading': 'Laddar…', 'oauth.authorize.errorTitle': 'Auktoriseringsfel', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 898e183978..43820307e5 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -382,6 +382,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Bildirimlerinizi ek bir kanal üzerinden iletmek', 'admin.plugins.perm.events:subscribe': 'Temel etkinlik olaylarına tepki verir (yalnızca olay adı + seyahat, asla içerik değil)', + 'admin.plugins.perm.mcp:tools': "TREK'e MCP üzerinden bağlanan yapay zekâ asistanlarına kendi araçlarını sunma", 'admin.plugins.perm.http:outbound': 'Bildirdiği ana bilgisayarlara giden istekler yapar', 'admin.plugins.perm.db:read:collab': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin notlarını, anketlerini ve sohbet mesajlarını okur (Collab eklentisi gerektirir)', @@ -494,6 +495,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Plana zaman ekler', 'admin.plugins.cap.geolocation': 'Konumunu okur', 'admin.plugins.cap.events': 'Etkinliğe tepki verir', + 'admin.plugins.cap.mcpTools': 'Yapay zekâ aracı ekler', 'admin.plugins.cap.requiresAddon': '{addon} gerekir', 'admin.plugins.cap.dependsOn': '{id} {version} gerekir', 'admin.plugins.dep.addonDisabledToast': 'Önce gerekli eklentileri etkinleştirin: {addons}', diff --git a/shared/src/i18n/tr/oauth.ts b/shared/src/i18n/tr/oauth.ts index 29216f452d..a475579fee 100644 --- a/shared/src/i18n/tr/oauth.ts +++ b/shared/src/i18n/tr/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Coğrafi', 'oauth.scope.group.weather': 'Hava durumu', 'oauth.scope.group.journey': 'Seyahat', + 'oauth.scope.group.plugins': 'Eklentiler', 'oauth.scope.trips:read.label': 'Seyahatleri ve programları görüntüle', 'oauth.scope.trips:read.description': 'Seyahatleri, günleri, gün notlarını ve üyeleri oku', 'oauth.scope.trips:write.label': 'Seyahatleri ve programları düzenle', @@ -79,6 +80,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:share.label': 'Journey bağlantılarını yönet', 'oauth.scope.journey:share.description': "Journey'ler için herkese açık paylaşım bağlantıları oluştur, güncelle ve iptal et", + 'oauth.scope.plugins:use.label': 'Eklenti araçlarını kullan', + 'oauth.scope.plugins:use.description': + 'Kurulu eklentilerin eklediği araçları çağırır — her biri, bir yöneticinin o eklentiye verdiği izinlerle çalışır', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 32e9af8e36..817b25be03 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -336,6 +336,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Доставляти ваші сповіщення через додатковий канал', 'admin.plugins.perm.events:subscribe': 'Реагувати на основні події активності (лише назва події + подорож, ніколи вміст)', + 'admin.plugins.perm.mcp:tools': 'Надавати власні інструменти ШІ-асистентам, підключеним до TREK через MCP', 'admin.plugins.perm.http:outbound': 'Виконувати вихідні запити до заявлених хостів', 'admin.plugins.perm.db:read:collab': 'Читати нотатки, опитування та повідомлення чату подорожей, до яких має доступ поточний користувач (потрібен додаток Collab)', @@ -448,6 +449,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Додає часи до плану', 'admin.plugins.cap.geolocation': 'Читає ваше місцезнаходження', 'admin.plugins.cap.events': 'Реагує на активність', + 'admin.plugins.cap.mcpTools': 'Додає ШІ-інструменти', 'admin.plugins.cap.requiresAddon': 'Потрібен {addon}', 'admin.plugins.cap.dependsOn': 'Потрібен {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Спершу увімкніть потрібні доповнення: {addons}', diff --git a/shared/src/i18n/uk/oauth.ts b/shared/src/i18n/uk/oauth.ts index ed4109f1b1..32df94e086 100644 --- a/shared/src/i18n/uk/oauth.ts +++ b/shared/src/i18n/uk/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Погода', 'oauth.scope.group.journey': 'Подорожі', + 'oauth.scope.group.plugins': 'Плагіни', 'oauth.scope.trips:read.label': 'Перегляд поїздок і маршрутів', 'oauth.scope.trips:read.description': 'Читання поїздок, днів, нотаток і учасників', 'oauth.scope.trips:write.label': 'Редагування поїздок і маршрутів', @@ -75,6 +76,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Створення, оновлення і видалення подорожей та їх записів', 'oauth.scope.journey:share.label': 'Керування посиланнями на подорожі', 'oauth.scope.journey:share.description': 'Створення, оновлення і відкликання публічних посилань на подорожі', + 'oauth.scope.plugins:use.label': 'Використання інструментів плагінів', + 'oauth.scope.plugins:use.description': + 'Виклик інструментів, доданих встановленими плагінами, — кожен діє з правами, які адміністратор надав цьому плагіну', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 12fc0e7970..37cad82113 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -335,6 +335,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:notification-channel': 'Gửi thông báo của bạn qua một kênh bổ sung', 'admin.plugins.perm.events:subscribe': 'Phản hồi các sự kiện hoạt động cốt lõi (chỉ tên sự kiện + chuyến đi, không bao giờ là nội dung)', + 'admin.plugins.perm.mcp:tools': 'Cung cấp công cụ riêng cho trợ lý AI kết nối với TREK qua MCP', 'admin.plugins.perm.http:outbound': 'Gửi yêu cầu ra ngoài đến các máy chủ đã khai báo', 'admin.plugins.perm.db:read:collab': 'Đọc ghi chú, bình chọn và tin nhắn trò chuyện của các chuyến đi mà người dùng hiện tại có quyền truy cập (cần add-on Collab)', @@ -447,6 +448,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': 'Thêm giờ kế hoạch', 'admin.plugins.cap.geolocation': 'Đọc vị trí của bạn', 'admin.plugins.cap.events': 'Phản hồi hoạt động', + 'admin.plugins.cap.mcpTools': 'Thêm công cụ AI', 'admin.plugins.cap.requiresAddon': 'Cần {addon}', 'admin.plugins.cap.dependsOn': 'Cần {id} {version}', 'admin.plugins.dep.addonDisabledToast': 'Trước tiên hãy bật các tiện ích bổ sung cần thiết: {addons}', diff --git a/shared/src/i18n/vi/oauth.ts b/shared/src/i18n/vi/oauth.ts index 410e5e28ec..d4fbe9fdf9 100644 --- a/shared/src/i18n/vi/oauth.ts +++ b/shared/src/i18n/vi/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'địa lý', 'oauth.scope.group.weather': 'Thời tiết', 'oauth.scope.group.journey': 'Hành trình', + 'oauth.scope.group.plugins': 'Tiện ích', 'oauth.scope.trips:read.label': 'Xem chuyến đi & hành trình', 'oauth.scope.trips:read.description': 'Đọc các chuyến đi, ngày, ghi chú trong ngày và các thành viên', 'oauth.scope.trips:write.label': 'Chỉnh sửa chuyến đi và hành trình', @@ -76,6 +77,9 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': 'Tạo, cập nhật và xóa hành trình cũng như các mục nhập của chúng', 'oauth.scope.journey:share.label': 'Quản lý liên kết hành trình', 'oauth.scope.journey:share.description': 'Tạo, cập nhật và thu hồi liên kết chia sẻ công khai cho hành trình', + 'oauth.scope.plugins:use.label': 'Dùng công cụ của tiện ích', + 'oauth.scope.plugins:use.description': + 'Gọi các công cụ do tiện ích đã cài thêm vào — mỗi công cụ hoạt động với quyền mà quản trị viên đã cấp cho tiện ích đó', 'oauth.authorize.authorizing': 'Đang ủy quyền…', 'oauth.authorize.loading': 'Đang tải…', 'oauth.authorize.errorTitle': 'Lỗi ủy quyền', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 148e35acc4..a27965a593 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -337,6 +337,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:trip-card-provider': '在儀表板的行程卡片上加入小徽章(狀態、計數)', 'admin.plugins.perm.hook:notification-channel': '透過額外的管道傳送你的通知', 'admin.plugins.perm.events:subscribe': '回應核心活動事件(僅事件名稱與行程,絕不包含內容)', + 'admin.plugins.perm.mcp:tools': '向透過 MCP 連線至 TREK 的 AI 助理提供自己的工具', 'admin.plugins.perm.http:outbound': '向其宣告的主機發出對外請求', 'admin.plugins.perm.db:read:collab': '讀取操作使用者可存取之行程的筆記、投票及聊天訊息(需要 Collab 附加元件)', 'admin.plugins.perm.db:read:files:content': '讀取操作使用者可存取之行程的檔案內容(位元組)', @@ -445,6 +446,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': '新增行程時間', 'admin.plugins.cap.geolocation': '讀取你的位置', 'admin.plugins.cap.events': '回應活動', + 'admin.plugins.cap.mcpTools': '新增 AI 工具', 'admin.plugins.cap.requiresAddon': '需要 {addon}', 'admin.plugins.cap.dependsOn': '需要 {id} {version}', 'admin.plugins.dep.addonDisabledToast': '請先啟用所需的外掛模組:{addons}', diff --git a/shared/src/i18n/zh-TW/oauth.ts b/shared/src/i18n/zh-TW/oauth.ts index 81b2eebd16..1b0ccac336 100644 --- a/shared/src/i18n/zh-TW/oauth.ts +++ b/shared/src/i18n/zh-TW/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': '天氣', 'oauth.scope.group.journey': '旅程', + 'oauth.scope.group.plugins': '外掛', 'oauth.scope.trips:read.label': '檢視行程與旅遊計畫', 'oauth.scope.trips:read.description': '讀取行程、天數、每日筆記及成員', 'oauth.scope.trips:write.label': '編輯行程與旅遊計畫', @@ -73,6 +74,8 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': '建立、更新及刪除旅程及其條目', 'oauth.scope.journey:share.label': '管理旅程連結', 'oauth.scope.journey:share.description': '建立、更新及撤銷旅程的公開分享連結', + 'oauth.scope.plugins:use.label': '使用外掛工具', + 'oauth.scope.plugins:use.description': '呼叫已安裝外掛新增的工具——每個工具都以管理員授予該外掛的權限執行', 'oauth.authorize.authorizing': 'Authorizing…', // en-fallback 'oauth.authorize.loading': 'Loading…', // en-fallback 'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index a883a17cce..d000c2a86e 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -295,6 +295,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:trip-card-provider': '在仪表盘的行程卡片上添加小徽章(状态、计数)', 'admin.plugins.perm.hook:notification-channel': '通过额外的渠道发送你的通知', 'admin.plugins.perm.events:subscribe': '响应核心活动事件(仅事件名称和行程,绝不包含内容)', + 'admin.plugins.perm.mcp:tools': '向通过 MCP 连接到 TREK 的 AI 助手提供自己的工具', 'admin.plugins.perm.http:outbound': '向其声明的主机发出出站请求', 'admin.plugins.perm.db:read:collab': '读取当前用户可访问的旅行的笔记、投票和聊天消息(需要 Collab 插件)', 'admin.plugins.perm.db:read:files:content': '读取当前用户可访问的旅行的文件内容(字节)', @@ -405,6 +406,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.daySchedule': '补充计划时间', 'admin.plugins.cap.geolocation': '读取你的位置', 'admin.plugins.cap.events': '响应活动', + 'admin.plugins.cap.mcpTools': '添加 AI 工具', 'admin.plugins.cap.requiresAddon': '需要 {addon}', 'admin.plugins.cap.dependsOn': '需要 {id} {version}', 'admin.plugins.dep.addonDisabledToast': '请先启用所需的插件模块:{addons}', diff --git a/shared/src/i18n/zh/oauth.ts b/shared/src/i18n/zh/oauth.ts index e1338aeecf..7fea2d7248 100644 --- a/shared/src/i18n/zh/oauth.ts +++ b/shared/src/i18n/zh/oauth.ts @@ -15,6 +15,7 @@ const oauth: TranslationStrings = { 'oauth.scope.group.geo': '地图服务', 'oauth.scope.group.weather': '天气', 'oauth.scope.group.journey': '旅程', + 'oauth.scope.group.plugins': '插件', 'oauth.scope.trips:read.label': '查看行程和行程计划', 'oauth.scope.trips:read.description': '读取行程、天数、每日笔记和成员', 'oauth.scope.trips:write.label': '编辑行程和行程计划', @@ -73,6 +74,8 @@ const oauth: TranslationStrings = { 'oauth.scope.journey:write.description': '创建、更新和删除旅程及其条目', 'oauth.scope.journey:share.label': '管理旅程链接', 'oauth.scope.journey:share.description': '创建、更新和撤销旅程的公开分享链接', + 'oauth.scope.plugins:use.label': '使用插件工具', + 'oauth.scope.plugins:use.description': '调用已安装插件添加的工具——每个工具都以管理员授予该插件的权限运行', 'oauth.authorize.authorizing': '正在授权…', 'oauth.authorize.loading': '正在加载…', 'oauth.authorize.errorTitle': '授权错误', diff --git a/wiki/MCP-Scopes.md b/wiki/MCP-Scopes.md index 3f48f03f99..60e96bafb2 100644 --- a/wiki/MCP-Scopes.md +++ b/wiki/MCP-Scopes.md @@ -6,7 +6,7 @@ OAuth scopes control exactly which data your AI client can read or write in TREK ## All scopes -TREK defines 27 scopes across 13 groups. +TREK defines 30 scopes across 15 groups. | Group | Scope | Permission | |---|---|---| @@ -37,6 +37,7 @@ TREK defines 27 scopes across 13 groups. | **Journey** | `journey:read` | Read journeys, entries, and contributor list | | | `journey:write` | Create, update, and delete journeys and their entries | | | `journey:share` | Create, update, and revoke public share links for journeys | +| **Plugins** | `plugins:use` | Call tools added by installed TREK plugins | ## Scope rules @@ -46,6 +47,7 @@ TREK defines 27 scopes across 13 groups. - `list_trips` and `get_trip_summary` are always available regardless of scope — they are navigation tools. - Static tokens and web session JWTs have full access equivalent to all scopes. - Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the corresponding addon to be enabled by an admin. +- `plugins:use` covers every plugin-provided tool at once (they are namespaced `plugin__`). It is consent to reach plugin tools at all, not a per-plugin permission: what one can actually touch is bounded by the permissions an admin granted that plugin, acting as your user. See [MCP-Tools-and-Resources](MCP-Tools-and-Resources#plugin-tools). ## Choosing the right scopes diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 7ff8fec2d2..b70d63f44b 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -177,6 +177,28 @@ Requires `notifications:read` or `notifications:write` scope. --- +## Plugin tools + +Installed plugins can add tools of their own. They appear alongside the built-ins, +namespaced `plugin__` (e.g. `plugin_trip-doctor_check_visa`), and +are described by the plugin author — read the tool's own description to know what it does. + +Three things have to line up for one to show up: + +- the plugin is **installed and active**, and an admin granted it `mcp:tools`; +- your token carries the **`plugins:use`** scope (a full-access token has it); +- the plugin system is enabled on the instance. + +A plugin tool runs **as you**: it can only reach what that plugin was granted, acting +with your own trip memberships and permissions. A tool that fails, times out (30 s) or +belongs to a plugin that has since been turned off comes back as a normal tool error. +Turning a plugin on or off invalidates open MCP sessions, so reconnect to pick up the +new tool list. + +See [Plugin-Development](Plugin-Development#mcp-tools) to write one. + +--- + ## Resources Resources provide read-only access via `trek://` URIs. Read them to understand current state before making changes. diff --git a/wiki/Plugin-Cookbook.md b/wiki/Plugin-Cookbook.md index 65edc135d2..9c11dfb9c9 100644 --- a/wiki/Plugin-Cookbook.md +++ b/wiki/Plugin-Cookbook.md @@ -318,6 +318,56 @@ Output is **data**. To store it, push it through a gated write yourself (e.g. `c --- +## Give an AI assistant a tool + +**Needs:** `mcp:tools` (plus whatever the tool itself uses — here `http:outbound`) + +The other side of `ai:invoke`: instead of *you* calling a model, a model connected to +TREK over MCP calls *you*. Declare the tools; the assistant decides when to reach for them. + +```js +module.exports = definePlugin({ + mcpTools: [{ + name: 'check_visa', + description: 'Look up whether a passport holder needs a visa for a country. ' + + 'Use before adding international travel to a trip.', + inputSchema: { + type: 'object', + properties: { + passport: { type: 'string', description: 'ISO-3166 alpha-2, e.g. "UY"' }, + destination: { type: 'string', description: 'ISO-3166 alpha-2, e.g. "JP"' }, + }, + required: ['passport', 'destination'], + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + async handler(input, ctx) { + const { passport, destination } = input ?? {} + // input is what the MODEL sent — schema validation is permissive, not a guarantee. + if (!/^[A-Z]{2}$/.test(passport) || !/^[A-Z]{2}$/.test(destination)) { + throw new Error('passport and destination must be ISO-3166 alpha-2 codes') + } + const res = await ctx.http.fetch(`https://visa.example.com/${passport}/${destination}`) + return await res.json() + }, + }], +}) +``` + +The assistant sees it as `plugin__check_visa`, and only over a session whose +token holds the `plugins:use` scope. The handler runs **as the calling user** (trip +reads are membership-checked, like a route), gets 30 s, and can throw — the message +goes to the model, which is how it learns to try something else. + +Test it the way the host would call it: + +```js +const d = createMockHost({ grants: ['mcp:tools'], actingUserId: 7 }).run(def) +await expect(d.mcpTool('check_visa', { passport: 'lower', destination: 'JP' })) + .rejects.toThrow(/alpha-2/) // your own validation, not the schema's +``` + +--- + ## Call a third-party API the user connected **Needs:** `oauth:client` (and `http:outbound` for the fetch) @@ -573,4 +623,5 @@ const apiKey = await ctx.settings.get('apiKey') // undefined when unset or use | `routes` | forked server child | `ctx` bound to the HTTP request's user | | `jobs` | forked server child, on a schedule | `ctx` with **no** user (can't read user-scoped data) | | `hooks` | forked server child, when core asks | `ctx` bound to the user who triggered the read, short timeout | +| `mcpTools` | forked server child, when an assistant calls | `ctx` bound to the MCP token's user, 30 s timeout | | `widget` / `page` | sandboxed iframe (no same-origin) | `postMessage` bridge; calls its own routes via `trek:invoke` | diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index 54ea4507a4..efebd62bcd 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -755,6 +755,79 @@ Notes: addresses by default. It relaxes the policy for *every* installed plugin, so enable it only if you trust them all. +## MCP tools + +TREK ships an MCP server, so an assistant (Claude, or any MCP client) can drive a trip +through ~200 built-in tools. `mcpTools` puts **your** tools in that same list. + +This is not a hook — nothing in core calls it. It is a top-level section like `routes` +or `jobs`: you declare the tools, and an assistant decides when to call them. + +```js +module.exports = definePlugin({ + mcpTools: [{ + name: 'check_visa', // snake_case, unique within your plugin + title: 'Check visa requirements', + description: 'Look up whether a passport holder needs a visa for a country. ' + + 'Use this before adding international travel to a trip.', + inputSchema: { + type: 'object', + properties: { + passport: { type: 'string', description: 'ISO-3166 alpha-2 code, e.g. "UY"' }, + destination: { type: 'string', description: 'ISO-3166 alpha-2 code, e.g. "JP"' }, + }, + required: ['passport', 'destination'], + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + async handler(input, ctx) { + // Validation is NOT guaranteed — TREK checks input against the parts of the + // schema it understands, anything else passes through — so check it yourself. + const { passport, destination } = input ?? {} + if (typeof passport !== 'string' || typeof destination !== 'string') { + throw new Error('passport and destination must be ISO-3166 alpha-2 codes') + } + const res = await ctx.http.fetch(`https://visa.example.com/${passport}/${destination}`) + return await res.json() // any JSON-serialisable value; a string passes through as-is + }, + }], +}) +``` + +Add `"mcp:tools"` to your manifest `permissions`. Without it your tools are never +advertised and never callable — silently, like every other ungranted entry point. + +**What the assistant sees.** Your tool is namespaced `plugin__`, so +`check_visa` in `trip-doctor` is `plugin_trip-doctor_check_visa`. Two plugins can never +collide, and a built-in tool always wins a name clash. + +**Who can call it.** The MCP session's token needs the `plugins:use` OAuth scope (a +full-access token has it). That scope is the user's consent to reach plugin tools at +all — what any individual tool can actually *touch* is still bounded by the permissions +an admin granted your plugin. + +**How it runs.** With the calling user bound, exactly like a route: `ctx.trips.*` is +membership-checked against them. You get 30 s (room to call an external API through +your declared egress). Throw to fail the call — your message goes back to the model, +which is how it learns to try something else. + +**What the host caps.** ≤16 tools per plugin, ≤64 chars for the namespaced name, title +≤80, description ≤4096, schema ≤16 KB serialized, result ~100 000 chars. Anything over +a cap is **dropped whole** rather than truncated into a tool whose description no longer +matches what it does — so keep them comfortably inside. + +**Write the description for a model, not a human.** It and the `description` on each +schema property are all the model reads before deciding to call you. Say what the tool +does *and when to reach for it*. TREK understands the common JSON Schema subset +(objects, strings, numbers, integers, booleans, arrays, enums, nesting, `required`); +anything more exotic is advertised as unconstrained rather than rejected. + +Test one without a running TREK: + +```js +const d = createMockHost({ grants: ['mcp:tools'], actingUserId: 7 }).run(def) +expect(await d.mcpTool('check_visa', { passport: 'UY', destination: 'JP' })).toMatchObject({ required: false }) +``` + ## Settings-page actions A plugin can put **buttons on its own settings page** — "Test connection", "Sync now", @@ -1175,6 +1248,7 @@ guard optional `ctx.*` namespaces. | `events:subscribe` | receive core activity events via `events: [...]` (event name + tripId + a { entity, entityId } hint, plus a whitelisted entity **snapshot** when the plugin also holds the family's `db:read:*` grant; never a user) | | `hook:trip-card-provider` | `hooks.tripCardProvider` — small badges on the dashboard trip cards | | `jobs:run` | run declared background `jobs` on their cron schedule **and** `ctx.scheduler` runtime timers → `scheduled` handler (opt-in; no user, so trip reads are refused) | +| `mcp:tools` | advertise your `mcpTools` on TREK's MCP server as `plugin__`, callable by a connected assistant (see [MCP tools](#mcp-tools)) | | `ws:broadcast:trip` | `ctx.ws.broadcastToTrip` | | `ws:broadcast:user` | `ctx.ws.broadcastToUser` | | `http:outbound` or `http:outbound:` | outbound HTTP to `egress[]` hosts | diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index 7dedbf8fde..9c8cf4dfcd 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -55,6 +55,7 @@ ungranted capability is physically unreachable**, not just disallowed. See | `ai:invoke` | Run the admin/user-configured LLM via `ctx.ai.complete` / `ctx.ai.extract` | Host-mediated: the host holds the (encrypted) credential and runs the call under the acting user's resolved provider — the plugin never sees a key. Refused when no provider is configured; prompt/text capped at 20 000 chars. Output is **DATA** — `complete` returns `{ text }`, `extract` returns `{ results }` for your JSON schema — and is never auto-written, so prompt-injection can't reach a write without your own gated call. | | `events:subscribe` | React to core activity via `events: [{ on, handler }]` on the plugin definition | The handler gets the **event name + tripId + a `{ entity, entityId }` hint**, plus a **whitelisted `snapshot` of the changed entity when the plugin also holds the family's `db:read:*` grant** (per-plugin filtered at deliver time — no grant, no fields). It runs with **no user** (like a job), so nothing beyond the snapshot is readable. The whitelist never carries user ids, private packing items (#858) or secrets; deletes/bulk/reorder events carry no snapshot, a non-entity id (e.g. a userId) never surfaces. Fire-and-forget on a short timeout; `plugin:*` re-broadcasts are never delivered back. | | `jobs:run` | Run the plugin's declared background `jobs` on their cron schedule **and** its runtime timers via `ctx.scheduler` (`at`/`in`/`every`/`cancel`) | **Opt-in.** Scheduled work runs with **no user** (its trip reads are refused), so a job can only touch its own `ctx.db` and declared egress. Invalid cron expressions are skipped; jobs stop when the plugin is deactivated. `ctx.scheduler` tasks are persisted (survive restarts), capped at 100/plugin with an 8 KB payload and a 60 s minimum recurring interval, and removed on uninstall. | +| `mcp:tools` | Advertise the plugin's declared `mcpTools` on TREK's MCP server, so an assistant connected over MCP can call them | **Opt-in, and gated twice.** Tools appear as `plugin__` (no cross-plugin collisions; a built-in name always wins), and only to a session whose token carries the **`plugins:use` OAuth scope** — a full-access token qualifies. The permission grants **no data access of its own**: a tool handler runs with the **calling user bound** (like a route, so trip reads are membership-checked) and can still only reach what the plugin's *other* grants allow. Declarations are capped host-side (≤16 tools/plugin, name ≤64 incl. namespace, title ≤80, description ≤4096, schema ≤16 KB) and anything over is dropped whole; argument validation is **not guaranteed** (the declared schema is enforced only as far as TREK's permissive conversion understands it — unrecognized constructs pass anything), so the handler must check its own input. 30 s timeout; a throw, timeout or crash becomes a tool error for the model, never a broken session. Activating or deactivating the plugin invalidates live MCP sessions so the tool list stays honest. | | `hook:photo-provider` | Register as a photo provider in Memories | Implement the `PhotoProvider` interface. | | `hook:calendar-source` | Register as a calendar source | Implement the `CalendarSource` interface. | | `hook:place-detail-provider` | Contribute extra details (reviews, ratings, links) to a place via the `hooks.placeDetailProvider` provider hook | Implement `PlaceDetailProvider` in `hooks` on the plugin definition (not on `ctx`) — shown in the place-detail panel; also exposed at `GET /api/place-details/:placeId`. |