From 8cfa608ea5076c598275bc162f880603d11e8155 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 03:02:22 +0200 Subject: [PATCH 1/3] fix(sdk): type hook decorators against the flow ctx and add ctx accessors for hooks Hook decorators now check the decorated method's parameter against FlowCtxOf of their flow, so a hook that reads a property the flow does not have fails to compile instead of reading undefined. Flows gain read-only accessors for hooks: toolName and toolInput (tools:call-tool), uri (resources:read-resource), promptName (prompts:get-prompt), and request, notify.listChanged() and error on every flow. --- .../e2e/dts-consumer.e2e.spec.ts | 7 +- .../fixture/hooks-must-compile.ts | 72 ++++ .../fixture/hooks-must-fail.ts | 103 ++++++ .../fixture/tsconfig.json | 1 + .../e2e/hook-ctx-accessors.e2e.spec.ts | 51 +++ .../demo-e2e-hooks/src/apps/audit/index.ts | 5 +- .../audit/plugins/request-probe.plugin.ts | 18 + .../apps/audit/tools/probe-request.tool.ts | 12 + .../src/common/decorators/hook.decorator.ts | 25 +- .../src/common/interfaces/flow.interface.ts | 52 ++- .../flows/__tests__/flow-accessors.spec.ts | 318 ++++++++++++++++++ .../flows/__tests__/flow-status-code.spec.ts | 4 +- .../fixtures/typed-hook-ctx.fixture.ts | 214 ++++++++++++ .../__tests__/flow-hooks.exports.spec.ts | 57 +++- libs/sdk/src/prompt/flows/get-prompt.flow.ts | 5 + .../src/resource/flows/read-resource.flow.ts | 5 + libs/sdk/src/tool/flows/call-tool.flow.ts | 10 + .../basic-logging-plugin.md | 9 +- ...ool-level-hooks-and-stage-short-circuit.md | 2 +- .../references/create-plugin-hooks.md | 100 ++++-- .../references/decorators-guide.md | 23 +- libs/skills/catalog/skills-manifest.json | 2 +- 22 files changed, 1041 insertions(+), 54 deletions(-) create mode 100644 apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-fail.ts create mode 100644 apps/e2e/demo-e2e-hooks/e2e/hook-ctx-accessors.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-hooks/src/apps/audit/plugins/request-probe.plugin.ts create mode 100644 apps/e2e/demo-e2e-hooks/src/apps/audit/tools/probe-request.tool.ts create mode 100644 libs/sdk/src/flows/__tests__/flow-accessors.spec.ts create mode 100644 libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index e5bbbe45e..a1e57dcc5 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -28,6 +28,7 @@ const fixtureFiles = [ 'tool-must-fail.ts', 'prompts-must-compile.ts', 'hooks-must-compile.ts', + 'hooks-must-fail.ts', 'host-must-compile.ts', 'skills-must-compile.ts', ].map((fileName) => path.join(fixtureDir, fileName)); @@ -90,10 +91,14 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('prompts-must-compile.ts'))).toEqual([]); }); - it('types the prompt, completion and Finally hooks, the scope channel accessors and server-level adapters', () => { + it('types the prompt, completion and Finally hooks, the hook ctx accessors, the scope channel accessors and server-level adapters', () => { expect(describeEach(inFixtureFile('hooks-must-compile.ts'))).toEqual([]); }); + it('rejects a hook whose parameter the flow ctx does not satisfy, for every hook decorator', () => { + expect(describeEach(inFixtureFile('hooks-must-fail.ts'))).toEqual([]); + }); + it('types a custom host without enhancedHandler, and names Scope for the web-fetch helpers (#819)', () => { expect(describeEach(inFixtureFile('host-must-compile.ts'))).toEqual([]); }); diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-compile.ts index c98ffdb31..b11939aee 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-compile.ts +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-compile.ts @@ -7,8 +7,13 @@ import { FrontMcp, ListPromptsHook, PromptHook, + ResourceHook, + Tool, + ToolContext, ToolHook, + z, type FlowCtxOf, + type FlowRequestInfo, type FrontMcpAdapterResponse, type ScopeEntry, } from '@frontmcp/sdk'; @@ -53,6 +58,73 @@ export class CallAudit { } } +// A hook's parameter is checked against the flow's ctx: FlowCtxOf, a structural subset of it, or none compiles. +export class TypedCallAudit { + @ToolHook.Will('execute') + beforeCall(ctx: FlowCtxOf<'tools:call-tool'>): void { + const toolName: string = ctx.toolName; + const toolInput: Record | undefined = ctx.toolInput; + const request: FlowRequestInfo = ctx.request; + const clientName: string | undefined = ctx.request.clientInfo?.name; + void [toolName, toolInput, request.sessionId, request.metadata?.userAgent, request.protocol, clientName]; + ctx.notify.listChanged('tools'); + } + + @ToolHook.Did('findTool') + afterFind(ctx: { toolName: string; state: { input?: { name: string } } }): void { + void ctx.toolName; + } + + @ToolHook.Around('execute') + async aroundCall(ctx: FlowCtxOf<'tools:call-tool'>, next: () => Promise): Promise { + await next(); + void ctx.toolName; + } + + @ToolHook.Finally('finalize') + afterCallWhateverItsOutcome(ctx: FlowCtxOf<'tools:call-tool'>): void { + const error: Error | undefined = ctx.error; + void error?.message; + } + + @ToolHook.Did('execute') + untyped(ctx: unknown): void { + void ctx; + } + + @ResourceHook.Will('execute') + beforeRead(ctx: FlowCtxOf<'resources:read-resource'>): void { + const uri: string = ctx.uri; + ctx.notify.listChanged('resources'); + void uri; + } + + @PromptHook.Will('execute') + beforeGetPrompt(ctx: FlowCtxOf<'prompts:get-prompt'>): void { + const promptName: string = ctx.promptName; + ctx.notify.listChanged('prompts'); + void promptName; + } +} + +// A tool class declares hooks on itself, static for the stages before its instance exists. +@Tool({ name: 'guarded-echo', inputSchema: { text: z.string() } }) +export class GuardedEchoTool extends ToolContext { + @ToolHook.Will('findTool') + static beforeFind(ctx: FlowCtxOf<'tools:call-tool'>): void { + void ctx.toolName; + } + + @ToolHook.Did('execute') + afterExecute(): void { + void this.input; + } + + async execute(input: { text: string }) { + return input.text; + } +} + // ScopeEntry declares the channel accessors, so no cast is needed. export function raise(scope: ScopeEntry): void { scope.channelEventBus?.emit('app:error', { message: 'Connection refused' }); diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-fail.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-fail.ts new file mode 100644 index 000000000..123f703d5 --- /dev/null +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-fail.ts @@ -0,0 +1,103 @@ +import 'reflect-metadata'; + +import { + AgentCallHook, + FlowHooksOf, + HttpHook, + JobHook, + ListResourcesHook, + ListToolsHook, + PromptHook, + ResourceHook, + ToolHook, + type FlowCtxOf, +} from '@frontmcp/sdk'; + +const SkillsFilterHook = FlowHooksOf('skills:filter'); + +// Every hook decorator checks the decorated method's parameter against the flow's ctx. +export class WronglyTypedHooks { + // @ts-expect-error toolName is a string + @ToolHook.Will('execute') + wrongType(ctx: { toolName: number }): void { + void ctx; + } + + // @ts-expect-error the call-tool ctx has no notAField + @ToolHook.Will('execute') + unknownField(ctx: { notAField: string }): void { + void ctx; + } + + // @ts-expect-error the ctx is not a number + @ToolHook.Did('execute') + notTheCtx(ctx: number): void { + void ctx; + } + + // @ts-expect-error a Finally hook gets the ctx alone, no second argument + @ToolHook.Finally('finalize') + extraArgument(ctx: FlowCtxOf<'tools:call-tool'>, next: () => Promise): void { + void [ctx, next]; + } + + // @ts-expect-error an Around hook's next() resolves to nothing + @ToolHook.Around('execute') + wrongNext(ctx: FlowCtxOf<'tools:call-tool'>, next: () => Promise): void { + void [ctx, next]; + } + + // @ts-expect-error a Stage hook gets the ctx of its own flow + @ToolHook.Stage('execute') + otherFlowsCtx(ctx: FlowCtxOf<'resources:read-resource'>): void { + void ctx; + } + + // @ts-expect-error the read-resource ctx has no toolName + @ResourceHook.Will('execute') + resourceToolName(ctx: { toolName: string }): void { + void ctx; + } + + // @ts-expect-error the get-prompt ctx has no uri + @PromptHook.Will('execute') + promptUri(ctx: { uri: string }): void { + void ctx; + } + + // @ts-expect-error the list-tools ctx has no toolName + @ListToolsHook.Did('findTools') + listToolName(ctx: { toolName: string }): void { + void ctx; + } + + // @ts-expect-error the list-resources ctx has no uri + @ListResourcesHook.Will('findResources') + listUri(ctx: { uri: string }): void { + void ctx; + } + + // @ts-expect-error the http:request ctx has no toolName + @HttpHook.Will('router') + httpToolName(ctx: { toolName: string }): void { + void ctx; + } + + // @ts-expect-error the call-agent ctx has no toolInput + @AgentCallHook.Will('execute') + agentToolInput(ctx: { toolInput: Record }): void { + void ctx; + } + + // @ts-expect-error the execute-job ctx has no promptName + @JobHook.Will('execute') + jobPromptName(ctx: { promptName: string }): void { + void ctx; + } + + // @ts-expect-error FlowHooksOf() types its decorators the same way + @SkillsFilterHook.Did('filterSkills') + skillsToolName(ctx: { toolName: string }): void { + void ctx; + } +} diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json index 282aa3e60..8ac486ca7 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json @@ -14,6 +14,7 @@ "tool-must-fail.ts", "prompts-must-compile.ts", "hooks-must-compile.ts", + "hooks-must-fail.ts", "host-must-compile.ts", "skills-must-compile.ts" ] diff --git a/apps/e2e/demo-e2e-hooks/e2e/hook-ctx-accessors.e2e.spec.ts b/apps/e2e/demo-e2e-hooks/e2e/hook-ctx-accessors.e2e.spec.ts new file mode 100644 index 000000000..30d807dd4 --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/e2e/hook-ctx-accessors.e2e.spec.ts @@ -0,0 +1,51 @@ +/** + * E2E: a hook reads the tool name and the request from its ctx, and sends list_changed through it. + * + * The request-probe plugin's `Will('execute')` hook, filtered on `ctx.toolName`, reports `ctx.toolName` and + * `ctx.request` for a `probe-request` call in the result `_meta`, then calls `ctx.notify.listChanged('tools')`. + */ +import { expect, test } from '@frontmcp/testing'; + +interface RequestProbe { + toolName: string; + sessionId?: string; + clientInfo?: { name: string; version: string }; +} + +test.describe('Hook ctx accessors E2E', () => { + test.use({ + server: 'apps/e2e/demo-e2e-hooks/src/main.ts', + project: 'demo-e2e-hooks', + publicMode: true, + }); + + test('a hook reads the tool name, the session and the client info from its ctx', async ({ server }) => { + const client = await server.createClient({ clientInfo: { name: 'hooks-e2e-client', version: '2.1.0' } }); + + const result = await client.tools.call('probe-request', {}); + + expect(result).toBeSuccessful(); + expect(result.raw._meta?.['request-probe']).toMatchObject({ + toolName: 'probe-request', + sessionId: client.sessionId, + clientInfo: { name: 'hooks-e2e-client', version: '2.1.0' }, + }); + }); + + test('ctx.notify.listChanged() reaches the client as notifications/tools/list_changed', async ({ server }) => { + const client = await server.createClient(); + const notifications = client.notifications.collect(); + + await client.tools.call('probe-request', {}); + + const notification = await notifications.waitFor('notifications/tools/list_changed', 5000); + expect(notification.method).toBe('notifications/tools/list_changed'); + }); + + test('the hook filter reads ctx.toolName: other tools get no probe', async ({ mcp }) => { + const result = await mcp.tools.call('plain-echo', { message: 'hello', user: 'someone' }); + + expect(result).toBeSuccessful(); + expect(result.raw._meta?.['request-probe']).toBeUndefined(); + }); +}); diff --git a/apps/e2e/demo-e2e-hooks/src/apps/audit/index.ts b/apps/e2e/demo-e2e-hooks/src/apps/audit/index.ts index a112818ce..f60967d7f 100644 --- a/apps/e2e/demo-e2e-hooks/src/apps/audit/index.ts +++ b/apps/e2e/demo-e2e-hooks/src/apps/audit/index.ts @@ -2,6 +2,7 @@ import { App } from '@frontmcp/sdk'; import AuditPlugin from './plugins/audit.plugin'; import FinallyRecorderPlugin from './plugins/finally-recorder.plugin'; +import RequestProbePlugin from './plugins/request-probe.plugin'; import AuditSummaryPrompt from './prompts/audit-summary.prompt'; import AuditLogResource from './resources/audit-log.resource'; import AuditedTool from './tools/audited.tool'; @@ -9,12 +10,13 @@ import ClearAuditLogTool from './tools/clear-audit-log.tool'; import FailingExecuteTool from './tools/failing-execute.tool'; import GetAuditLogTool from './tools/get-audit-log.tool'; import GetFinallyLogTool from './tools/get-finally-log.tool'; +import ProbeRequestTool from './tools/probe-request.tool'; import RefusedAnswerTool from './tools/refused-answer.tool'; import { PlainEchoTool, StaticGuardedEchoTool } from './tools/static-guarded.tool'; @App({ name: 'audit', - plugins: [AuditPlugin, FinallyRecorderPlugin], + plugins: [AuditPlugin, FinallyRecorderPlugin, RequestProbePlugin], tools: [ AuditedTool, GetAuditLogTool, @@ -24,6 +26,7 @@ import { PlainEchoTool, StaticGuardedEchoTool } from './tools/static-guarded.too RefusedAnswerTool, FailingExecuteTool, GetFinallyLogTool, + ProbeRequestTool, ], resources: [AuditLogResource], prompts: [AuditSummaryPrompt], diff --git a/apps/e2e/demo-e2e-hooks/src/apps/audit/plugins/request-probe.plugin.ts b/apps/e2e/demo-e2e-hooks/src/apps/audit/plugins/request-probe.plugin.ts new file mode 100644 index 000000000..ffd65630d --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/src/apps/audit/plugins/request-probe.plugin.ts @@ -0,0 +1,18 @@ +import { Plugin, ToolHook, type FlowCtxOf } from '@frontmcp/sdk'; + +/** Reads the tool name and the request from the hook's ctx, and tells every client the tool list changed. */ +@Plugin({ + name: 'request-probe', + description: 'Reports what a hook reads from its ctx about a probe-request call, and announces a tool list change', +}) +export default class RequestProbePlugin { + @ToolHook.Will('execute', { filter: (ctx) => ctx.toolName === 'probe-request' }) + probe(ctx: FlowCtxOf<'tools:call-tool'>) { + const { sessionId, clientInfo } = ctx.request; + ctx.state.set('resultMeta', { + ...ctx.state.resultMeta, + 'request-probe': { toolName: ctx.toolName, sessionId, clientInfo }, + }); + ctx.notify.listChanged('tools'); + } +} diff --git a/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/probe-request.tool.ts b/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/probe-request.tool.ts new file mode 100644 index 000000000..f8009fb8b --- /dev/null +++ b/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/probe-request.tool.ts @@ -0,0 +1,12 @@ +import { Tool, ToolContext } from '@frontmcp/sdk'; + +@Tool({ + name: 'probe-request', + description: 'Answers ok; the request-probe plugin reports what its hook read about the call in the result _meta', + inputSchema: {}, +}) +export default class ProbeRequestTool extends ToolContext { + async execute() { + return 'ok'; + } +} diff --git a/libs/sdk/src/common/decorators/hook.decorator.ts b/libs/sdk/src/common/decorators/hook.decorator.ts index 0b529cdea..ed73a931e 100644 --- a/libs/sdk/src/common/decorators/hook.decorator.ts +++ b/libs/sdk/src/common/decorators/hook.decorator.ts @@ -53,12 +53,25 @@ export function consumePendingTC39Hooks(method: Function): HookMetadata[] { return pendingHookRegistry.consume(method); } +/** The method a Stage, Will, Did or Finally hook decorates: the runner calls it with the flow (`FlowCtxOf`). */ +export type FlowHookMethod = (ctx: Ctx) => unknown; + +/** The method an Around hook decorates: `next()` runs the rest of the stage, and rejects when it fails. */ +export type AroundFlowHookMethod = (ctx: Ctx, next: () => Promise) => unknown; + +/** A hook decorator: the method's parameter must be the flow's ctx or a structural subset of it, or be omitted. */ +export type FlowHookDecorator = ( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void; + /** * Base factory for creating hook decorators * Works with both legacy TypeScript and TC39 Stage 3 decorators */ function make(flow: FlowName, type: HookStageType) { - return function (stage: T, opts: HookOptions = {}): MethodDecorator { + return function (stage: T, opts: HookOptions = {}): FlowHookDecorator { return (target: any, keyOrContext: any, _desc?: PropertyDescriptor): any => { const { priority = 0, filter, ...rest } = opts; @@ -134,7 +147,7 @@ export function StageHookOf(flow: Name) { type Ctx = ExtendFlows[Name]['ctx']; const base = make(flow, 'stage'); return function (stage: T, opts: HookOptions = {}) { - return base(stage, opts); + return base>(stage, opts); }; } @@ -146,7 +159,7 @@ export function WillHookOf(flow: Name) { type Ctx = ExtendFlows[Name]['ctx']; const base = make(flow, 'will'); return function (stage: T, opts: HookOptions = {}) { - return base(stage, opts); + return base>(stage, opts); }; } @@ -158,7 +171,7 @@ export function DidHookOf(flow: Name) { type Ctx = ExtendFlows[Name]['ctx']; const base = make(flow, 'did'); return function (stage: T, opts: HookOptions = {}) { - return base(stage, opts); + return base>(stage, opts); }; } @@ -170,7 +183,7 @@ export function AroundHookOf(name: Name) { type Ctx = ExtendFlows[Name]['ctx']; const base = make(name, 'around'); return function (stage: T, opts: HookOptions = {}) { - return base(stage, opts); + return base>(stage, opts); }; } @@ -182,7 +195,7 @@ export function FinallyHookOf(name: Name) { type Ctx = ExtendFlows[Name]['ctx']; const base = make(name, 'finally'); return function (stage: T, opts: HookOptions = {}) { - return base(stage, opts); + return base>(stage, opts); }; } diff --git a/libs/sdk/src/common/interfaces/flow.interface.ts b/libs/sdk/src/common/interfaces/flow.interface.ts index ba3e7df94..e65d73d33 100644 --- a/libs/sdk/src/common/interfaces/flow.interface.ts +++ b/libs/sdk/src/common/interfaces/flow.interface.ts @@ -2,10 +2,12 @@ import { type Token, type Type } from '@frontmcp/di'; import { type z } from '@frontmcp/lazy-zod'; import { brandClass, isBrandedInstance } from '@frontmcp/utils'; -import type { FrontMcpContext } from '../../context/frontmcp-context'; +import type { FrontMcpContext, RequestMetadata } from '../../context/frontmcp-context'; import { FrontMcpContextStorage } from '../../context/frontmcp-context-storage'; +import type { ClientInfo } from '../../notification'; import { type HookEntry, type ScopeEntry } from '../entries'; import { type FlowMetadata, type FlowName } from '../metadata'; +import { type TransportProtocolType } from '../types'; import { FlowState, type FlowStateOf } from './internal/flow.utils'; import { type FrontMcpLogger } from './logger.interface'; @@ -18,6 +20,27 @@ export type FlowExecuteStagesOf = ExtendFlows[N]['executeSta export type FlowControlType = 'respond' | 'fail' | 'abort' | 'next' | 'handled'; +/** The request a flow run serves, as a hook reads it (`ctx.request`); a field is undefined when the run has no such value. */ +export interface FlowRequestInfo { + /** The session the server verified for the request. */ + readonly sessionId: string | undefined; + /** The client's name and version, sent with the request or at its session's initialize. */ + readonly clientInfo: ClientInfo | undefined; + /** The request's user agent, content type, accept header, client IP and `x-frontmcp-*` headers. */ + readonly metadata: RequestMetadata | undefined; + /** The transport the request's session was opened over, when its session records it (an anonymous one does not). */ + readonly protocol: TransportProtocolType | undefined; +} + +/** The lists `ctx.notify.listChanged()` can tell every connected client changed. */ +export type ListChangedKind = 'tools' | 'resources' | 'prompts'; + +/** Notifications a flow run sends to the connected clients (`ctx.notify`). */ +export interface FlowNotifier { + /** Sends `notifications//list_changed` to every connected session. */ + listChanged(kind: ListChangedKind): void; +} + function setStackTraceLimit(limit: number): void { try { Error.stackTraceLimit = limit; @@ -119,6 +142,33 @@ export abstract class FlowBase { return this.scope.providers.get(token); } + /** The request this run serves; its fields are undefined when the run has no request context. */ + get request(): FlowRequestInfo { + const context = this.tryGetContext(); + const sessionId = context?.verifiedSessionId; + let clientInfo = context?.clientInfo; + if (!clientInfo && sessionId) clientInfo = this.scope.notifications.getClientInfo(sessionId); + return { + sessionId, + clientInfo, + metadata: context?.metadata, + protocol: context?.sessionMetadata?.protocol, + }; + } + + /** Notifications the run sends to the connected clients. */ + get notify(): FlowNotifier { + return { + listChanged: (kind) => this.scope.notifications.broadcastNotification(`notifications/${kind}/list_changed`), + }; + } + + /** The first failure of the run (`state.flowError`), for the stages and `Finally` hooks after it. */ + get error(): Error | undefined { + const flowError: unknown = this.state.get('flowError' as never); + return flowError instanceof Error ? flowError : undefined; + } + respond(output: FlowOutputOf) { throw FlowControl.respond((this.metadata.outputSchema as z.ZodObject).parse(output)); } diff --git a/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts b/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts new file mode 100644 index 000000000..2139afcbd --- /dev/null +++ b/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts @@ -0,0 +1,318 @@ +/** + * The flow a hook receives exposes what hooks used to dig out of its state or reach through protected + * members: the tool's name and input, the resource's URI, the prompt's name, the request it serves, + * list_changed notifications and the run's first failure. + */ +import 'reflect-metadata'; + +import { type GetPromptResult, type ReadResourceResult } from '@frontmcp/protocol'; + +import { FlowBase, type FlowCtxOf, type FlowMetadata, type ScopeEntry } from '../../common'; +import { FrontMcpContextStorage } from '../../context/frontmcp-context-storage'; +import { type DirectClient } from '../../direct/client.types'; +import { type DirectMcpServer } from '../../direct/direct.types'; +import { PublicMcpError } from '../../errors'; +import { FrontMcpInstance } from '../../front-mcp/front-mcp'; +import { + App, + LogLevel, + Plugin, + Prompt, + PromptContext, + PromptHook, + Resource, + ResourceContext, + ResourceHook, + Tool, + ToolContext, + ToolHook, +} from '../../index'; + +const seen: Array> = []; + +@Plugin({ name: 'accessor-probe' }) +class AccessorProbe { + @ToolHook.Will('parseInput') + beforeParse(ctx: FlowCtxOf<'tools:call-tool'>) { + seen.push({ at: 'will:parseInput', toolName: ctx.toolName, toolInput: ctx.toolInput }); + } + + @ToolHook.Will('findTool') + beforeFind(ctx: FlowCtxOf<'tools:call-tool'>) { + seen.push({ at: 'will:findTool', toolName: ctx.toolName }); + } + + @ToolHook.Did('findTool') + afterFind(ctx: FlowCtxOf<'tools:call-tool'>) { + seen.push({ at: 'did:findTool', toolName: ctx.toolName }); + } + + @ToolHook.Will('execute') + beforeExecute(ctx: FlowCtxOf<'tools:call-tool'>) { + seen.push({ at: 'will:execute', toolInput: ctx.toolInput, request: ctx.request }); + if (ctx.toolInput?.['announce'] === true) ctx.notify.listChanged('tools'); + } + + @ToolHook.Finally('execute') + afterExecute(ctx: FlowCtxOf<'tools:call-tool'>) { + seen.push({ at: 'finally:execute', error: ctx.error }); + } + + @ResourceHook.Will('parseInput') + beforeParseRead(ctx: FlowCtxOf<'resources:read-resource'>) { + seen.push({ at: 'will:parseRead', uri: ctx.uri }); + } + + @ResourceHook.Will('execute') + beforeRead(ctx: FlowCtxOf<'resources:read-resource'>) { + seen.push({ at: 'will:read', uri: ctx.uri }); + } + + @PromptHook.Will('parseInput') + beforeParsePrompt(ctx: FlowCtxOf<'prompts:get-prompt'>) { + seen.push({ at: 'will:parsePrompt', promptName: ctx.promptName }); + } + + @PromptHook.Will('findPrompt') + beforeFindPrompt(ctx: FlowCtxOf<'prompts:get-prompt'>) { + seen.push({ at: 'will:findPrompt', promptName: ctx.promptName }); + } + + @PromptHook.Did('findPrompt') + afterFindPrompt(ctx: FlowCtxOf<'prompts:get-prompt'>) { + seen.push({ at: 'did:findPrompt', promptName: ctx.promptName }); + } +} + +@Tool({ name: 'say_hello', inputSchema: {} }) +class SayHelloTool extends ToolContext { + async execute() { + return 'hello'; + } +} + +@Tool({ name: 'ledger', inputSchema: {} }) +class LedgerTool extends ToolContext { + async execute(): Promise { + throw new PublicMcpError('The ledger is unreachable'); + } +} + +@Resource({ name: 'readme', uri: 'notes://readme', mimeType: 'text/plain' }) +class ReadmeResource extends ResourceContext { + async execute(uri: string): Promise { + return { contents: [{ uri, text: 'read me' }] }; + } +} + +@Prompt({ name: 'greeting', arguments: [] }) +class GreetingPrompt extends PromptContext { + async execute(): Promise { + return { messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }] }; + } +} + +@App({ + id: 'notes', + name: 'Notes', + plugins: [AccessorProbe], + tools: [SayHelloTool, LedgerTool], + resources: [ReadmeResource], + prompts: [GreetingPrompt], +}) +class NotesApp {} + +const at = (stage: string) => seen.find((entry) => entry['at'] === stage); + +describe('flow accessors in hooks', () => { + let server: DirectMcpServer; + + beforeAll(async () => { + server = await FrontMcpInstance.createDirect({ + info: { name: 'flow-accessors', version: '1.0.0' }, + apps: [NotesApp], + logging: { level: LogLevel.Off }, + }); + }); + + afterAll(async () => { + await server.dispose(); + }); + + beforeEach(() => { + seen.length = 0; + }); + + it('toolName is the name the call asks for until findTool resolves the tool, then the name the tool declares', async () => { + await server.callTool('notes:say_hello', { mood: 'cheerful' }); + + expect(at('will:parseInput')).toEqual({ + at: 'will:parseInput', + toolName: 'notes:say_hello', + toolInput: { mood: 'cheerful' }, + }); + expect(at('will:findTool')).toEqual({ at: 'will:findTool', toolName: 'notes:say_hello' }); + expect(at('did:findTool')).toEqual({ at: 'did:findTool', toolName: 'say_hello' }); + }); + + it('toolInput is the arguments the tool runs with', async () => { + await server.callTool('say_hello', { mood: 'calm' }); + + expect(at('will:execute')?.['toolInput']).toEqual({ mood: 'calm' }); + }); + + it('request carries the metadata of an in-process call', async () => { + await server.callTool('say_hello', {}, { metadata: { userAgent: 'probe-agent/1.0' } }); + + expect(at('will:execute')?.['request']).toMatchObject({ + metadata: { userAgent: 'probe-agent/1.0' }, + protocol: undefined, + }); + }); + + it('error is undefined after a stage that succeeded, and the first failure of the run after one that failed', async () => { + await server.callTool('say_hello', {}); + expect(at('finally:execute')).toEqual({ at: 'finally:execute', error: undefined }); + + seen.length = 0; + await expect(server.callTool('ledger', {})).rejects.toThrow('The ledger is unreachable'); + const error = at('finally:execute')?.['error']; + expect(error).toBeInstanceOf(PublicMcpError); + expect((error as Error).message).toBe('The ledger is unreachable'); + }); + + it('uri is the URI a read asks for, before and after parseInput', async () => { + await server.readResource('notes://readme'); + + expect(at('will:parseRead')).toEqual({ at: 'will:parseRead', uri: 'notes://readme' }); + expect(at('will:read')).toEqual({ at: 'will:read', uri: 'notes://readme' }); + }); + + it('promptName is the name the request asks for until findPrompt resolves the prompt', async () => { + await server.getPrompt('greeting', {}); + + expect(at('will:parsePrompt')).toEqual({ at: 'will:parsePrompt', promptName: 'greeting' }); + expect(at('will:findPrompt')).toEqual({ at: 'will:findPrompt', promptName: 'greeting' }); + expect(at('did:findPrompt')).toEqual({ at: 'did:findPrompt', promptName: 'greeting' }); + }); + + describe('over a connected session', () => { + let client: DirectClient; + const notifications: string[] = []; + + beforeEach(async () => { + notifications.length = 0; + client = await server.connect({ clientInfo: { name: 'probe-client', version: '3.1.0' } }); + client.onNotification((notification) => notifications.push(notification.method)); + }); + + afterEach(async () => { + await client.close(); + }); + + it('request names the session and the client its initialize reported', async () => { + await client.callTool('say_hello', {}); + + const request = at('will:execute')?.['request'] as FlowCtxOf<'tools:call-tool'>['request']; + expect(request.sessionId).toEqual(expect.any(String)); + expect(request.clientInfo).toEqual({ name: 'probe-client', version: '3.1.0' }); + }); + + it('notify.listChanged() sends notifications/tools/list_changed to the client', async () => { + await client.callTool('say_hello', { announce: true }); + for (let tick = 0; tick < 5; tick++) await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(notifications).toContain('notifications/tools/list_changed'); + }); + }); +}); + +describe('flow accessors outside a request', () => { + class BareFlow extends FlowBase<'tools:list-tools'> {} + + const broadcastNotification = jest.fn(); + const getClientInfo = jest.fn(); + const storage = new FrontMcpContextStorage(); + + function bareFlow(getProvider: () => unknown = () => storage): BareFlow { + const scope = { + logger: { child: () => scope.logger }, + providers: { get: getProvider }, + notifications: { broadcastNotification, getClientInfo }, + } as unknown as ScopeEntry; + return new BareFlow({ name: 'tools:list-tools' } as FlowMetadata<'tools:list-tools'>, {}, scope, () => undefined); + } + + beforeEach(() => { + broadcastNotification.mockReset(); + getClientInfo.mockReset(); + }); + + it('request has undefined fields, and never throws, without a request context', () => { + const noRequest = { sessionId: undefined, clientInfo: undefined, metadata: undefined, protocol: undefined }; + + expect(bareFlow().request).toEqual(noRequest); + expect( + bareFlow(() => { + throw new Error('no context storage'); + }).request, + ).toEqual(noRequest); + expect(getClientInfo).not.toHaveBeenCalled(); + }); + + it('request reads the context it runs in, and the client info of the session when the request has none', async () => { + getClientInfo.mockReturnValue({ name: 'session-client', version: '1.0.0' }); + const flow = bareFlow(); + + const request = await storage.run( + { sessionId: 'session-1', scopeId: 'scope', authInfo: { sessionId: 'session-1' } }, + () => { + storage.getStoreOrThrow().updateSessionMetadata({ + nodeId: 'node', + authSig: 'sig', + uuid: '00000000-0000-4000-8000-000000000000', + iat: 0, + protocol: 'streamable-http', + }); + return flow.request; + }, + ); + + expect(request).toEqual({ + sessionId: 'session-1', + clientInfo: { name: 'session-client', version: '1.0.0' }, + metadata: { customHeaders: {} }, + protocol: 'streamable-http', + }); + expect(getClientInfo).toHaveBeenCalledWith('session-1'); + }); + + it('request prefers the client info the request itself carries', async () => { + const flow = bareFlow(); + + const request = await storage.run({ sessionId: 'session-2', scopeId: 'scope' }, () => { + storage.getStoreOrThrow().setClientInfo({ name: 'request-client', version: '2.0.0' }, 'unknown'); + return flow.request; + }); + + expect(request.sessionId).toBeUndefined(); + expect(request.clientInfo).toEqual({ name: 'request-client', version: '2.0.0' }); + expect(getClientInfo).not.toHaveBeenCalled(); + }); + + it.each(['tools', 'resources', 'prompts'] as const)('notify.listChanged(%s) broadcasts its list_changed', (kind) => { + bareFlow().notify.listChanged(kind); + + expect(broadcastNotification).toHaveBeenCalledWith(`notifications/${kind}/list_changed`); + }); + + it('error is state.flowError', () => { + const flow = bareFlow(); + expect(flow.error).toBeUndefined(); + + const failure = new Error('stage failed'); + flow.state.set('flowError' as never, failure as never); + + expect(flow.error).toBe(failure); + }); +}); diff --git a/libs/sdk/src/flows/__tests__/flow-status-code.spec.ts b/libs/sdk/src/flows/__tests__/flow-status-code.spec.ts index 0d5f294b0..32e0020e8 100644 --- a/libs/sdk/src/flows/__tests__/flow-status-code.spec.ts +++ b/libs/sdk/src/flows/__tests__/flow-status-code.spec.ts @@ -20,8 +20,8 @@ class StatusProbe { } @ToolHook.Did('finalize') - record(flowCtx: { state: { statusCode?: unknown } }) { - statusesSeenAtFinalize.push(flowCtx.state.statusCode); + record(flowCtx: { state: object }) { + statusesSeenAtFinalize.push(Reflect.get(flowCtx.state, 'statusCode')); } } diff --git a/libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts b/libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts new file mode 100644 index 000000000..cdaa7985e --- /dev/null +++ b/libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts @@ -0,0 +1,214 @@ +import 'reflect-metadata'; + +import { + AgentCallHook, + ChannelListHook, + ChannelSendHook, + CompletionHook, + HttpHook, + JobHook, + ListPromptsHook, + ListResourcesHook, + ListResourceTemplatesHook, + ListToolsHook, + OAuthAuthorizeHook, + OAuthCallbackHook, + OAuthRegisterHook, + OAuthTokenHook, + PromptHook, + ResourceHook, + Tool, + ToolContext, + ToolHook, + type FlowCtxOf, +} from '../../../index'; + +interface NotInAnyCtx { + notAField: string; +} + +export class EveryExportRejectsAParameterItsCtxDoesNotSatisfy { + // @ts-expect-error the ctx has no notAField + @ToolHook.Will('parseInput') + tool(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @ListToolsHook.Will('parseInput') + listTools(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @HttpHook.Will('router') + http(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @OAuthAuthorizeHook.Will('parseInput') + oauthAuthorize(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @OAuthCallbackHook.Will('parseInput') + oauthCallback(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @OAuthTokenHook.Will('parseInput') + oauthToken(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @OAuthRegisterHook.Will('parseInput') + oauthRegister(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @ResourceHook.Will('parseInput') + resource(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @ListResourcesHook.Will('parseInput') + listResources(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @ListResourceTemplatesHook.Will('parseInput') + listResourceTemplates(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @PromptHook.Will('parseInput') + prompt(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @ListPromptsHook.Will('parseInput') + listPrompts(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @CompletionHook.Will('parseInput') + completion(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @AgentCallHook.Will('parseInput') + agentCall(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @JobHook.Will('parseInput') + job(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @ChannelSendHook.Will('parseInput') + channelSend(ctx: NotInAnyCtx) { + return ctx; + } + + // @ts-expect-error the ctx has no notAField + @ChannelListHook.Will('listChannels') + channelList(ctx: NotInAnyCtx) { + return ctx; + } +} + +export class EveryDecoratorRejectsAWronglyTypedParameter { + // @ts-expect-error toolName is a string + @ToolHook.Stage('execute') + stage(ctx: { toolName: number }) { + return ctx; + } + + // @ts-expect-error toolName is a string + @ToolHook.Did('execute') + did(ctx: { toolName: number }) { + return ctx; + } + + // @ts-expect-error toolName is a string + @ToolHook.Around('execute') + around(ctx: { toolName: number }, next: () => Promise) { + return [ctx, next]; + } + + // @ts-expect-error toolName is a string + @ToolHook.Finally('execute') + finally(ctx: { toolName: number }) { + return ctx; + } + + // @ts-expect-error a Will hook gets no second argument + @ToolHook.Will('execute') + extraArgument(ctx: FlowCtxOf<'tools:call-tool'>, next: () => Promise) { + return [ctx, next]; + } +} + +export class HooksThatCompile { + @ToolHook.Will('parseInput') + flowCtx(ctx: FlowCtxOf<'tools:call-tool'>) { + return [ctx.toolName, ctx.toolInput, ctx.request.clientInfo?.name, ctx.error?.message]; + } + + @ToolHook.Did('findTool') + structuralSubset(ctx: { toolName: string; state: { input?: { name: string } } }) { + return ctx.toolName; + } + + @ToolHook.Around('execute') + async around(ctx: FlowCtxOf<'tools:call-tool'>, next: () => Promise) { + await next(); + ctx.notify.listChanged('tools'); + } + + @ToolHook.Finally('finalize') + noParameter() { + return undefined; + } + + @ResourceHook.Will('execute') + resource(ctx: FlowCtxOf<'resources:read-resource'>) { + return ctx.uri; + } + + @PromptHook.Will('execute') + prompt(ctx: FlowCtxOf<'prompts:get-prompt'>) { + return ctx.promptName; + } +} + +@Tool({ name: 'typed-ctx', inputSchema: {} }) +export class ToolWithItsOwnHooks extends ToolContext { + @ToolHook.Will('findTool') + static beforeFind(ctx: FlowCtxOf<'tools:call-tool'>) { + return ctx.toolName; + } + + @ToolHook.Did('execute') + afterExecute() { + return this.input; + } + + async execute() { + return 'ok'; + } +} diff --git a/libs/sdk/src/hooks/__tests__/flow-hooks.exports.spec.ts b/libs/sdk/src/hooks/__tests__/flow-hooks.exports.spec.ts index 814b683f2..505ac557c 100644 --- a/libs/sdk/src/hooks/__tests__/flow-hooks.exports.spec.ts +++ b/libs/sdk/src/hooks/__tests__/flow-hooks.exports.spec.ts @@ -2,15 +2,21 @@ * The SDK exports hook decorators for the prompt and completion flows, as it does for tools and * resources. Exporting them puts those flows' augmentations in the published type graph, so * `FlowHooksOf('prompts:get-prompt' | 'prompts:list-prompts' | 'completion:complete')` typechecks - * for consumers (#678). + * for consumers (#678). Every exported hook decorator types the decorated method's parameter against + * its flow's ctx, so a hook that reads a property the flow does not have fails to compile. */ import 'reflect-metadata'; +import { resolve } from 'node:path'; + +import * as ts from 'typescript'; + import { type GetPromptResult } from '@frontmcp/protocol'; import { type HookMetadata } from '../../common'; import { type DirectMcpServer } from '../../direct/direct.types'; import { FrontMcpInstance } from '../../front-mcp/front-mcp'; +import * as sdk from '../../index'; import { App, CompletionHook, @@ -55,6 +61,55 @@ class GreetingPrompt extends PromptContext { @App({ id: 'prompts', name: 'Prompts', plugins: [PromptAuditPlugin], prompts: [GreetingPrompt] }) class PromptsApp {} +const typedCtxFixture = resolve(__dirname, 'fixtures/typed-hook-ctx.fixture.ts'); + +function strictCompileErrors(fileName: string): string[] { + const configFile = resolve(__dirname, '../../../tsconfig.spec.json'); + const parsed = ts.getParsedCommandLineOfConfigFile(configFile, undefined, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: () => undefined, + }); + if (!parsed) throw new Error(`${configFile} could not be read`); + const program = ts.createProgram([fileName], { + ...parsed.options, + strict: true, + noEmit: true, + composite: false, + declaration: false, + rootDir: undefined, + }); + const sourceFile = program.getSourceFile(fileName); + return [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)].map( + (diagnostic) => { + const line = sourceFile?.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line ?? -1; + return `${line + 1}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`; + }, + ); +} + +describe('hook decorator types', () => { + it('reject a parameter the flow ctx does not satisfy and accept the ctx or a subset of it, for every export', () => { + expect(strictCompileErrors(typedCtxFixture)).toEqual([]); + }, 120_000); + + it('are checked in the fixture for every exported hook helper', () => { + const decorators = Object.keys(FlowHooksOf('tools:call-tool')); + const isHookHelper = (value: unknown) => + typeof value === 'object' && + value !== null && + decorators.every((decorator) => typeof Reflect.get(value, decorator) === 'function'); + const exportedHelpers = Object.entries(sdk) + .filter(([, value]) => isHookHelper(value)) + .map(([name]) => name) + .sort(); + const fixture = ts.sys.readFile(typedCtxFixture) ?? ''; + const checkedHelpers = [...fixture.matchAll(/has no notAField\n {2}@(\w+)\.Will\(/g)].map(([, name]) => name); + + expect(exportedHelpers.length).toBeGreaterThan(10); + expect([...new Set(checkedHelpers)].sort()).toEqual(exportedHelpers); + }); +}); + describe('prompt and completion hook exports', () => { it('register hooks for their flows', () => { const flows = (collectHook(PromptAuditPlugin) as HookMetadata[]).map((hook) => `${hook.flow}:${hook.stage}`); diff --git a/libs/sdk/src/prompt/flows/get-prompt.flow.ts b/libs/sdk/src/prompt/flows/get-prompt.flow.ts index f7d1cb0d6..91e64f07a 100644 --- a/libs/sdk/src/prompt/flows/get-prompt.flow.ts +++ b/libs/sdk/src/prompt/flows/get-prompt.flow.ts @@ -138,6 +138,11 @@ export default class GetPromptFlow extends FlowBase { logger = this.scopeLogger.child('GetPromptFlow'); + /** The prompt's name: the one `findPrompt` resolved, else the name the request asks for. */ + get promptName(): string { + return this.state.prompt?.metadata.name ?? (this.state.input ?? this.input.request.params).name; + } + @Stage('parseInput') async parseInput() { this.logger.verbose('parseInput:start'); diff --git a/libs/sdk/src/resource/flows/read-resource.flow.ts b/libs/sdk/src/resource/flows/read-resource.flow.ts index 8e0db6505..731085245 100644 --- a/libs/sdk/src/resource/flows/read-resource.flow.ts +++ b/libs/sdk/src/resource/flows/read-resource.flow.ts @@ -126,6 +126,11 @@ export default class ReadResourceFlow extends FlowBase { logger = this.scopeLogger.child('ReadResourceFlow'); + /** The URI the read asks for. */ + get uri(): string { + return (this.state.input ?? this.input.request.params).uri; + } + @Stage('parseInput') async parseInput() { this.logger.verbose('parseInput:start'); diff --git a/libs/sdk/src/tool/flows/call-tool.flow.ts b/libs/sdk/src/tool/flows/call-tool.flow.ts index 2b12cecd6..6c9df98fa 100644 --- a/libs/sdk/src/tool/flows/call-tool.flow.ts +++ b/libs/sdk/src/tool/flows/call-tool.flow.ts @@ -252,6 +252,16 @@ export default class CallToolFlow extends FlowBase { logger = this.scopeLogger.child('CallToolFlow'); + /** The tool's name: the one `findTool` resolved, else the name the call asks for. */ + get toolName(): string { + return this.state.tool?.metadata.name ?? (this.state.input ?? this.input.request.params).name; + } + + /** The arguments the call passes to the tool, as `Will` hooks left them. */ + get toolInput(): Record | undefined { + return (this.state.input ?? this.input.request.params).arguments; + } + @Stage('parseInput') async parseInput() { this.logger.verbose('parseInput:start'); diff --git a/libs/skills/catalog/frontmcp-development/examples/create-plugin-hooks/basic-logging-plugin.md b/libs/skills/catalog/frontmcp-development/examples/create-plugin-hooks/basic-logging-plugin.md index c15ad0ebe..f1d4e3fa4 100644 --- a/libs/skills/catalog/frontmcp-development/examples/create-plugin-hooks/basic-logging-plugin.md +++ b/libs/skills/catalog/frontmcp-development/examples/create-plugin-hooks/basic-logging-plugin.md @@ -8,7 +8,7 @@ features: - "Using `ToolHook` pre-built export instead of calling `FlowHooksOf('tools:call-tool')` directly" - 'Destructuring `Will` and `Did` decorators from the hook object' - "Setting `priority: -100` on `@Will` so the logging hook runs before the stage's other `@Will` hooks (lower runs first)" - - 'Reading the tool name and arguments from `ctx.state.required.input` and logging with `ctx.logger`' + - 'Reading the tool name and arguments from `ctx.toolName` and `ctx.toolInput` and logging with `ctx.logger`' - 'Registering a plugin in the `plugins` array of `@App`' --- @@ -28,13 +28,12 @@ const { Will, Did } = ToolHook; export class LoggingPlugin { @Will('execute', { priority: -100 }) logBefore(ctx: FlowCtxOf<'tools:call-tool'>) { - const { name, arguments: toolArguments } = ctx.state.required.input; - ctx.logger.info(`Tool "${name}" called`, toolArguments); + ctx.logger.info(`Tool "${ctx.toolName}" called`, ctx.toolInput); } @Did('execute') logAfter(ctx: FlowCtxOf<'tools:call-tool'>) { - ctx.logger.info(`Tool "${ctx.state.required.input.name}" completed`); + ctx.logger.info(`Tool "${ctx.toolName}" completed`); } } ``` @@ -63,7 +62,7 @@ class MyServer {} - Using `ToolHook` pre-built export instead of calling `FlowHooksOf('tools:call-tool')` directly - Destructuring `Will` and `Did` decorators from the hook object - Setting `priority: -100` on `@Will` so the logging hook runs before the stage's other `@Will` hooks (lower runs first) -- Reading the tool name and arguments from `ctx.state.required.input` and logging with `ctx.logger` +- Reading the tool name and arguments from `ctx.toolName` and `ctx.toolInput` and logging with `ctx.logger` - Registering a plugin in the `plugins` array of `@App` ## Related diff --git a/libs/skills/catalog/frontmcp-development/examples/create-plugin-hooks/tool-level-hooks-and-stage-short-circuit.md b/libs/skills/catalog/frontmcp-development/examples/create-plugin-hooks/tool-level-hooks-and-stage-short-circuit.md index 3a7a27b79..5a723dd00 100644 --- a/libs/skills/catalog/frontmcp-development/examples/create-plugin-hooks/tool-level-hooks-and-stage-short-circuit.md +++ b/libs/skills/catalog/frontmcp-development/examples/create-plugin-hooks/tool-level-hooks-and-stage-short-circuit.md @@ -81,7 +81,7 @@ const { Stage } = ToolHook; export class MockPlugin { // Joins the `execute` stage and runs before the tool's own step (same priority). @Stage('execute', { - filter: (ctx) => ctx.state.required.input.name === 'fetch_weather', + filter: (ctx) => ctx.toolName === 'fetch_weather', }) mockWeather(ctx: FlowCtxOf<'tools:call-tool'>) { // Ends the flow with this result, so `fetch_weather`'s execute() does not run. diff --git a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md index 9dffce9be..36646180e 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md +++ b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md @@ -57,7 +57,7 @@ const { Stage, Will, Did, Around, Finally } = FlowHooksOf('tools:call-tool'); A stage runs its `@Will` hooks, then its body (wrapped by its `@Around` hooks), then its `@Did` hooks, then its `@Finally` hooks. - `@Did` runs only when the stage succeeded. When a `@Will` hook, the body or an `@Around` hook fails, the stage's `@Did` hooks are skipped. So a `@Did` hook that closes what a `@Will` hook opened (a span, a timer, a lock) leaks it on every failure. -- `@Finally` runs after the stage on every outcome: when it succeeded or responded, when it failed, when a `@Will` hook failed or responded in its place, when an `@Around` hook failed, and when `finalize` refused an answer the tool's output schema rejects (`InvalidOutputError`). Read the failure from `ctx.state.flowError`: the first error the run failed with (what a stage or hook threw, or passed to `ctx.fail()`), typed on `tools:call-tool`, `resources:read-resource`, `prompts:get-prompt` and `jobs:execute-job`. +- `@Finally` runs after the stage on every outcome: when it succeeded or responded, when it failed, when a `@Will` hook failed or responded in its place, when an `@Around` hook failed, and when `finalize` refused an answer the tool's output schema rejects (`InvalidOutputError`). Read the failure from `ctx.error`: the first error the run failed with (what a stage or hook threw, or passed to `ctx.fail()`), `undefined` while the run has none. It is `ctx.state.flowError`, which the state types only on `tools:call-tool`, `resources:read-resource`, `prompts:get-prompt` and `jobs:execute-job`; `ctx.error` is typed on every flow. - A `@Finally` hook cannot change the outcome. If it calls `ctx.respond()` or `ctx.fail()`, or throws, the flow logs a warning and goes on as if it had not: the client gets the same answer or the same error, and the stage's other `@Finally` hooks still run. - `@Finally` hooks of one stage run in `priority` order, like every other hook. A stage the flow never reaches (because an earlier stage failed) runs none of its hooks, `@Finally` included: hook `finalize` to see the end of every call. @@ -79,13 +79,13 @@ export class ExecuteTimerPlugin { stop(ctx: FlowCtxOf<'tools:call-tool'>) { const startedAt = this.startedAt.get(ctx); if (startedAt === undefined) return; - const outcome = ctx.state.flowError ? `failed: ${ctx.state.flowError.message}` : 'succeeded'; + const outcome = ctx.error ? `failed: ${ctx.error.message}` : 'succeeded'; ctx.logger.info(`execute ${outcome} in ${Date.now() - startedAt}ms`); } } ``` -Up to 1.10.15 there was no `@Finally`: a failure in `finalize` (an output schema refusing the answer) skipped the stage's `@Did` hooks and left `state.flowError` unset. +Up to 1.10.15 there was no `@Finally`: a failure in `finalize` (an output schema refusing the answer) skipped the stage's `@Did` hooks and left `state.flowError` unset. `ctx.error` is new in 1.10.16. ### What `@Stage` Does @@ -199,7 +199,7 @@ const reportCacheProvider = { export class CacheWarmerPlugin {} ``` -Listing the provider in `exports` too lets the app's tools resolve the cache with `this.get(ReportCache)`; `exports` takes provider definitions, not bare tokens. Inside a tool, resource or prompt, the scope itself is `this.scope`. Inside a flow hook, resolve it with `ctx.get(ScopeEntry)` (for example `ctx.get(ScopeEntry).apps.getApps()` in a server-scoped plugin): the flow's own `scope` is protected. See [Scope lifecycle hooks](https://frontmcp.dev/reference/sdk/scope). +Listing the provider in `exports` too lets the app's tools resolve the cache with `this.get(ReportCache)`; `exports` takes provider definitions, not bare tokens. Inside a tool, resource or prompt, the scope itself is `this.scope`. Inside a flow hook, resolve it with `ctx.get(ScopeEntry)` (for example `ctx.get(ScopeEntry).apps.getApps()` in a server-scoped plugin): the flow's own `scope` is protected. To tell clients a list changed, a hook calls `ctx.notify.listChanged('tools')` (see [What a Hook Receives](#what-a-hook-receives)). See [Scope lifecycle hooks](https://frontmcp.dev/reference/sdk/scope). ## Pre-Built Hook Type Exports @@ -248,6 +248,8 @@ The main stages of the `tools:call-tool` flow, in order (the full list is under A hook method on a plugin or provider receives the running flow as its first argument; type it `FlowCtxOf<''>`. An `@Around` hook also receives `next`. `this` is the plugin or provider instance. +The decorator checks that parameter against the flow it hooks. `FlowCtxOf<''>`, a structural subset of it (`ctx: { toolName: string }`), `unknown`, or no parameter compiles. A parameter with a property the flow does not have, a property of the wrong type, the ctx of another flow, or a second parameter on a hook other than `@Around` fails to compile, with `TS1241: Unable to resolve signature of method decorator when called as an expression` and a last line naming the property, such as `Property 'notAField' is missing in type 'CallToolFlow'`. Up to 1.10.15 the decorators did not check the parameter: a hook typed `ctx: { toolName: string }` compiled, read `undefined` and silently never matched. + | Member | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `state` | The flow's state, typed per flow: read `ctx.state.tool` (or `ctx.state.required.tool`, which throws when unset), write with `ctx.state.set(key, value)` | @@ -256,8 +258,55 @@ A hook method on a plugin or provider receives the running flow as its first arg | `get(token)` | Resolves a provider registered on the server, or a context-scoped provider of the current call | | `respond(output)` | Ends the flow with this output (ignored in a `@Finally` hook) | | `fail(error)` | Ends the flow with this error (an `Error`, such as an MCP error class; ignored in a `@Finally` hook) | +| `request` | The request the run serves: `sessionId`, `clientInfo`, `metadata`, `protocol` (see below); each `undefined` when the run has none | +| `notify` | `notify.listChanged('tools' \| 'resources' \| 'prompts')` sends `notifications//list_changed` to every connected session | +| `error` | The first failure of the run (`state.flowError`), for the stages and `@Finally` hooks after it; `undefined` while the run has none | + +The entry flows also name their entry: + +| Flow | Member | Description | +| ------------------------- | ------------ | --------------------------------------------------------------------------------------------------- | +| `tools:call-tool` | `toolName` | The tool's name: the one `findTool` resolved (its declared `name`), else the name the call asks for | +| `tools:call-tool` | `toolInput` | The arguments the tool runs with (`state.input.arguments`, as `@Will` hooks left them) | +| `resources:read-resource` | `uri` | The URI the read asks for | +| `prompts:get-prompt` | `promptName` | The prompt's name: the one `findPrompt` resolved, else the name the request asks for | + +`ctx.request` holds: + +- `sessionId`: the session the server verified for the request; `undefined` for a request without one (the stateless transports, MCP 2026-07-28, an `mcp-session-id` the server did not accept). +- `clientInfo`: the client's `name` and `version`, sent with the request (MCP 2026-07-28 `_meta`) or at its session's `initialize`. +- `metadata`: the request's `userAgent`, `contentType`, `accept`, `clientIp` and `customHeaders` (its `x-frontmcp-*` headers). +- `protocol`: the transport the session was opened over (`'streamable-http'`, `'legacy-sse'`, ...), when the session records it; an anonymous session (`auth.mode: 'public'`) does not. + +The flow has no `elapsed` or `tryGet`, and its `input`, `scope` and `tryGetContext()` are protected: read the request through `ctx.request`. In `tools:call-tool`, the tool entry is `ctx.state.tool` (set by `findTool`) and the tool instance `ctx.state.toolContext` (set from `createToolCallContext` on); to rewrite the call's name or arguments, set `ctx.state.input`. The state accepts only the keys the flow declares: `ctx.state.set('startedAt', Date.now())` does not compile. + +### Reading the Request and Announcing a List Change + +```typescript +import { FlowCtxOf, Plugin, ToolHook } from '@frontmcp/sdk'; + +const { Will, Did } = ToolHook; + +@Plugin({ name: 'client-audit' }) +export class ClientAuditPlugin { + @Will('execute', { filter: (ctx) => ctx.toolName !== 'health_check' }) + logCaller(ctx: FlowCtxOf<'tools:call-tool'>) { + const { clientInfo, sessionId, metadata } = ctx.request; + ctx.logger.info(`${clientInfo?.name ?? 'An unknown client'} called ${ctx.toolName}`, { + sessionId, + userAgent: metadata?.userAgent, + arguments: ctx.toolInput, + }); + } + + @Did('execute', { filter: (ctx) => ctx.toolName === 'install_extension' }) + announceNewTools(ctx: FlowCtxOf<'tools:call-tool'>) { + ctx.notify.listChanged('tools'); + } +} +``` -The flow has no `toolName`, `elapsed` or `tryGet`, and its `input` is protected. In `tools:call-tool`, read the tool name and arguments from `ctx.state.required.input` (`name`, `arguments`), the tool entry from `ctx.state.tool`, and the tool instance from `ctx.state.toolContext` (set from `createToolCallContext` on). The state accepts only the keys the flow declares: `ctx.state.set('startedAt', Date.now())` does not compile. +`notify.listChanged()` reaches every session connected to the server, over the transports that keep a session (streamable HTTP with its `GET` stream, legacy SSE, stdio, `connect()`); a client without a session (stateless HTTP, MCP 2026-07-28) receives nothing. Up to 1.10.15 a hook reached the request only through `ctx.get(FRONTMCP_CONTEXT)` and the notifications through `ctx.get(ScopeEntry).notifications`. ### Sharing Data Between Hooks @@ -284,7 +333,7 @@ export class TimingPlugin { stop(ctx: FlowCtxOf<'tools:call-tool'>) { const startedAt = this.startedAt.get(ctx); if (startedAt !== undefined) { - ctx.logger.info(`Tool "${ctx.state.required.input.name}" took ${Date.now() - startedAt}ms`); + ctx.logger.info(`Tool "${ctx.toolName}" took ${Date.now() - startedAt}ms`); } } } @@ -297,13 +346,13 @@ All five decorators accept an optional options object: ```typescript @Will('execute', { priority: 10, // Lower runs first (default: 0) - filter: (ctx) => ctx.state.required.input.name !== 'health_check', // Predicate to skip + filter: (ctx) => ctx.toolName !== 'health_check', // Predicate to skip appliesTo: 'own-app', // Reach of an app plugin's hook (default: 'own-app') }) ``` - **priority** (`number`) - Execution order when multiple hooks target the same stage. Lower values run first, for `@Will`, `@Did`, `@Around`, `@Finally` and `@Stage` alike. Default: `0`. -- **filter** (`(ctx) => boolean`) - A predicate that receives the flow context. Return `false` to skip this hook for the current invocation. +- **filter** (`(ctx) => boolean`) - A predicate that receives the flow context, typed like the hook's (`ctx.toolName`, `ctx.request`, ...). Return `false` to skip this hook for the current invocation. - **appliesTo** (`'own-app' | 'uncovered-apps'`) - A hook of a plugin installed on an app runs, in `tools/call`, `resources/read`, `prompts/get` and `completion/complete`, only for that app's entries (`'own-app'`), unless the plugin has `scope: 'server'`. With `'uncovered-apps'` it also runs for the entries of any app that has no instance of the same hook (same class and method) of its own or from a server-level plugin. Use it for gates an entry's metadata asks for (approval, feature flags), so the entry is not left ungated when the plugin sits on another app. Server-level plugins' hooks, and list-flow hooks, already run for every app. ## Examples @@ -319,13 +368,12 @@ const { Will, Did } = ToolHook; export class LoggingPlugin { @Will('execute', { priority: -100 }) logBefore(ctx: FlowCtxOf<'tools:call-tool'>) { - const { name, arguments: toolArguments } = ctx.state.required.input; - ctx.logger.info(`Tool "${name}" called`, toolArguments); + ctx.logger.info(`Tool "${ctx.toolName}" called`, ctx.toolInput); } @Did('execute') logAfter(ctx: FlowCtxOf<'tools:call-tool'>) { - ctx.logger.info(`Tool "${ctx.state.required.input.name}" completed`); + ctx.logger.info(`Tool "${ctx.toolName}" completed`); } } ``` @@ -362,8 +410,7 @@ export class CachePlugin { @Around('execute', { priority: 90 }) async cacheResults(ctx: FlowCtxOf<'tools:call-tool'>, next: () => Promise) { - const { name, arguments: toolArguments } = ctx.state.required.input; - const key = `${name}:${JSON.stringify(toolArguments)}`; + const key = `${ctx.toolName}:${JSON.stringify(ctx.toolInput)}`; const toolContext = ctx.state.required.toolContext; const cached = this.cache.get(key); @@ -394,7 +441,7 @@ const { Stage } = ToolHook; @Plugin({ name: 'mock-plugin' }) export class MockPlugin { @Stage('execute', { - filter: (ctx) => ctx.state.required.input.name === 'fetch_weather', + filter: (ctx) => ctx.toolName === 'fetch_weather', }) mockWeather(ctx: FlowCtxOf<'tools:call-tool'>) { ctx.respond({ content: [{ type: 'text', text: '72F and sunny' }] }); @@ -533,7 +580,8 @@ An instance method on an earlier stage, or `Will`/`Around` on `createToolCallCon | Around next() | `await next();` | Forgetting to call `next()` in `@Around` | Omitting `next()` skips the wrapped stage; its `@Did` hooks still run | | Cleanup after a stage | `@Finally('execute')` closes the span or timer `@Will` opened | Closing it in `@Did('execute')` | `@Did` is skipped when the stage fails; `@Finally` runs on every outcome | | Around error recovery | `catch { ctx.state.required.toolContext.output = fallback; }` | Catching a rejected `next()` without setting the output | Returning normally handles the failure: the stage counts as successful | -| Filter predicate | `filter: (ctx) => ctx.state.required.input.name !== 'health_check'` | Checking tool name inside the hook body and returning early | A filtered-out hook is skipped cleanly; for `@Around`, the stage still runs | +| Filter predicate | `filter: (ctx) => ctx.toolName !== 'health_check'` | Checking tool name inside the hook body and returning early | A filtered-out hook is skipped cleanly; for `@Around`, the stage still runs | +| Hook parameter | `ctx: FlowCtxOf<'tools:call-tool'>`, then `ctx.toolName` | `ctx: { toolName: string }` copied from another flow's hook | The decorator checks the parameter against the flow; the full type documents it | | Tool-level hooks | `@Will('execute')` on a `@Tool` class (scoped to that tool) | `@Will('execute')` on a `@Plugin` class expecting tool-scoped behavior | Plugin hooks fire for every tool of the app; tool-level hooks only for that tool | ## Verification Checklist @@ -543,6 +591,7 @@ An instance method on an earlier stage, or `Will`/`Around` on `createToolCallCon - [ ] Hook decorator is obtained from `FlowHooksOf(flowName)` or a pre-built export (e.g., `ToolHook`) - [ ] Stage name matches an actual stage in the targeted flow (e.g., `execute`, `validateInput`) - [ ] Plugin with hooks is registered in `plugins` array of `@App` or `@FrontMcp` +- [ ] Each hook's parameter is `FlowCtxOf<''>` (or a subset of it), so the compiler checks what the hook reads ### Runtime @@ -555,16 +604,17 @@ An instance method on an earlier stage, or `Will`/`Around` on `createToolCallCon ## Troubleshooting -| Problem | Cause | Solution | -| ------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| Hook never fires | Plugin not registered in `plugins` array | Add plugin class to `@App` or `@FrontMcp` `plugins` array | -| `InvalidHookFlowError` at startup | Entry-class hook that could never run | Make early-stage hooks `static`; move list-flow hooks (and a `@Job` hook for a flow other than `jobs:execute-job`) to a plugin or a provider | -| Hook fires for wrong flow | Used wrong flow name in `FlowHooksOf` | Verify flow name matches (e.g., `'tools:call-tool'` not `'tool:call'`) | -| `@Around` skips the stage entirely | `next()` not called inside the around handler | Always `await next()` to execute the wrapped stage | -| Multiple hooks execute in wrong order | Priorities not set or conflicting | Set explicit `priority` values; lower numbers execute first | -| The tool still runs despite a plugin `@Stage` | `@Stage` adds a step; it does not replace the stage | End the flow with `ctx.respond(result)`, or use `@Around` without calling `next()` | -| A span or timer opened in `@Will` stays open | It is closed in `@Did`, which a failed stage skips | Close it in `@Finally` on the same stage | -| `ctx.respond()` in a `@Finally` hook does nothing | `@Finally` cannot change a stage's outcome | Answer from `@Will`, `@Stage` or `@Around`; the flow logs a warning for each ignored `@Finally` response or throw | +| Problem | Cause | Solution | +| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Hook never fires | Plugin not registered in `plugins` array | Add plugin class to `@App` or `@FrontMcp` `plugins` array | +| `InvalidHookFlowError` at startup | Entry-class hook that could never run | Make early-stage hooks `static`; move list-flow hooks (and a `@Job` hook for a flow other than `jobs:execute-job`) to a plugin or a provider | +| Hook fires for wrong flow | Used wrong flow name in `FlowHooksOf` | Verify flow name matches (e.g., `'tools:call-tool'` not `'tool:call'`) | +| `@Around` skips the stage entirely | `next()` not called inside the around handler | Always `await next()` to execute the wrapped stage | +| Multiple hooks execute in wrong order | Priorities not set or conflicting | Set explicit `priority` values; lower numbers execute first | +| The tool still runs despite a plugin `@Stage` | `@Stage` adds a step; it does not replace the stage | End the flow with `ctx.respond(result)`, or use `@Around` without calling `next()` | +| A span or timer opened in `@Will` stays open | It is closed in `@Did`, which a failed stage skips | Close it in `@Finally` on the same stage | +| `ctx.respond()` in a `@Finally` hook does nothing | `@Finally` cannot change a stage's outcome | Answer from `@Will`, `@Stage` or `@Around`; the flow logs a warning for each ignored `@Finally` response or throw | +| `TS1241: Unable to resolve signature of method decorator` on a hook | The method's parameter asks for what the flow does not have: a missing or wrongly typed property, another flow's ctx, a second parameter | Type it `FlowCtxOf<''>` and read the ctx members (`ctx.toolName`, `ctx.request`, ...); only `@Around` takes `next` | ## Examples diff --git a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md index ad38d5f01..ae8976bf9 100644 --- a/libs/skills/catalog/frontmcp-development/references/decorators-guide.md +++ b/libs/skills/catalog/frontmcp-development/references/decorators-guide.md @@ -659,13 +659,13 @@ class DeployPipeline {} **Variants:** -| Decorator | Timing | Description | -| ---------- | ------------- | ------------------------------------------------------------------------------------------------- | -| `@Will` | Before | Runs before the stage | -| `@Did` | After success | Runs after the stage succeeds; skipped when it fails | -| `@Stage` | During | Runs as part of the stage itself | -| `@Around` | Wrapping | Wraps the stage; `await next()` runs it | -| `@Finally` | After, always | Runs after the stage whatever its outcome; reads `ctx.state.flowError`; cannot change the outcome | +| Decorator | Timing | Description | +| ---------- | ------------- | --------------------------------------------------------------------------------------- | +| `@Will` | Before | Runs before the stage | +| `@Did` | After success | Runs after the stage succeeds; skipped when it fails | +| `@Stage` | During | Runs as part of the stage itself | +| `@Around` | Wrapping | Wraps the stage; `await next()` runs it | +| `@Finally` | After, always | Runs after the stage whatever its outcome; reads `ctx.error`; cannot change the outcome | Declare hooks on a `@Plugin` (or a provider) to run them for every tool, resource or prompt of the app it is registered on: @@ -692,19 +692,22 @@ export class AuditPlugin { async timeResourceRead(ctx: FlowCtxOf<'resources:read-resource'>, next: () => Promise) { const startedAt = Date.now(); await next(); - ctx.logger.info(`Read ${ctx.state.required.input.uri} in ${Date.now() - startedAt}ms`); + ctx.logger.info(`Read ${ctx.uri} in ${Date.now() - startedAt}ms`); } } ``` Register the plugin in `@App({ plugins: [AuditPlugin] })`, or in `@FrontMcp({ plugins: [AuditPlugin] })` to run its hooks for every app. -A hook method receives the running flow, typed `FlowCtxOf<''>`: +A hook method receives the running flow, typed `FlowCtxOf<''>`. The decorator checks the parameter: the flow's ctx, a structural subset of it, `unknown` or none compiles, and a property the flow does not have (or of the wrong type) fails to compile (up to 1.10.15 any parameter compiled). +- `ctx.toolName` and `ctx.toolInput` (`tools:call-tool`), `ctx.uri` (`resources:read-resource`) and `ctx.promptName` (`prompts:get-prompt`) name the entry and its input. +- `ctx.request` holds the request's `sessionId`, `clientInfo`, `metadata` (user agent, client IP, `x-frontmcp-*` headers) and `protocol`; each is `undefined` when the run has none. +- `ctx.notify.listChanged('tools' | 'resources' | 'prompts')` tells every connected client the list changed. - `ctx.state` holds the flow's data (for `tools:call-tool`: `input`, `tool`, `toolContext`, ...); `ctx.state.required.` throws when the key is unset. - `ctx.logger` logs, and `ctx.fail(error)` or `ctx.respond(output)` ends the flow. - An `@Around` hook also receives `next`, which runs the stage. -- A `@Finally` hook runs when the stage failed too, so close spans, timers and locks there rather than in `@Did`; its `ctx.respond()`, `ctx.fail()` and throws are logged and ignored. +- A `@Finally` hook runs when the stage failed too, so close spans, timers and locks there rather than in `@Did`; it reads the failure from `ctx.error`, and its `ctx.respond()`, `ctx.fail()` and throws are logged and ignored. - `this` is the plugin or provider instance. A hook declared on a `@Tool`, `@Resource` or `@Prompt` class runs only for that entry, on its context (`this.input`, `this.get()`). See `create-plugin-hooks` for the stages of each flow, `priority`, `filter` and entry-class hooks. diff --git a/libs/skills/catalog/skills-manifest.json b/libs/skills/catalog/skills-manifest.json index 0a9a4646c..7b0692434 100644 --- a/libs/skills/catalog/skills-manifest.json +++ b/libs/skills/catalog/skills-manifest.json @@ -1865,7 +1865,7 @@ "Using `ToolHook` pre-built export instead of calling `FlowHooksOf('tools:call-tool')` directly", "Destructuring `Will` and `Did` decorators from the hook object", "Setting `priority: -100` on `@Will` so the logging hook runs before the stage's other `@Will` hooks (lower runs first)", - "Reading the tool name and arguments from `ctx.state.required.input` and logging with `ctx.logger`", + "Reading the tool name and arguments from `ctx.toolName` and `ctx.toolInput` and logging with `ctx.logger`", "Registering a plugin in the `plugins` array of `@App`" ] }, From 8859221f16d431c96313bf5bfd5c5f8d07875db0 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 03:30:08 +0200 Subject: [PATCH 2/3] fix(sdk): keep ctx.toolName on a tool's id and assert each must-fail hook fails with TS1241 ctx.toolName read metadata.name once findTool resolved the tool, so for a tool declaring an id it changed mid-run from the called id to the display name; it now reads the entry's name (id, else name). The must-fail type tests recompile their fixtures without the @ts-expect-error directives and require one TS1241 per case, so a wrong stage name or a missing export no longer passes. The catalog notes protected members, the MethodDecorator type and what toolInput holds. --- .../e2e/dts-consumer.e2e.spec.ts | 29 +++++++++++++++++-- .../flows/__tests__/flow-accessors.spec.ts | 18 ++++++++++-- .../__tests__/flow-hooks.exports.spec.ts | 26 ++++++++++++++--- libs/sdk/src/tool/flows/call-tool.flow.ts | 4 +-- .../references/create-plugin-hooks.md | 14 ++++----- .../references/decorators-guide.md | 2 +- 6 files changed, 75 insertions(+), 18 deletions(-) diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index c92a110ef..fcbfb5a3f 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -34,7 +34,7 @@ const fixtureFiles = [ 'output-schemas-must-compile.ts', ].map((fileName) => path.join(fixtureDir, fileName)); -function compileFixture(): { diagnostics: FixtureDiagnostic[]; sourceFiles: string[] } { +function compileFixture(withoutExpectErrors?: string): { diagnostics: FixtureDiagnostic[]; sourceFiles: string[] } { const sdkPathOverride = sdkDeclarationsOverride ? { paths: { '@frontmcp/sdk': [path.join(sdkDeclarationsDir, 'index.d.ts')] } } : {}; @@ -47,13 +47,23 @@ function compileFixture(): { diagnostics: FixtureDiagnostic[]; sourceFiles: stri if (!parsedConfig) { throw new Error(`Unable to read ${path.join(fixtureDir, 'tsconfig.json')}`); } - const program = ts.createProgram({ rootNames: parsedConfig.fileNames, options: parsedConfig.options }); + const host = ts.createCompilerHost(parsedConfig.options); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, ...rest) => + path.resolve(fileName) === withoutExpectErrors + ? ts.createSourceFile(fileName, withoutExpectErrorsText(fileName), languageVersion) + : getSourceFile(fileName, languageVersion, ...rest); + const program = ts.createProgram({ rootNames: parsedConfig.fileNames, options: parsedConfig.options, host }); return { diagnostics: [...parsedConfig.errors, ...ts.getPreEmitDiagnostics(program)].map(toFixtureDiagnostic), sourceFiles: program.getSourceFiles().map((sourceFile) => path.resolve(sourceFile.fileName)), }; } +function withoutExpectErrorsText(fileName: string): string { + return (ts.sys.readFile(fileName) ?? '').replaceAll('// @ts-expect-error', '// expected:'); +} + function describeEach(diagnostics: FixtureDiagnostic[]): string[] { return diagnostics.map(({ location, summary }) => `${location} ${summary}`); } @@ -100,6 +110,21 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('hooks-must-fail.ts'))).toEqual([]); }); + it('fails each hooks-must-fail.ts case with TS1241 once its @ts-expect-error is removed', () => { + const mustFail = path.join(fixtureDir, 'hooks-must-fail.ts'); + const expectedFailures = (ts.sys.readFile(mustFail) ?? '') + .split('\n') + .flatMap((line, index) => (line.includes('// @ts-expect-error') ? [index + 2] : [])) + .map( + (line) => + `${path.relative(workspaceRoot, mustFail)}:${line} TS1241: Unable to resolve signature of method decorator when called as an expression.`, + ); + const failures = compileFixture(mustFail).diagnostics.filter((diagnostic) => diagnostic.fileName === mustFail); + + expect(expectedFailures.length).toBeGreaterThan(10); + expect(describeEach(failures)).toEqual(expectedFailures); + }); + it('types a custom host without enhancedHandler, and names Scope for the web-fetch helpers (#819)', () => { expect(describeEach(inFixtureFile('host-must-compile.ts'))).toEqual([]); }); diff --git a/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts b/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts index 2139afcbd..f09fe728d 100644 --- a/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts +++ b/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts @@ -91,6 +91,13 @@ class SayHelloTool extends ToolContext { } } +@Tool({ id: 'archive_note', name: 'Archive a note', inputSchema: {} }) +class ArchiveNoteTool extends ToolContext { + async execute() { + return 'archived'; + } +} + @Tool({ name: 'ledger', inputSchema: {} }) class LedgerTool extends ToolContext { async execute(): Promise { @@ -116,7 +123,7 @@ class GreetingPrompt extends PromptContext { id: 'notes', name: 'Notes', plugins: [AccessorProbe], - tools: [SayHelloTool, LedgerTool], + tools: [SayHelloTool, ArchiveNoteTool, LedgerTool], resources: [ReadmeResource], prompts: [GreetingPrompt], }) @@ -155,7 +162,14 @@ describe('flow accessors in hooks', () => { expect(at('did:findTool')).toEqual({ at: 'did:findTool', toolName: 'say_hello' }); }); - it('toolInput is the arguments the tool runs with', async () => { + it('toolName of a tool with an id stays the name tools/list lists it under', async () => { + await server.callTool('archive_note', {}); + + expect(at('will:findTool')).toEqual({ at: 'will:findTool', toolName: 'archive_note' }); + expect(at('did:findTool')).toEqual({ at: 'did:findTool', toolName: 'archive_note' }); + }); + + it('toolInput is the arguments the call passes to the tool', async () => { await server.callTool('say_hello', { mood: 'calm' }); expect(at('will:execute')?.['toolInput']).toEqual({ mood: 'calm' }); diff --git a/libs/sdk/src/hooks/__tests__/flow-hooks.exports.spec.ts b/libs/sdk/src/hooks/__tests__/flow-hooks.exports.spec.ts index 505ac557c..2fa7ecfcd 100644 --- a/libs/sdk/src/hooks/__tests__/flow-hooks.exports.spec.ts +++ b/libs/sdk/src/hooks/__tests__/flow-hooks.exports.spec.ts @@ -63,26 +63,33 @@ class PromptsApp {} const typedCtxFixture = resolve(__dirname, 'fixtures/typed-hook-ctx.fixture.ts'); -function strictCompileErrors(fileName: string): string[] { +function strictCompileErrors(fileName: string, sourceText?: string): string[] { const configFile = resolve(__dirname, '../../../tsconfig.spec.json'); const parsed = ts.getParsedCommandLineOfConfigFile(configFile, undefined, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => undefined, }); if (!parsed) throw new Error(`${configFile} could not be read`); - const program = ts.createProgram([fileName], { + const options = { ...parsed.options, strict: true, noEmit: true, composite: false, declaration: false, rootDir: undefined, - }); + }; + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (name, languageVersion, ...rest) => + sourceText !== undefined && resolve(name) === fileName + ? ts.createSourceFile(name, sourceText, languageVersion) + : getSourceFile(name, languageVersion, ...rest); + const program = ts.createProgram([fileName], options, host); const sourceFile = program.getSourceFile(fileName); return [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)].map( (diagnostic) => { const line = sourceFile?.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line ?? -1; - return `${line + 1}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`; + return `${line + 1}: TS${diagnostic.code} ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`; }, ); } @@ -92,6 +99,17 @@ describe('hook decorator types', () => { expect(strictCompileErrors(typedCtxFixture)).toEqual([]); }, 120_000); + it('fail with TS1241, on each decorator the fixture expects to fail, once its @ts-expect-error is removed', () => { + const fixture = ts.sys.readFile(typedCtxFixture) ?? ''; + const expectedFailures = fixture + .split('\n') + .flatMap((line, index) => (line.includes('// @ts-expect-error') ? [`${index + 2}: TS1241`] : [])); + const errors = strictCompileErrors(typedCtxFixture, fixture.replaceAll('// @ts-expect-error', '// expected:')); + + expect(expectedFailures.length).toBeGreaterThan(20); + expect(errors.map((error) => error.split(' ', 2).join(' '))).toEqual(expectedFailures); + }, 120_000); + it('are checked in the fixture for every exported hook helper', () => { const decorators = Object.keys(FlowHooksOf('tools:call-tool')); const isHookHelper = (value: unknown) => diff --git a/libs/sdk/src/tool/flows/call-tool.flow.ts b/libs/sdk/src/tool/flows/call-tool.flow.ts index 2bfddb39a..5a23f470d 100644 --- a/libs/sdk/src/tool/flows/call-tool.flow.ts +++ b/libs/sdk/src/tool/flows/call-tool.flow.ts @@ -262,9 +262,9 @@ export default class CallToolFlow extends FlowBase { logger = this.scopeLogger.child('CallToolFlow'); - /** The tool's name: the one `findTool` resolved, else the name the call asks for. */ + /** The tool's name (its `id`, else its `name`) once `findTool` resolved it, else the name the call asks for. */ get toolName(): string { - return this.state.tool?.metadata.name ?? (this.state.input ?? this.input.request.params).name; + return this.state.tool?.name ?? (this.state.input ?? this.input.request.params).name; } /** The arguments the call passes to the tool, as `Will` hooks left them. */ diff --git a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md index 36646180e..09507ad9c 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md +++ b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md @@ -248,7 +248,7 @@ The main stages of the `tools:call-tool` flow, in order (the full list is under A hook method on a plugin or provider receives the running flow as its first argument; type it `FlowCtxOf<''>`. An `@Around` hook also receives `next`. `this` is the plugin or provider instance. -The decorator checks that parameter against the flow it hooks. `FlowCtxOf<''>`, a structural subset of it (`ctx: { toolName: string }`), `unknown`, or no parameter compiles. A parameter with a property the flow does not have, a property of the wrong type, the ctx of another flow, or a second parameter on a hook other than `@Around` fails to compile, with `TS1241: Unable to resolve signature of method decorator when called as an expression` and a last line naming the property, such as `Property 'notAField' is missing in type 'CallToolFlow'`. Up to 1.10.15 the decorators did not check the parameter: a hook typed `ctx: { toolName: string }` compiled, read `undefined` and silently never matched. +The decorator checks that parameter against the flow it hooks. `FlowCtxOf<''>`, a structural subset of it (`ctx: { toolName: string }`), `unknown`, or no parameter compiles. A parameter with a property the flow does not have (or keeps protected, such as `input` or `scope`), a property of the wrong type, the ctx of another flow, or a second parameter on a hook other than `@Around` fails to compile, with `TS1241: Unable to resolve signature of method decorator when called as an expression` and a last line naming the property, such as `Property 'notAField' is missing in type 'CallToolFlow'`. Up to 1.10.15 the decorators did not check the parameter: a hook typed `ctx: { toolName: string }` compiled, read `undefined` and silently never matched. A hook decorator is a `FlowHookDecorator`, not a `MethodDecorator`: a helper that returns one leaves its return type inferred. | Member | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -264,12 +264,12 @@ The decorator checks that parameter against the flow it hooks. `FlowCtxOf<''>`. The decorator checks the parameter: the flow's ctx, a structural subset of it, `unknown` or none compiles, and a property the flow does not have (or of the wrong type) fails to compile (up to 1.10.15 any parameter compiled). +A hook method receives the running flow, typed `FlowCtxOf<''>`. The decorator checks the parameter: the flow's ctx, a structural subset of it, `unknown` or none compiles, and a property the flow does not have (or keeps protected, or of the wrong type) fails to compile (up to 1.10.15 any parameter compiled). - `ctx.toolName` and `ctx.toolInput` (`tools:call-tool`), `ctx.uri` (`resources:read-resource`) and `ctx.promptName` (`prompts:get-prompt`) name the entry and its input. - `ctx.request` holds the request's `sessionId`, `clientInfo`, `metadata` (user agent, client IP, `x-frontmcp-*` headers) and `protocol`; each is `undefined` when the run has none. From 3b72bcecb2692ab9c306703826baa3b913b5d403 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 03:32:56 +0200 Subject: [PATCH 3/3] fix(sdk): let a flow that assigns its own request, notify or error keep that value --- .../sdk/src/common/interfaces/flow.interface.ts | 17 +++++++++++++++++ .../src/flows/__tests__/flow-accessors.spec.ts | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/libs/sdk/src/common/interfaces/flow.interface.ts b/libs/sdk/src/common/interfaces/flow.interface.ts index e65d73d33..95ea675c6 100644 --- a/libs/sdk/src/common/interfaces/flow.interface.ts +++ b/libs/sdk/src/common/interfaces/flow.interface.ts @@ -41,6 +41,11 @@ export interface FlowNotifier { listChanged(kind: ListChangedKind): void; } +// A flow subclass that already assigns one of these names keeps its own value. +function shadowWithOwnValue(target: object, key: 'request' | 'notify' | 'error', value: unknown): void { + Object.defineProperty(target, key, { value, writable: true, configurable: true, enumerable: true }); +} + function setStackTraceLimit(limit: number): void { try { Error.stackTraceLimit = limit; @@ -156,6 +161,10 @@ export abstract class FlowBase { }; } + set request(value: FlowRequestInfo) { + shadowWithOwnValue(this, 'request', value); + } + /** Notifications the run sends to the connected clients. */ get notify(): FlowNotifier { return { @@ -163,12 +172,20 @@ export abstract class FlowBase { }; } + set notify(value: FlowNotifier) { + shadowWithOwnValue(this, 'notify', value); + } + /** The first failure of the run (`state.flowError`), for the stages and `Finally` hooks after it. */ get error(): Error | undefined { const flowError: unknown = this.state.get('flowError' as never); return flowError instanceof Error ? flowError : undefined; } + set error(value: Error | undefined) { + shadowWithOwnValue(this, 'error', value); + } + respond(output: FlowOutputOf) { throw FlowControl.respond((this.metadata.outputSchema as z.ZodObject).parse(output)); } diff --git a/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts b/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts index f09fe728d..050916f3b 100644 --- a/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts +++ b/libs/sdk/src/flows/__tests__/flow-accessors.spec.ts @@ -320,6 +320,18 @@ describe('flow accessors outside a request', () => { expect(broadcastNotification).toHaveBeenCalledWith(`notifications/${kind}/list_changed`); }); + it.each(['request', 'notify', 'error'] as const)( + 'a flow that assigns its own %s keeps that value instead of throwing', + (accessor) => { + const flow = bareFlow(); + const ownValue = { set: 'by the flow' }; + + expect(Reflect.set(flow, accessor, ownValue)).toBe(true); + expect(Reflect.get(flow, accessor)).toBe(ownValue); + expect(Reflect.get(bareFlow(), accessor)).not.toBe(ownValue); + }, + ); + it('error is state.flowError', () => { const flow = bareFlow(); expect(flow.error).toBeUndefined();