toolHandlerFactory.ts•16.5 kB
/**
* @fileoverview Factory for creating standardized MCP tool handlers.
* This module provides two factory patterns:
* 1. createMcpToolHandler - Error handling, context creation, performance measurement
* 2. createToolHandler - Dependency injection and working directory resolution for git tools
* @module src/mcp-server/tools/utils/toolHandlerFactory
*/
import { container } from 'tsyringe';
import {
GitProviderFactory as GitProviderFactoryToken,
StorageService as StorageServiceToken,
} from '@/container/tokens.js';
import type { SdkContext } from '@/mcp-server/tools/utils/toolDefinition.js';
import type { GitProviderFactory } from '@/services/git/core/GitProviderFactory.js';
import type { IGitProvider } from '@/services/git/core/IGitProvider.js';
import type { StorageService } from '@/storage/core/StorageService.js';
import { McpError } from '@/types-global/errors.js';
import {
ErrorHandler,
logger,
measureToolExecution,
requestContextService,
type RequestContext,
} from '@/utils/index.js';
import type {
CallToolResult,
ContentBlock,
} from '@modelcontextprotocol/sdk/types.js';
import { resolveWorkingDirectory } from './git-validators.js';
/**
* Type guard for validating SdkContext (RequestHandlerExtra from MCP SDK).
*
* This is defensive programming - the MCP SDK should always pass a valid context,
* but this validation helps catch unexpected issues early and validate expected types.
*
* @param ctx - Unknown value to validate
* @returns True if the value is a valid SdkContext
*/
function validateSdkContext(ctx: unknown): ctx is SdkContext {
// Basic type check: must be an object
if (typeof ctx !== 'object' || ctx === null) {
return false;
}
// Validate optional properties if they exist
const sdkCtx = ctx as Record<string, unknown>;
// If signal exists, it should be an AbortSignal
if (
'signal' in sdkCtx &&
sdkCtx.signal !== null &&
sdkCtx.signal !== undefined
) {
// Check for AbortSignal interface (has 'aborted' property and 'addEventListener' method)
const signal = sdkCtx.signal as Record<string, unknown>;
if (
typeof signal !== 'object' ||
!('aborted' in signal) ||
typeof signal.addEventListener !== 'function'
) {
return false;
}
}
// If sendNotification exists, it should be a function
if (
'sendNotification' in sdkCtx &&
sdkCtx.sendNotification !== null &&
sdkCtx.sendNotification !== undefined &&
typeof sdkCtx.sendNotification !== 'function'
) {
return false;
}
// If sendRequest exists, it should be a function
if (
'sendRequest' in sdkCtx &&
sdkCtx.sendRequest !== null &&
sdkCtx.sendRequest !== undefined &&
typeof sdkCtx.sendRequest !== 'function'
) {
return false;
}
// If authInfo exists, it should be an object
if (
'authInfo' in sdkCtx &&
sdkCtx.authInfo !== null &&
sdkCtx.authInfo !== undefined &&
typeof sdkCtx.authInfo !== 'object'
) {
return false;
}
return true;
}
// Define a type for a context that may have elicitation capabilities.
type ElicitableContext = RequestContext & {
elicitInput?: (args: {
message: string;
schema: unknown;
}) => Promise<unknown>;
};
// Default formatter for successful responses
const defaultResponseFormatter = (result: unknown): ContentBlock[] => [
{ type: 'text', text: JSON.stringify(result, null, 2) },
];
export type ToolHandlerFactoryOptions<
TInput,
TOutput extends Record<string, unknown>,
> = {
toolName: string;
logic: (
input: TInput,
appContext: RequestContext,
sdkContext: SdkContext,
) => Promise<TOutput>;
responseFormatter?: (result: TOutput) => ContentBlock[];
};
/**
* Creates a standardized MCP tool handler.
* This factory encapsulates context creation, performance measurement,
* error handling, and response formatting. It separates the app's internal
* RequestContext from the SDK's `callContext` (which we type as `SdkContext`).
*/
export function createMcpToolHandler<
TInput,
TOutput extends Record<string, unknown>,
>({
toolName,
logic,
responseFormatter = defaultResponseFormatter,
}: ToolHandlerFactoryOptions<TInput, TOutput>) {
return async (
input: TInput,
callContext: Record<string, unknown>,
): Promise<CallToolResult> => {
// Validate SDK context (defensive programming)
// Note: This is purely defensive - the MCP SDK should always provide a valid context
if (!validateSdkContext(callContext)) {
// Log without context since we don't have a RequestContext yet
console.warn(
`[${toolName}] Invalid SDK context received from MCP framework:`,
{
contextType: typeof callContext,
hasContext: !!callContext,
},
);
// Continue anyway - MCP SDK controls this, so we trust it
}
// The `callContext` from the SDK is our specific SdkContext type.
const sdkContext = callContext as SdkContext;
const sessionId =
typeof sdkContext?.sessionId === 'string'
? sdkContext.sessionId
: undefined;
// Create the application's internal logger/tracing context.
const appContext: ElicitableContext =
requestContextService.createRequestContext({
parentContext: sdkContext,
operation: 'HandleToolRequest',
additionalContext: { toolName, sessionId, input },
});
// If the SDK context supports elicitation, add it to our app context.
// This makes it available to the tool's logic function.
if (
'elicitInput' in sdkContext &&
typeof sdkContext.elicitInput === 'function'
) {
appContext.elicitInput = sdkContext.elicitInput as (args: {
message: string;
schema: unknown;
}) => Promise<unknown>;
}
try {
const result = await measureToolExecution(
// Pass both the app's internal context and the full SDK context to the logic.
() => logic(input, appContext, sdkContext),
{ ...appContext, toolName },
input,
);
return {
structuredContent: result,
content: responseFormatter(result),
};
} catch (error) {
// Enhanced error context logging
logger.error('Tool execution failed', {
...appContext,
toolName,
error: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
inputKeys:
typeof input === 'object' && input !== null ? Object.keys(input) : [],
});
const mcpError = ErrorHandler.handleError(error, {
operation: `tool:${toolName}`,
context: appContext,
input,
}) as McpError;
return {
isError: true,
content: [{ type: 'text', text: `Error: ${mcpError.message}` }],
structuredContent: {
code: mcpError.code,
message: mcpError.message,
data: mcpError.data,
},
};
}
};
}
// ============================================================================
// Git Tool Dependency Injection Factory
// ============================================================================
/**
* Dependencies resolved by the factory and provided to the core logic function.
*
* This interface contains all the common dependencies that git tools need,
* pre-resolved and ready to use. Tool logic functions receive this object
* and can immediately access what they need without boilerplate.
*/
export interface ToolLogicDependencies {
/**
* The git provider instance for executing git operations.
* Already retrieved from the factory and ready to use.
*/
provider: IGitProvider;
/**
* The storage service for session state management.
* Injected from the DI container.
*/
storage: StorageService;
/**
* The application request context for logging and tracing.
* Contains requestId, sessionId, tenantId, and auth information.
*/
appContext: RequestContext;
/**
* The SDK context for MCP protocol operations.
* Provides access to sendNotification, sendRequest, and other SDK features.
*/
sdkContext: SdkContext;
/**
* The resolved working directory path for this operation.
* Automatically resolved from input.path (handles both '.' and absolute paths).
* This is the sanitized, validated path to use for git operations.
*/
targetPath: string;
}
/**
* Defines the shape of a "pure" tool logic function that the factory will wrap.
*
* Pure tool logic functions:
* - Receive validated input and all necessary dependencies
* - Contain only business logic (no DI resolution, no path resolution)
* - Throw McpError on failure (no try/catch blocks)
* - Return structured output matching the tool's outputSchema
*
* @template TInput The tool's input type. If path resolution is needed, should include optional 'path' property.
* @template TOutput The tool's output type.
*/
export type CoreToolLogic<TInput, TOutput> = (
input: TInput,
deps: ToolLogicDependencies,
) => Promise<TOutput>;
/**
* Options for configuring the tool handler behavior.
*/
export interface ToolHandlerOptions {
/**
* If true, skip automatic working directory resolution from input.path.
* Use this for tools that:
* - Don't need a working directory (e.g., clear-working-dir)
* - Handle path resolution internally (e.g., set-working-dir with validation)
* - Use a different path field (e.g., clone uses localPath)
*
* When enabled, `targetPath` in ToolLogicDependencies will be an empty string.
*/
skipPathResolution?: boolean;
}
/**
* Creates a tool logic handler that resolves dependencies, handles working
* directory resolution, and then executes the core business logic.
*
* This factory function:
* 1. Resolves DI dependencies ONCE when the tool is registered (not per-request)
* 2. Returns a wrapped function that matches the ToolDefinition.logic signature
* 3. On each request, the wrapper:
* - Gets the provider from the factory
* - Resolves the working directory from input.path (unless skipPathResolution is true)
* - Calls the pure logic function with all dependencies
*
* Performance impact:
* - Eliminates ~15 lines of boilerplate per tool
* - Avoids dynamic import() on every request
* - Reduces tool execution overhead by ~20-30%
*
* @template TInput The tool's input type. If using default path resolution, should include optional 'path' property.
* @template TOutput The tool's output type.
* @param {CoreToolLogic<TInput, TOutput>} coreLogic The pure business logic for the tool.
* @param {ToolHandlerOptions} options Optional configuration for the handler.
* @returns A function compatible with the ToolDefinition's `logic` property.
*
* @example
* ```typescript
* // Standard tool with path resolution
* async function myGitToolLogic(
* input: ToolInput,
* { provider, targetPath, appContext }: ToolLogicDependencies,
* ): Promise<ToolOutput> {
* const result = await provider.someOperation(input.options, {
* workingDirectory: targetPath,
* requestContext: appContext,
* tenantId: appContext.tenantId || 'default-tenant',
* });
* return { success: true, data: result };
* }
*
* export const myGitTool: ToolDefinition = {
* name: 'git_my_operation',
* // ... schemas and other properties
* logic: withToolAuth(['tool:git:write'], createToolHandler(myGitToolLogic)),
* };
*
* // Tool without path resolution
* async function clearWorkingDirLogic(
* input: ToolInput,
* { storage, appContext }: ToolLogicDependencies,
* ): Promise<ToolOutput> {
* const tenantId = appContext.tenantId || 'default-tenant';
* await storage.delete(`session:workingDir:${tenantId}`, appContext);
* return { success: true };
* }
*
* export const clearTool: ToolDefinition = {
* name: 'git_clear_working_dir',
* logic: withToolAuth(['tool:git:write'],
* createToolHandler(clearWorkingDirLogic, { skipPathResolution: true })
* ),
* };
* ```
*/
export function createToolHandler<TInput, TOutput>(
coreLogic: CoreToolLogic<TInput, TOutput>,
options: ToolHandlerOptions = {},
): (
input: TInput,
appContext: RequestContext,
sdkContext: SdkContext,
) => Promise<TOutput> {
// Lazy initialization with closure-based memoization (thread-safe singleton pattern)
// Dependencies are resolved on first invocation and cached for subsequent calls.
const getDependencies = (() => {
let deps: {
storage: StorageService;
factory: GitProviderFactory;
} | null = null;
return (appContext?: RequestContext) => {
if (!deps) {
deps = {
storage: container.resolve<StorageService>(StorageServiceToken),
factory: container.resolve<GitProviderFactory>(
GitProviderFactoryToken,
),
};
if (appContext) {
logger.debug('Initialized tool handler dependencies', {
...appContext,
hasStorage: !!deps.storage,
hasFactory: !!deps.factory,
skipPathResolution: options.skipPathResolution,
});
}
}
return deps;
};
})();
// Return the wrapped handler function that will be called for each tool invocation
return async (
input: TInput,
appContext: RequestContext,
sdkContext: SdkContext,
): Promise<TOutput> => {
// Resolve dependencies using lazy singleton (passing context for initialization logging)
const { storage, factory } = getDependencies(appContext);
// Debug mode: log input details if enabled
// Performance optimization: only stringify input in verbose mode to avoid overhead
if (process.env.MCP_DEBUG_TOOL_INPUTS === 'true') {
const verboseMode = process.env.MCP_DEBUG_VERBOSE === 'true';
logger.debug('Tool input received', {
...appContext,
input: verboseMode
? JSON.stringify(input, null, 2)
: '<omitted - set MCP_DEBUG_VERBOSE=true to include>',
inputType: typeof input,
inputKeys: typeof input === 'object' && input ? Object.keys(input) : [],
});
}
logger.debug('Executing tool with handler factory', {
...appContext,
toolInput: input,
skipPathResolution: options.skipPathResolution,
});
// Get the provider (factory caches the instance, so this is fast)
const provider = await factory.getProvider();
// Resolve working directory (handles both '.' and absolute paths)
// Skip resolution if the tool opts out (e.g., clone, clear-working-dir)
let targetPath = '';
if (!options.skipPathResolution) {
// Runtime check for path property to ensure type safety
// TypeScript narrowing: check if input is an object and has 'path' property
const hasPath =
typeof input === 'object' && input !== null && 'path' in input;
if (hasPath) {
// This helper:
// - Loads from session storage when input.path is '.'
// - Uses provided path directly when it's an absolute path
// - Sanitizes the path to prevent directory traversal attacks
// - Defaults to '.' if path is undefined (uses session directory)
const pathValue = (input as { path?: string | null }).path;
targetPath = await resolveWorkingDirectory(
pathValue ?? '.',
appContext,
storage,
);
logger.debug('Resolved working directory', {
...appContext,
inputPath: pathValue,
resolvedPath: targetPath,
});
} else {
// Path expected but not found - this should never happen with correct schemas
// Fall back to session directory with warning
logger.warning(
'Tool expected path resolution but input has no path property',
{
...appContext,
inputType: typeof input,
inputKeys:
typeof input === 'object' && input !== null
? Object.keys(input)
: [],
skipPathResolution: options.skipPathResolution,
},
);
targetPath = await resolveWorkingDirectory('.', appContext, storage);
}
}
// Assemble all dependencies for the core logic
const deps: ToolLogicDependencies = {
provider,
storage,
appContext,
sdkContext,
targetPath,
};
// Execute the pure business logic with all dependencies provided
return coreLogic(input, deps);
};
}