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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,33 @@ Visibility and execution policies may intentionally differ — a tool hidden fro

A hook that throws denies access (fail closed) and logs a warning. Only an explicit `true` grants access. Without the hook, every registered tool stays visible and callable.

## Observing Tool Calls

Provide `onToolCallComplete` to observe every tool call, regardless of transport, in one place:

```typescript
await app.register(mcpPlugin, {
onToolCallComplete: async (event) => {
logger.info({
tool: event.toolName,
source: event.source,
durationMs: event.durationMs,
ok: event.outcome.ok
}, 'tool call completed')
}
})
```

The hook fires exactly once per tool call, after it settles: for JSON-RPC `tools/call`, for `app.mcpCallTool()`, and for a task-augmented call once execution finishes (not when the task is merely accepted). It also fires for access denial, unknown tools, invalid arguments, and task-required outcomes. It does not fire for `tools/list`, initialization/ping, or malformed requests where no tool name was resolved.

`event.source` is `'json-rpc'`, `'in-process'`, or `'task'`. `event.outcome` is the same `McpCallToolOutcome` returned by `mcpCallTool()`. The hook is awaited before the response is sent, so keep it fast; a throwing or rejecting hook is logged and otherwise ignored — it never changes the tool response.

Synchronous sources (`'json-rpc'` and `'in-process'`) expose the active `request`/`reply`. Task events never do: a task can finish after the original HTTP response has already completed, so `request`/`reply` are absent on `source: 'task'` events. Every event, regardless of source, carries `requestId` for correlation.

Duration semantics also differ by source: `'json-rpc'` and `'in-process'` durations include authorization, validation, sanitization, and execution. Task durations measure only actual execution — validation, sanitization, and the tool call — excluding the time the task spent queued.

`event.arguments` are the raw, unredacted tool arguments. Redact sensitive values yourself before logging or persisting them.

## Customizing MCP Route Schemas

