Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8cfa608
fix(sdk): type hook decorators against the flow ctx and add ctx acces…
frontegg-david Nov 2, 2026
9bb9750
Merge remote-tracking branch 'origin/fix/04-output-schemas' into fix/…
frontegg-david Nov 2, 2026
8859221
fix(sdk): keep ctx.toolName on a tool's id and assert each must-fail …
frontegg-david Nov 2, 2026
3b72bce
fix(sdk): let a flow that assigns its own request, notify or error ke…
frontegg-david Nov 2, 2026
6a9b4ce
Merge remote-tracking branch 'origin/fix/04-output-schemas' into fix/…
frontegg-david Nov 2, 2026
2104e6b
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
3edb44f
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
8c8cd0a
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
5b6157c
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
f105634
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
e784d95
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
8d6f938
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
d6e06c7
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
08751bb
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
3da68ac
Merge remote-tracking branch 'refs/remotes/origin/fix/04-output-schem…
frontegg-david Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ 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',
'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')] } }
: {};
Expand All @@ -46,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}`);
}
Expand Down Expand Up @@ -91,10 +102,29 @@ 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('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([]);
});
Expand Down
72 changes: 72 additions & 0 deletions apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import {
FrontMcp,
ListPromptsHook,
PromptHook,
ResourceHook,
Tool,
ToolContext,
ToolHook,
z,
type FlowCtxOf,
type FlowRequestInfo,
type FrontMcpAdapterResponse,
type ScopeEntry,
} from '@frontmcp/sdk';
Expand Down Expand Up @@ -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<string, unknown> | 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<unknown>): Promise<void> {
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' });
Expand Down
103 changes: 103 additions & 0 deletions apps/e2e/demo-e2e-dts-consumer/fixture/hooks-must-fail.ts
Original file line number Diff line number Diff line change
@@ -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 {
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<string>): 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<string, unknown> }): 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;
}
}
1 change: 1 addition & 0 deletions apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"output-schemas-must-compile.ts"
Expand Down
51 changes: 51 additions & 0 deletions apps/e2e/demo-e2e-hooks/e2e/hook-ctx-accessors.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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<RequestProbe>({
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();
});
});
5 changes: 4 additions & 1 deletion apps/e2e/demo-e2e-hooks/src/apps/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ 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';
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,
Expand All @@ -24,6 +26,7 @@ import { PlainEchoTool, StaticGuardedEchoTool } from './tools/static-guarded.too
RefusedAnswerTool,
FailingExecuteTool,
GetFinallyLogTool,
ProbeRequestTool,
],
resources: [AuditLogResource],
prompts: [AuditSummaryPrompt],
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
}
}
12 changes: 12 additions & 0 deletions apps/e2e/demo-e2e-hooks/src/apps/audit/tools/probe-request.tool.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
Loading
Loading