Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/src/api/oauthScopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
1 change: 1 addition & 0 deletions client/src/api/oauthScopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'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)
Expand Down
5 changes: 3 additions & 2 deletions client/src/components/Admin/AdminPluginsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 })
}
Expand Down
5 changes: 3 additions & 2 deletions client/src/mobile/screens/admin/MAdminPluginsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 })
}
Expand Down
1 change: 1 addition & 0 deletions plugin-sdk/src/cli/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand Down
65 changes: 64 additions & 1 deletion plugin-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,66 @@ export interface PluginJob {
handler(ctx: PluginContext): Promise<void>;
}

// ── 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_<pluginId>_<name>`, 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<string, unknown>;
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> | unknown;
}

// ── integration hook interfaces ──────────────────────────────────────────────
export interface Photo {
id: string;
Expand Down Expand Up @@ -672,6 +732,9 @@ export interface PluginDefinition {
onUnload?(ctx: PluginContext): Promise<void> | 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_<pluginId>_<name>`. 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> | void;
Expand Down Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion plugin-sdk/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down
21 changes: 19 additions & 2 deletions plugin-sdk/src/mock-host.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -133,6 +133,15 @@ export interface PluginDriver {
exportUserData(userId: number): Promise<unknown>;
/** Invoke a provider hook, e.g. hook('tripCardProvider', 'getCards', [1, 2]). */
hook<T = unknown>(name: string, fn: string, ...args: unknown[]): Promise<T>;
/** 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<T = unknown>(name: string, input?: unknown): Promise<T>;
/** 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
Expand Down Expand Up @@ -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}"`);
Expand Down Expand Up @@ -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';
4 changes: 4 additions & 0 deletions plugin-sdk/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:';

Expand All @@ -52,6 +54,7 @@ export interface PluginEntryPoints {
deleteUserData?: unknown;
exportUserData?: unknown;
hooks?: Record<string, unknown>;
mcpTools?: unknown[];
}

/** An entry point the plugin implements but has no permission to actually run. */
Expand Down Expand Up @@ -86,6 +89,7 @@ export function grantGaps(plugin: PluginEntryPoints, grants: ReadonlySet<string>
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;
Expand Down
5 changes: 3 additions & 2 deletions plugin-sdk/test/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {};
Expand Down Expand Up @@ -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);
Expand All @@ -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)', () => {
Expand Down
Loading
Loading