Use `transformRouteSchema` to customize Fastify/OpenAPI schema metadata on MCP transport routes without replacing handlers or route definitions.
Expand Down Expand Up @@ -1824,6 +1851,7 @@ await app.register(import('@fastify/bearer-auth'), {
- `instructions`: Optional server instructions
- `enableSSE`: Enable Server-Sent Events support (default: false)
- `canAccessTool`: Per-request tool authorization hook consulted by `tools/list` and `tools/call` (optional)
- `onToolCallComplete`: Transport-neutral hook fired once after every tool call settles, across JSON-RPC, `mcpCallTool()`, and tasks (optional)
- `authorization`: OAuth 2.1 authorization configuration (optional)
- `enabled`: Enable OAuth 2.1 authorization (default: false)
- `authorizationServers`: Authorization server URIs
Expand Down
78 changes: 71 additions & 7 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from './schema.ts'
import type { RequestId } from './schema.ts'

import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions, ResourceHandlers, McpCallToolOutcome, ToolAccessOperation } from './types.ts'
import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions, ResourceHandlers, McpCallToolOutcome, ToolAccessOperation, MCPToolCallCompleteEvent } from './types.ts'
import type { SessionStore } from './stores/session-store.ts'
import type { TaskStore, TaskRecord, TaskWaiters } from './stores/task-store.ts'
import { isTerminal, toWireTask } from './stores/task-store.ts'
Expand Down Expand Up @@ -300,6 +300,7 @@ async function handleToolsCall (

const params = paramsValidation.data
const toolName = params.name
const startedAt = performance.now()

// A denied tool answers exactly like an unknown one, so a caller cannot
// distinguish "does not exist" from "exists but not for you" by the protocol
Expand All @@ -310,6 +311,7 @@ async function handleToolsCall (
// the model cannot correct itself out of missing access.
const resolved = await resolveRegisteredTool(toolName, dependencies)
if (!resolved.ok) {
await emitToolCallComplete('json-rpc', toolName, params.arguments || {}, resolved, startedAt, dependencies)
return toolCallOutcomeToJsonRpc(request.id, toolName, resolved)
}

Expand All @@ -322,18 +324,64 @@ async function handleToolsCall (
: undefined
const augmentation = resolveTaskAugmentation(resolved.tool, taskParams !== undefined)
if ('error' in augmentation) {
await emitToolCallComplete('json-rpc', toolName, params.arguments || {}, { ok: false, reason: 'task-required' }, startedAt, dependencies)
return createError(request.id, METHOD_NOT_FOUND, augmentation.error)
}
if (augmentation.mode === 'task') {
return await runToolCallAsTask(
request,
taskParams?.ttl,
() => executeToolCall(request, resolved.tool, params, sessionId, dependencies),
// Timed from when the task actually starts executing, not from when it
// was queued, so `durationMs` reflects work done rather than wait time.
() => executeToolCall(request, resolved.tool, params, sessionId, dependencies, { source: 'task', startedAt: performance.now() }),
dependencies
)
}

return await executeToolCall(request, resolved.tool, params, sessionId, dependencies)
return await executeToolCall(request, resolved.tool, params, sessionId, dependencies, { source: 'json-rpc', startedAt })
}

/** An observability failure must never change the tool response. */
async function emitToolCallComplete (
source: MCPToolCallCompleteEvent['source'],
toolName: string,
args: Record<string, unknown>,
outcome: McpCallToolOutcome,
startedAt: number,
dependencies: ToolCallDependencies
): Promise<void> {
const hook = dependencies.opts.onToolCallComplete
if (!hook) {
return
}

const common = {
toolName,
arguments: args,
authContext: dependencies.authContext,
sessionId: dependencies.sessionId,
requestId: dependencies.request.id,
durationMs: performance.now() - startedAt,
outcome
}

// Tasks may complete after the originating HTTP response has already been
// sent, so they must never carry the (possibly finished) request/reply.
const event: MCPToolCallCompleteEvent = source === 'task'
? { ...common, source }
: { ...common, source, request: dependencies.request, reply: dependencies.reply }

try {
await hook(event)
} catch (error) {
if (source === 'task') {
// request.log may belong to an already-finished request; app.log plus
// the correlation id is the safe choice for task-time failures.
dependencies.app.log.error({ err: error, tool: toolName, requestId: common.requestId }, 'onToolCallComplete hook failed')
} else {
dependencies.request.log.error({ err: error, tool: toolName }, 'onToolCallComplete hook failed')
}
}
}

async function resolveRegisteredTool (
Expand Down Expand Up @@ -392,29 +440,45 @@ export async function callRegisteredTool (
args: Record<string, unknown>,
dependencies: ToolCallDependencies
): Promise<McpCallToolOutcome> {
const startedAt = performance.now()

const resolved = await resolveRegisteredTool(name, dependencies)
if (!resolved.ok) {
await emitToolCallComplete('in-process', name, args, resolved, startedAt, dependencies)
return resolved
}

const augmentation = resolveTaskAugmentation(resolved.tool, false)
if ('error' in augmentation) {
return { ok: false, reason: 'task-required' }
const outcome: McpCallToolOutcome = { ok: false, reason: 'task-required' }
await emitToolCallComplete('in-process', name, args, outcome, startedAt, dependencies)
return outcome
}

return await executeRegisteredTool(resolved.tool, name, args, dependencies)
const outcome = await executeRegisteredTool(resolved.tool, name, args, dependencies)
await emitToolCallComplete('in-process', name, args, outcome, startedAt, dependencies)
return outcome
}

interface ToolCallObservationContext {
source: MCPToolCallCompleteEvent['source']
startedAt: number
}

async function executeToolCall (
request: JSONRPCRequest,
tool: MCPTool,
params: { name: string, arguments?: Record<string, unknown> },
sessionId: string | undefined,
dependencies: HandlerDependencies
dependencies: HandlerDependencies,
observation: ToolCallObservationContext
): Promise<JSONRPCResponse | JSONRPCError> {
const toolName = params.name
const args = params.arguments || {}

const outcome = await executeRegisteredTool(tool, toolName, params.arguments || {}, { ...dependencies, sessionId })
const callDependencies = { ...dependencies, sessionId }
const outcome = await executeRegisteredTool(tool, toolName, args, callDependencies)
await emitToolCallComplete(observation.source, toolName, args, outcome, observation.startedAt, callDependencies)
return toolCallOutcomeToJsonRpc(request.id, toolName, outcome)
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export type {
ToolAccessOperation,
McpCallToolContext,
McpCallToolOutcome,
MCPToolCallCompleteEvent,
MCPTool,
MCPResource,
MCPPrompt,
Expand Down
40 changes: 40 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,34 @@ export type McpCallToolOutcome =
| { ok: false, reason: 'invalid-arguments', detail: string }
| { ok: false, reason: 'task-required' }

interface MCPToolCallCompleteEventBase {
toolName: string
arguments: Record<string, unknown>
authContext?: AuthorizationContext
sessionId?: string
/** Correlates this event back to the originating request, for every source */
requestId: string
durationMs: number
outcome: McpCallToolOutcome
}

/**
* `source: 'task'` fires after the original HTTP response may already have
* completed, so it must not carry the (possibly finished) `request`/`reply`
* objects that the synchronous sources expose.
*/
export type MCPToolCallCompleteEvent =
| (MCPToolCallCompleteEventBase & {
source: 'json-rpc' | 'in-process'
request: FastifyRequest
reply: FastifyReply
})
| (MCPToolCallCompleteEventBase & {
source: 'task'
request?: undefined
reply?: undefined
})

export interface MCPPluginOptions {
serverInfo?: Implementation
capabilities?: ServerCapabilities
Expand Down Expand Up @@ -275,6 +303,18 @@ export interface MCPPluginOptions {
toolName: string,
context: ToolAccessContext
) => boolean | Promise<boolean>
/**
* Fires once per tool call, after it settles, regardless of transport
* (JSON-RPC, `app.mcpCallTool()`, or a completed task). Not called for
* `tools/list`, initialization/ping, or malformed requests where no tool
* name was resolved. Awaited before the response is sent, so keep it fast;
* a throwing hook is logged and otherwise ignored, it never changes the
* tool response. `event.arguments` are the raw, unredacted tool
* arguments, so redact sensitive values yourself before logging them.
*/
onToolCallComplete?: (
event: MCPToolCallCompleteEvent
) => void | Promise<void>
/**
* Customize Fastify/OpenAPI schema metadata for MCP transport routes.
* This callback runs once per registered route during startup.
Expand Down
26 changes: 26 additions & 0 deletions test-d/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
MCPRouteSchemaTransformer,
McpCallToolContext,
McpCallToolOutcome,
MCPToolCallCompleteEvent,
} from '../dist/index.js'

// ─── ToolHandler ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -395,6 +396,31 @@ expectAssignable<McpCallToolOutcome>({ ok: false, reason: 'invalid-arguments', d
expectAssignable<McpCallToolOutcome>({ ok: false, reason: 'task-required' })
expectNotAssignable<McpCallToolOutcome>({ ok: false, reason: 'nope' })

// ─── onToolCallComplete event ───────────────────────────────────────

// Every source carries a requestId
expectType<string>(({} as MCPToolCallCompleteEvent).requestId)

// Synchronous sources expose the live request/reply
declare const jsonRpcEvent: MCPToolCallCompleteEvent & { source: 'json-rpc' | 'in-process' }
expectType<FastifyRequest>(jsonRpcEvent.request)
expectType<FastifyReply>(jsonRpcEvent.reply)

// Task events cannot carry request/reply
declare const taskEvent: MCPToolCallCompleteEvent & { source: 'task' }
expectType<undefined>(taskEvent.request)
expectType<undefined>(taskEvent.reply)
expectNotAssignable<MCPToolCallCompleteEvent & { source: 'task' }>({
source: 'task',
toolName: 'echo',
arguments: {},
requestId: 'req-1',
durationMs: 1,
outcome: { ok: true, result: { content: [] } },
request: {} as FastifyRequest,
reply: {} as FastifyReply,
})

// Sync and async hooks are both accepted
expectAssignable<MCPPluginOptions>({ canAccessTool: () => true })
expectAssignable<MCPPluginOptions>({ canAccessTool: async () => false })
Expand Down
Loading