Skip to main content
Glama

Git MCP Server

toolHandlerFactory.ts16.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); }; }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cyanheads/git-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server