Files
TREK/nest-mcp/src/types.ts
T
Maurice 2d0eca7938 Let MCP addon gates read an injected service
Nine *.mcp.ts files reached through addons.bridge for one boolean each, and
platform.routes plus the legacy registrars still do. That was never a
preference: a decorator's options object is built while the class is being
defined, so the `when:` closure had no `this` to reach an injected service
through, and the only thing in scope was a module-level singleton that
builds its own AddonsService outside the container. Nine domains hid an edge
to the addons domain from the module graph to answer "is this addon on".

@trek/nest-mcp hands the gate its declaring instance now, resolved at
attach rather than captured at module load, and addons/addon-gate.ts turns
that into addonGate(ADDON_IDS.X) over the injected service. The option is
declared with method syntax so a predicate written against its own
controller class stays assignable.

Two more edges went the same way, both checked for cycles first:
reservations.mcp injects AssignmentsService (AssignmentsModule never
reaches back), atlas.mcp and journey.mcp inject AuthService.

The tests that forced a toggle by mocking the bridge module now write the
addon row the admin panel writes, so they exercise the real read.

Four in-container uses stay, each documented in the nest README: places.mcp
(a real Days -> Places -> Assignments cycle), budget/packing/costs against
trips.bridge, user-cleanup against budget.bridge, and the two multer
interceptor options, whose fix is MulterModule.registerAsync.
2026-08-10 17:08:54 +02:00

165 lines
6.1 KiB
TypeScript

import type { ZodRawShape } from 'zod';
/**
* The per-session context handed to `McpRegistry.attach(server, ctx)` and
* forwarded to every handler (last argument) and access predicate/policy.
*
* The package defines NO context fields of its own — the host application
* augments this interface once:
*
* declare module '@trek/nest-mcp' {
* interface McpContext { userId: number; scopes: string[] | null }
* }
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface McpContext {}
/**
* Host-augmentable registry of valid `access.group` values — the `McpContext`
* augmentation trick applied to groups. Augment once:
*
* declare module '@trek/nest-mcp' {
* interface McpAccessGroupRegistry extends Record<MyGroupUnion, true> {}
* }
*
* Unaugmented, `McpAccessGroup` stays `string` — the package attaches no
* semantics to groups, same contract as `McpContext`.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface McpAccessGroupRegistry {}
export type McpAccessGroup = keyof McpAccessGroupRegistry extends never
? string
: keyof McpAccessGroupRegistry & string;
/**
* Host augmentation point for the access *mode*, mirroring
* `McpAccessGroupRegistry`:
*
* declare module '@trek/nest-mcp' {
* interface McpAccessModeRegistry extends Record<MyModeUnion, true> {}
* }
*
* Unaugmented, `McpAccessMode` falls back to `'read' | 'write'` — the two
* modes every host had before this was augmentable, so leaving the registry
* empty keeps the previous contract exactly.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface McpAccessModeRegistry {}
export type McpAccessMode = keyof McpAccessModeRegistry extends never
? 'read' | 'write'
: keyof McpAccessModeRegistry & string;
/**
* Declarative access marker resolved by the host-supplied `accessPolicy`.
* The package attaches no semantics to `group`/`mode` — the policy does.
*/
export interface McpDeclarativeAccess {
group: McpAccessGroup;
mode: McpAccessMode;
}
/** Predicate escape hatch — bypasses the policy entirely. */
export type McpAccessPredicate = (ctx: McpContext) => boolean;
export type McpAccess = McpDeclarativeAccess | McpAccessPredicate;
/**
* Host-supplied resolver for declarative access, given once at
* `McpModule.forRoot({ accessPolicy })`. Return true to register the entry
* on the session's server.
*/
export type McpAccessPolicy = (access: McpDeclarativeAccess, ctx: McpContext) => boolean;
interface McpEntryOptionsBase {
name: string;
title?: string;
description?: string;
/**
* Availability gate evaluated BEFORE `access` (short-circuits it) — for
* "is this feature on at all" checks like addon toggles. Keeps `access`
* purely about permissions, so declarative scope markers still compose
* with runtime feature gates instead of being replaced by predicates.
*
* `self` is the `@McpController()` instance the entry was declared on,
* handed in at attach time. Without it the gate could only reach a
* module-level singleton, because the options object is built when the class
* is defined and there is no `this` yet — which is how a host ends up
* constructing a second copy of a service outside its own container just to
* answer "is this addon on". With it, the predicate reads an injected
* collaborator and the dependency is visible in the module graph.
*
* Declared with method syntax on purpose: that makes the parameter bivariant,
* so a predicate written against its own controller class stays assignable
* here. The registry always passes the instance that declared the entry.
*/
when?(ctx: McpContext, self: object): boolean;
/** Omitted ⇒ the entry is always registered (subject to `when`). */
access?: McpAccess;
}
/**
* Mirrors the SDK's `registerTool` config. `inputSchema` is a ZodRawShape
* passed straight through — the SDK does the parsing/validation.
* `annotations`/`_meta` are kept structural so the package's public d.ts
* never imports deep SDK subpaths.
*/
export interface ToolOptions extends McpEntryOptionsBase {
inputSchema?: ZodRawShape;
outputSchema?: ZodRawShape;
annotations?: Record<string, unknown>;
_meta?: Record<string, unknown>;
}
/** Mirrors `registerResource(name, uri, metadata, cb)` for a fixed URI. */
export interface ResourceOptions extends McpEntryOptionsBase {
uri: string;
mimeType?: string;
_meta?: Record<string, unknown>;
}
/** Mirrors `registerResource(name, new ResourceTemplate(uriTemplate), metadata, cb)`. */
export interface ResourceTemplateOptions extends McpEntryOptionsBase {
uriTemplate: string;
mimeType?: string;
_meta?: Record<string, unknown>;
}
/** Mirrors the SDK's `registerPrompt` config (argsSchema entries must be string-valued Zod schemas). */
export interface PromptOptions extends McpEntryOptionsBase {
argsSchema?: ZodRawShape;
}
export type McpEntryKind = 'tool' | 'resource' | 'resourceTemplate' | 'prompt';
export type McpEntry =
| { kind: 'tool'; methodName: string; options: ToolOptions }
| { kind: 'resource'; methodName: string; options: ResourceOptions }
| { kind: 'resourceTemplate'; methodName: string; options: ResourceTemplateOptions }
| { kind: 'prompt'; methodName: string; options: PromptOptions };
/** Introspection view of one recorded entry (see `McpRegistry.list()`). */
export interface McpRegistryListing {
kind: McpEntryKind;
name: string;
className: string;
methodName: string;
access?: McpAccess;
}
/**
* Host-supplied per-entry check run by `validate()`. Return a problem
* description to fail boot, null/undefined to accept. Only called for
* entries with DECLARATIVE access (predicates are opaque to the host).
*/
export type McpAccessValidator = (access: McpDeclarativeAccess, entry: McpRegistryListing) => string | null | undefined;
export interface McpModuleOptions {
accessPolicy?: McpAccessPolicy;
validateAccess?: McpAccessValidator;
}
/** Injection token for the options object given to `McpModule.forRoot()`. */
export const MCP_MODULE_OPTIONS = Symbol('MCP_MODULE_OPTIONS');