Skip to content
Closed
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
62 changes: 60 additions & 2 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
INVALID_PARAMS
} from './schema.ts'

import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions } from './types.ts'
import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions, HandlerContext } from './types.ts'
import type { AuthorizationContext } from './types/auth-types.ts'
import { validate, CallToolRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, isTypeBoxSchema } from './validation/index.ts'
import { sanitizeToolParams, assessToolSecurity, SECURITY_WARNINGS } from './security.ts'
Expand All @@ -39,6 +39,19 @@ type HandlerDependencies = {
request: FastifyRequest
reply: FastifyReply
authContext?: AuthorizationContext
/**
* Optional hooks to allow for a tool to perform actions before or after handling invocation.
*/
globalHooks?: {
/**
* Optional before handler hook.
* Return nothing to signify the handler should invoke.
* A result indicates a failure and will result in the handler not being invoked.
*
* @param context The same context that is supplied to a handler
*/
toolBeforeHandler?: (context: HandlerContext) => Promise<CallToolResult | void> | CallToolResult | void,
}
}

export function createResponse (id: string | number, result: any): JSONRPCResponse {
Expand Down Expand Up @@ -188,9 +201,54 @@ async function handleToolsCall (
return createResponse(request.id, result)
}

// Setup context for following calls
const invocationContext: HandlerContext = {
sessionId,
request: dependencies.request,
reply: dependencies.reply,
authContext: dependencies.authContext
}

if (dependencies.globalHooks?.toolBeforeHandler) {
try {
const result = await dependencies.globalHooks.toolBeforeHandler(invocationContext)
if (result) {
return createResponse(request.id, result)
}
} catch (err: any) {
const result: CallToolResult = {
content: [{
type: 'text',
text: `Tool execution failed: ${err.message || err}`
}],
isError: true
}
return createResponse(request.id, result)
}
}

// Invoke the before hook if available
if (tool.definition.hooks?.beforeHandler) {
try {
const result = await tool.definition.hooks.beforeHandler(invocationContext)
if (result) {
return createResponse(request.id, result)
}
} catch (err: any) {
const result: CallToolResult = {
content: [{
type: 'text',
text: `Tool execution failed: ${err.message || err}`
}],
isError: true
}
return createResponse(request.id, result)
}
}

// Use validated arguments
try {
const result = await tool.handler(argumentsValidation.data, { sessionId, request: dependencies.request, reply: dependencies.reply, authContext: dependencies.authContext })
const result = await tool.handler(argumentsValidation.data, invocationContext)
return createResponse(request.id, result)
} catch (error: any) {
const result: CallToolResult = {
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption
const resources = new Map<string, MCPResource>()
const prompts = new Map<string, MCPPrompt>()

const globalHooks = opts.hooks

// Initialize stores and brokers based on configuration
let sessionStore: SessionStore
let messageBroker: MessageBroker
Expand Down Expand Up @@ -118,7 +120,8 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption
prompts,
sessionStore,
messageBroker,
localStreams
localStreams,
globalHooks,
})

// Add close hook to clean up Redis connections and authorization components
Expand Down
22 changes: 18 additions & 4 deletions src/routes/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { randomUUID } from 'crypto'
import type { FastifyRequest, FastifyReply, FastifyPluginAsync } from 'fastify'
import fp from 'fastify-plugin'
import type { JSONRPCMessage } from '../schema.ts'
import type { CallToolResult, JSONRPCMessage } from '../schema.ts'
import { JSONRPC_VERSION, INTERNAL_ERROR } from '../schema.ts'
import type { MCPPluginOptions, MCPTool, MCPResource, MCPPrompt } from '../types.ts'
import type { MCPPluginOptions, MCPTool, MCPResource, MCPPrompt, HandlerContext } from '../types.ts'
import type { SessionStore, SessionMetadata } from '../stores/session-store.ts'
import type { MessageBroker } from '../brokers/message-broker.ts'
import type { AuthorizationContext } from '../types/auth-types.ts'
Expand All @@ -20,10 +20,23 @@ interface MCPPubSubRoutesOptions {
sessionStore: SessionStore
messageBroker: MessageBroker
localStreams: Map<string, Set<any>>
/**
* Optional hooks to allow for a tool to perform actions before or after handling invocation.
*/
globalHooks?: {
/**
* Optional global before tool handler hook.
* Return nothing to signify the handler should invoke.
* A result indicates a failure and will result in the handler not being invoked.
*
* @param context The same context that is supplied to a handler
*/
toolBeforeHandler?: (context: HandlerContext) => Promise<CallToolResult | void> | CallToolResult | void,
}
}

const mcpPubSubRoutesPlugin: FastifyPluginAsync<MCPPubSubRoutesOptions> = async (app, options) => {
const { enableSSE, opts, capabilities, serverInfo, tools, resources, prompts, sessionStore, messageBroker, localStreams } = options
const { enableSSE, opts, capabilities, serverInfo, tools, resources, prompts, sessionStore, messageBroker, localStreams, globalHooks } = options

async function createSSESession (): Promise<SessionMetadata> {
const sessionId = randomUUID()
Expand Down Expand Up @@ -187,7 +200,8 @@ const mcpPubSubRoutesPlugin: FastifyPluginAsync<MCPPubSubRoutesOptions> = async
prompts,
request,
reply,
authContext
authContext,
globalHooks,
})
if (response) {
return response
Expand Down
16 changes: 16 additions & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

import type { HandlerContext } from './types.ts'

/**
* Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.
*
Expand Down Expand Up @@ -943,6 +945,20 @@ export interface Tool extends BaseMetadata {
* See [specification/2025-06-18/basic/index#general-fields] for notes on _meta usage.
*/
_meta?: { [key: string]: unknown };

/**
* Optional hooks to allow for a tool to perform actions before or after handling invocation.
*/
hooks?: {
/**
* Optional before handler hook.
* Return nothing to signify the handler should invoke.
* A result indicates a failure and will result in the handler not being invoked.
*
* @param context The same context that is supplied to a handler
*/
beforeHandler?: (context: HandlerContext) => Promise<CallToolResult | void> | CallToolResult | void,
}
}

/* Logging */
Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ export interface MCPPluginOptions {
db?: number
}
authorization?: AuthorizationConfig

/**
* Optional hooks to allow for a tool to perform actions before or after handling invocation.
*/
hooks?: {
/**
* Optional global before tool handler hook.
* Return nothing to signify the handler should invoke.
* A result indicates a failure and will result in the handler not being invoked.
*
* @param context The same context that is supplied to a handler
*/
toolBeforeHandler?: (context: HandlerContext) => Promise<CallToolResult | void> | CallToolResult | void,
}
}

export interface SSESession {
Expand Down
Loading