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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => {
expect(describeEach(inFixtureFile('prompts-must-compile.ts'))).toEqual([]);
});

it('types the prompt and completion hooks, the scope channel accessors and server-level adapters', () => {
it('types the prompt, completion and Finally hooks, the scope channel accessors and server-level adapters', () => {
expect(describeEach(inFixtureFile('hooks-must-compile.ts'))).toEqual([]);
});

Expand Down
14 changes: 14 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,6 +7,7 @@ import {
FrontMcp,
ListPromptsHook,
PromptHook,
ToolHook,
type FlowCtxOf,
type FrontMcpAdapterResponse,
type ScopeEntry,
Expand Down Expand Up @@ -37,6 +38,19 @@ export class PromptAudit {

@CompletionHook.Did('complete')
afterComplete(): void {}

@GetPrompt.Finally('execute')
afterPromptWhateverItsOutcome(ctx: FlowCtxOf<'prompts:get-prompt'>): void {
void ctx.state.flowError?.message;
}
}

// Finally hooks run after a stage whatever its outcome, and read the failure from state.flowError.
export class CallAudit {
@ToolHook.Finally('finalize')
afterCall(ctx: FlowCtxOf<'tools:call-tool'>): void {
void ctx.state.flowError?.message;
}
}

// ScopeEntry declares the channel accessors, so no cast is needed.
Expand Down
81 changes: 81 additions & 0 deletions apps/e2e/demo-e2e-hooks/e2e/finally-hooks.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* E2E: Finally hooks run after a stage whatever its outcome, and never change what the client gets.
*
* The finally-recorder plugin records how the `execute` and `finalize` stages of a call ended. Before its
* recorders run, two other Finally hooks of `finalize` try to answer in place of the call and to fail it: both
* are ignored. `refused-answer` answers with a value its output schema refuses (a `finalize` failure, which used
* to skip every hook after the stage), and `failing-execute` fails in `execute`.
*/
import { expect, test, type ServerFixture } from '@frontmcp/testing';

interface FinallyLog {
entries: Array<{ stage: string; outcome: string }>;
}

/** The server's logs once they contain `text`, or as they are after `timeoutMs`: its output reaches us after the reply. */
async function logsOnceTheyContain(server: ServerFixture, text: string, timeoutMs = 5000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (!server.getLogs().join('\n').includes(text) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
return server.getLogs().join('\n');
}

test.describe('Finally hooks E2E', () => {
test.use({
server: 'apps/e2e/demo-e2e-hooks/src/main.ts',
project: 'demo-e2e-hooks',
publicMode: true,
});

test('see an answer the output schema refuses, and the client still gets INVALID_OUTPUT', async ({ mcp }) => {
await mcp.tools.call('clear-audit-log', {});

const result = await mcp.tools.call('refused-answer', {});

expect(result).toBeError();
expect(result.raw._meta?.['code']).toBe('INVALID_OUTPUT');
expect(result).not.toHaveTextContent('replaced by a Finally hook');
const log = await mcp.tools.call('get-finally-log', { toolName: 'refused-answer' });
expect(log.json<FinallyLog>().entries).toEqual([
{ stage: 'execute', outcome: 'ok' },
{ stage: 'finalize', outcome: 'InvalidOutputError' },
]);
});

test('see an execute that failed, and the client still gets its error', async ({ mcp }) => {
await mcp.tools.call('clear-audit-log', {});

const result = await mcp.tools.call('failing-execute', {});

expect(result).toBeError();
expect(result.text()).toBe('The ledger is unreachable');
const log = await mcp.tools.call('get-finally-log', { toolName: 'failing-execute' });
expect(log.json<FinallyLog>().entries).toEqual([
{ stage: 'execute', outcome: 'PublicMcpError' },
{ stage: 'finalize', outcome: 'PublicMcpError' },
]);
});

test('see a call that succeeded, and its answer is left as it was', async ({ mcp }) => {
await mcp.tools.call('clear-audit-log', {});

const result = await mcp.tools.call('audited-tool', { message: 'finally' });

expect(result).toBeSuccessful();
expect(result.json<{ message: string }>().message).toBe('Processed: finally');
const log = await mcp.tools.call('get-finally-log', { toolName: 'audited-tool' });
expect(log.json<FinallyLog>().entries).toEqual([
{ stage: 'execute', outcome: 'ok' },
{ stage: 'finalize', outcome: 'ok' },
]);
});

test('log what an ignored Finally hook threw', async ({ mcp, server }) => {
server.clearLogs();

await mcp.tools.call('audited-tool', { message: 'logged' });

expect(await logsOnceTheyContain(server, 'the recorder store is down')).toContain('the recorder store is down');
});
});
26 changes: 26 additions & 0 deletions apps/e2e/demo-e2e-hooks/src/apps/audit/data/finally-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* What the Finally hooks of the finally-recorder plugin saw, per tool call stage.
*/
export interface FinallyEntry {
toolName: string;
stage: string;
outcome: string;
}

class FinallyLogStore {
private entries: FinallyEntry[] = [];

add(entry: FinallyEntry): void {
this.entries.push(entry);
}

entriesFor(toolName: string): FinallyEntry[] {
return this.entries.filter((entry) => entry.toolName === toolName);
}

clear(): void {
this.entries = [];
}
}

export const finallyLog = new FinallyLogStore();
17 changes: 15 additions & 2 deletions apps/e2e/demo-e2e-hooks/src/apps/audit/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { App } from '@frontmcp/sdk';

import AuditPlugin from './plugins/audit.plugin';
import FinallyRecorderPlugin from './plugins/finally-recorder.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 RefusedAnswerTool from './tools/refused-answer.tool';
import { PlainEchoTool, StaticGuardedEchoTool } from './tools/static-guarded.tool';

@App({
name: 'audit',
plugins: [AuditPlugin],
tools: [AuditedTool, GetAuditLogTool, ClearAuditLogTool, StaticGuardedEchoTool, PlainEchoTool],
plugins: [AuditPlugin, FinallyRecorderPlugin],
tools: [
AuditedTool,
GetAuditLogTool,
ClearAuditLogTool,
StaticGuardedEchoTool,
PlainEchoTool,
RefusedAnswerTool,
FailingExecuteTool,
GetFinallyLogTool,
],
resources: [AuditLogResource],
prompts: [AuditSummaryPrompt],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Plugin, ToolHook, type FlowCtxOf } from '@frontmcp/sdk';

import { finallyLog } from '../data/finally-log';

type CallToolFlow = FlowCtxOf<'tools:call-tool'>;

const RECORDED_TOOLS = new Set(['audited-tool', 'refused-answer', 'failing-execute']);

const isRecorded = (flowCtx: CallToolFlow) => RECORDED_TOOLS.has(String(flowCtx.state.input?.name));

function record(flowCtx: CallToolFlow, stage: string): void {
finallyLog.add({
toolName: String(flowCtx.state.input?.name),
stage,
outcome: flowCtx.state.flowError?.name ?? 'ok',
});
}

/** The first two Finally hooks try to answer and to fail the call: both are ignored, and the recorders still run. */
@Plugin({
name: 'finally-recorder',
description: 'Records how the execute and finalize stages of a tool call ended',
})
export default class FinallyRecorderPlugin {
@ToolHook.Finally('finalize', { priority: -10, filter: isRecorded })
replaceAnswer(flowCtx: CallToolFlow) {
flowCtx.respond({ content: [{ type: 'text', text: 'replaced by a Finally hook' }] });
}

@ToolHook.Finally('finalize', { priority: -5, filter: isRecorded })
failRecorder() {
throw new Error('the recorder store is down');
}

@ToolHook.Finally('execute', { filter: isRecorded })
recordExecute(flowCtx: CallToolFlow) {
record(flowCtx, 'execute');
}

@ToolHook.Finally('finalize', { filter: isRecorded })
recordFinalize(flowCtx: CallToolFlow) {
record(flowCtx, 'finalize');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from '@frontmcp/lazy-zod';
import { Tool, ToolContext } from '@frontmcp/sdk';

import { auditLog } from '../data/audit-log';
import { finallyLog } from '../data/finally-log';

const inputSchema = {};

Expand All @@ -15,13 +16,14 @@ type Output = z.infer<typeof outputSchema>;

@Tool({
name: 'clear-audit-log',
description: 'Clear all audit log entries',
description: 'Clear all audit log and Finally log entries',
inputSchema,
outputSchema,
})
export default class ClearAuditLogTool extends ToolContext {
async execute(_input: Input): Promise<Output> {
auditLog.clear();
finallyLog.clear();

return {
success: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from '@frontmcp/lazy-zod';
import { PublicMcpError, Tool, ToolContext } from '@frontmcp/sdk';

const outputSchema = z.object({ posted: z.boolean() });

type Output = z.infer<typeof outputSchema>;

@Tool({
name: 'failing-execute',
description: 'Fails while it runs',
inputSchema: {},
outputSchema,
})
export default class FailingExecuteTool extends ToolContext {
async execute(): Promise<Output> {
throw new PublicMcpError('The ledger is unreachable');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from '@frontmcp/lazy-zod';
import { Tool, ToolContext } from '@frontmcp/sdk';

import { finallyLog } from '../data/finally-log';

const inputSchema = {
toolName: z.string().describe('The tool whose calls to list'),
};

const outputSchema = z.object({
entries: z.array(z.object({ stage: z.string(), outcome: z.string() })),
});

type Input = z.infer<z.ZodObject<typeof inputSchema>>;
type Output = z.infer<typeof outputSchema>;

@Tool({
name: 'get-finally-log',
description: 'How the execute and finalize stages of the calls of one tool ended, as its Finally hooks saw them',
inputSchema,
outputSchema,
})
export default class GetFinallyLogTool extends ToolContext {
async execute(input: Input): Promise<Output> {
const entries = finallyLog.entriesFor(input.toolName).map(({ stage, outcome }) => ({ stage, outcome }));
return { entries };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from '@frontmcp/lazy-zod';
import { Tool, ToolContext } from '@frontmcp/sdk';

const outputSchema = z.object({ total: z.number() });

type Output = z.infer<typeof outputSchema>;

@Tool({
name: 'refused-answer',
description: 'Answers with a total its own output schema refuses',
inputSchema: {},
outputSchema,
})
export default class RefusedAnswerTool extends ToolContext {
async execute(): Promise<Output> {
return { total: 'twelve' } as unknown as Output;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* E2E: `@frontmcp/observability` closes the `tools/call` span and records the failure on the request log when
* a tool's output schema refuses its answer. The refusal fails the `finalize` stage, which skips that stage's
* Did hooks, so the span stayed open and the request log recorded no error. Observability closes them in a
* Finally hook now.
*/
import { expect, test } from '@frontmcp/testing';

interface Telemetry {
spans: Array<{ tool: string; call: string }>;
requestLogs: Array<{ status: string; errorCode?: string }>;
}

test.describe('Observability of an answer the output schema refuses', () => {
test.use({
server: 'apps/e2e/demo-e2e-standalone/src/main-observability.ts',
project: 'demo-e2e-standalone',
publicMode: true,
});

test('closes the tools/call span as failed and records INVALID_OUTPUT on the request log', async ({ mcp }) => {
const result = await mcp.tools.call('refused-answer', {});
expect(result).toBeError();
expect(result.raw._meta?.['code']).toBe('INVALID_OUTPUT');

const telemetry = await mcp.tools.call('get-telemetry', { toolName: 'refused-answer' });

expect(telemetry.json<Telemetry>()).toEqual({
spans: [{ tool: 'ok', call: 'error' }],
requestLogs: [{ status: 'error', errorCode: 'INVALID_OUTPUT' }],
});
});
});
16 changes: 16 additions & 0 deletions apps/e2e/demo-e2e-standalone/src/apps/observability-probe/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { App } from '@frontmcp/sdk';

import GetTelemetryTool from './tools/get-telemetry.tool';
import RefusedAnswerTool from './tools/refused-answer.tool';

export { recordRequestLog } from './telemetry-records';

/**
* Reports what `@frontmcp/observability` recorded for a call whose answer its output schema refuses.
*/
@App({
name: 'observability-probe',
description: 'A tool whose answer its output schema refuses, and what observability recorded for it',
tools: [RefusedAnswerTool, GetTelemetryTool],
})
export class ObservabilityProbeApp {}
Loading
Loading