Skip to main content
Glama
tool-registry.ts23 kB
/** * @fileoverview Tool registry for managing MCP tool definitions and handlers * @packageDocumentation */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { promises as fs } from 'fs'; import { join, normalize, resolve } from 'path'; import { createLogger } from '../utils/logging/logger.js'; import { HandlerFunction, BaseHandlerDeps } from '../handlers/base/handler.interface.js'; import { createDefaultHandlerDeps, isApiResponse, createErrorResponse, } from '../handlers/base/handler.factory.js'; import { logToolInvocation, logToolResult, logAndFormatError } from './tool-helpers.js'; import { isFeatureEnabled } from '../config/features.js'; import type { DiscoveryConfig } from '../config/default.js'; const logger = createLogger('ToolRegistry'); /** * MCP response type */ type McpResponse = { content: Array<{ type: 'text'; text: string; [key: string]: unknown; }>; isError?: boolean; structuredContent?: Record<string, unknown>; [key: string]: unknown; }; /** * Tool metadata for categorization and filtering */ export interface ToolMetadata { /** Tool category for grouping */ category?: string; /** Tool version */ version?: string; /** Tool tags for filtering */ tags?: string[]; /** Whether the tool is enabled by default */ enabled?: boolean; } /** * Tool definition interface */ export interface ToolDefinition<TInput = unknown, TOutput = unknown> { /** Tool name */ name: string; /** Tool description */ description: string; /** Input schema using Zod - can be ZodType or ZodRawShape */ inputSchema?: z.ZodType<TInput> | z.ZodRawShape; /** Output schema using Zod - can be ZodType or ZodRawShape */ outputSchema?: z.ZodType<TOutput> | z.ZodRawShape; /** Handler function */ handler: HandlerFunction<TInput>; /** Optional metadata for enhanced features */ metadata?: ToolMetadata; } /** * Tool discovery options (compatible with DiscoveryConfig) */ export type ToolDiscoveryOptions = Partial<DiscoveryConfig>; /** * Registry for managing MCP tools */ export class ToolRegistry { private tools = new Map<string, ToolDefinition<unknown, unknown>>(); private toolMetadata = new Map<string, ToolMetadata>(); private discoveredTools = new Map<string, string>(); // tool name -> file path private server: McpServer; private defaultDeps: BaseHandlerDeps; constructor(server: McpServer, defaultDeps?: BaseHandlerDeps) { this.server = server; this.defaultDeps = defaultDeps || createDefaultHandlerDeps(); logger.info('ToolRegistry initialized'); } /** * Registers a tool with the MCP server * @param tool - The tool definition */ registerTool<TInput = unknown, TOutput = unknown>(tool: ToolDefinition<TInput, TOutput>): void { if (this.tools.has(tool.name)) { logger.warn(`Tool ${tool.name} is already registered, overwriting`); } // Store metadata if provided if (tool.metadata) { this.toolMetadata.set(tool.name, tool.metadata); logger.debug(`Stored metadata for tool: ${tool.name}`, tool.metadata); } this.tools.set(tool.name, tool as ToolDefinition<unknown, unknown>); logger.debug(`Registering tool: ${tool.name}`, { hasInputSchema: Boolean(tool.inputSchema), inputSchemaType: tool.inputSchema ? typeof tool.inputSchema : 'undefined', inputSchemaDetails: tool.inputSchema ? { isZodSchema: tool.inputSchema && '_def' in (tool.inputSchema as object), schemaKeys: tool.inputSchema ? Object.keys(tool.inputSchema) : [], } : null, }); // MCP SDK expects Zod schemas directly const toolConfig: Record<string, unknown> = { description: tool.description, }; if (tool.inputSchema) { toolConfig.inputSchema = tool.inputSchema; logger.debug(`Tool ${tool.name} inputSchema details`, { schemaType: typeof tool.inputSchema, isZodSchema: tool.inputSchema && typeof tool.inputSchema === 'object' && '_def' in tool.inputSchema, hasShape: tool.inputSchema && typeof tool.inputSchema === 'object' && 'shape' in tool.inputSchema, }); } if (tool.outputSchema) { toolConfig.outputSchema = tool.outputSchema; logger.debug(`Tool ${tool.name} outputSchema details`, { schemaType: typeof tool.outputSchema, isZodSchema: tool.outputSchema && typeof tool.outputSchema === 'object' && '_def' in tool.outputSchema, }); } // Register with MCP server logger.info(`About to register tool with MCP server: ${tool.name}`, { mcpServerType: typeof this.server, mcpServerHasRegisterTool: 'registerTool' in this.server, toolConfigKeys: Object.keys(toolConfig), }); try { this.server.registerTool( tool.name, toolConfig, async (params: Record<string, unknown>, _: unknown): Promise<McpResponse> => { // Second parameter required by MCP SDK but not used logger.info(`===== TOOL INVOCATION START: ${tool.name} =====`); logger.info(`Tool ${tool.name} received params:`, { params, paramsType: typeof params, paramsKeys: params ? Object.keys(params) : [], paramsStringified: JSON.stringify(params), hasInputSchema: Boolean(tool.inputSchema), }); try { logger.debug(`Tool ${tool.name} invoked`, { params, paramsType: typeof params, hasInputSchema: Boolean(tool.inputSchema), }); logToolInvocation(tool.name, params); // Validate input if schema provided let validatedParams: TInput; if (tool.inputSchema && params !== undefined) { logger.debug(`Validating params for ${tool.name}`, { schemaType: typeof tool.inputSchema, hasDefProperty: '_def' in (tool.inputSchema as object), hasSafeParseMethod: 'safeParse' in (tool.inputSchema as object), }); // Wrap the inputSchema in z.object() if it's a ZodRawShape const schema = tool.inputSchema && typeof tool.inputSchema === 'object' && !('safeParse' in tool.inputSchema) ? z.object(tool.inputSchema as z.ZodRawShape) : (tool.inputSchema as z.ZodSchema); const parseResult = schema.safeParse(params); if (!parseResult.success) { logger.error(`Input validation failed for tool ${tool.name}`, { errors: parseResult.error.issues, }); throw new Error(`Invalid input: ${parseResult.error.message}`); } validatedParams = parseResult.data; } else { // If no schema or params is undefined, pass through as-is validatedParams = (params ?? {}) as TInput; } // Execute handler logger.info(`About to execute handler for ${tool.name} with validated params:`, { validatedParams, handlerType: typeof tool.handler, }); const result = await tool.handler(validatedParams); logger.info(`Handler for ${tool.name} returned result:`, { resultType: typeof result, isApiResponse: isApiResponse(result), result, }); logToolResult(tool.name, result); // Handle ApiResponse format if (isApiResponse(result)) { // If it's an error response, handle appropriately if (result.isError) { const errorContent = result.content[0]; if (errorContent?.type === 'text') { let errorData; try { errorData = JSON.parse(errorContent.text); } catch { errorData = { error: errorContent.text }; } logger.error(`${tool.name} handler returned error`, errorData); throw new Error(errorContent.text); } } // Parse the JSON content for structured response const textContent = result.content[0]; if (textContent?.type === 'text') { let parsedData; try { parsedData = JSON.parse(textContent.text); } catch { parsedData = textContent.text; } logger.info(`Successfully processed ${tool.name}`, { success: true, hasData: parsedData !== null && parsedData !== undefined, }); // Wrap arrays in an object for MCP SDK compatibility // Use tool-specific field names for arrays const structuredData = Array.isArray(parsedData) ? { [tool.name]: parsedData } : parsedData; const finalResponse = { content: result.content, structuredContent: structuredData as Record<string, unknown>, isError: false, } as McpResponse; logger.info(`===== TOOL INVOCATION SUCCESS: ${tool.name} =====`, { responseType: 'ApiResponse', finalResponse, }); return finalResponse; } logger.info(`===== TOOL INVOCATION SUCCESS: ${tool.name} =====`, { responseType: 'ApiResponse-passthrough', result, }); return result as unknown as McpResponse; } // For non-ApiResponse results, wrap them // Wrap arrays in an object for MCP SDK compatibility // Use tool-specific field names for arrays const structuredResult = Array.isArray(result) ? { [tool.name]: result } : result; const wrappedResponse = { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], structuredContent: structuredResult as Record<string, unknown>, isError: false, } as McpResponse; logger.info(`===== TOOL INVOCATION SUCCESS: ${tool.name} =====`, { responseType: 'wrapped', wrappedResponse, }); return wrappedResponse; } catch (error) { logger.error(`===== TOOL INVOCATION ERROR: ${tool.name} =====`, { error, errorMessage: error instanceof Error ? error.message : String(error), errorStack: error instanceof Error ? error.stack : undefined, }); const errorMessage = logAndFormatError(error, tool.name); const errorResponse = createErrorResponse(error, `Failed to execute ${tool.name}`); // Extract structured error data let structuredError = {}; try { const errorContent = errorResponse.content[0]; if (errorContent?.type === 'text') { structuredError = JSON.parse(errorContent.text); } } catch { structuredError = { error: errorMessage }; } const finalErrorResponse = { content: [ { type: 'text', text: errorMessage, }, ], structuredContent: structuredError as Record<string, unknown>, isError: true, } as McpResponse; logger.error(`===== TOOL INVOCATION FAILED: ${tool.name} =====`, { finalErrorResponse, }); return finalErrorResponse; } } ); logger.info(`MCP server.registerTool completed for: ${tool.name}`); } catch (error) { logger.error(`Failed to register tool ${tool.name} with MCP server`, { error, errorMessage: error instanceof Error ? error.message : String(error), errorStack: error instanceof Error ? error.stack : undefined, }); throw error; } logger.info(`Tool ${tool.name} registered successfully`); } /** * Registers multiple tools at once * @param tools - Array of tool definitions */ registerTools(tools: ToolDefinition[]): void { for (const tool of tools) { this.registerTool(tool); } } /** * Gets a registered tool by name * @param name - Tool name * @returns The tool definition or undefined */ getTool(name: string): ToolDefinition | undefined { return this.tools.get(name); } /** * Gets all registered tools * @returns Array of tool names */ getToolNames(): string[] { return Array.from(this.tools.keys()); } /** * Checks if a tool is registered * @param name - Tool name * @returns True if registered */ hasTool(name: string): boolean { return this.tools.has(name); } /** * Updates the default dependencies * @param deps - New default dependencies */ updateDefaultDeps(deps: BaseHandlerDeps): void { this.defaultDeps = deps; logger.debug('Default dependencies updated'); } /** * Discovers and loads tools from specified directories (requires FEATURE_TOOL_DISCOVERY) * @param options - Discovery options * @returns Array of discovered tool names */ async discoverTools(options: ToolDiscoveryOptions = {}): Promise<string[]> { if (!isFeatureEnabled('toolDiscovery')) { logger.debug('Tool discovery is disabled by feature flag'); return []; } const { directories = ['./tools'], patterns = ['*.tool.js', '*.tool.mjs'], recursive = true, includeCategories, excludeCategories, includeTags, excludeTags, } = options; logger.info('Starting tool discovery', { directories, patterns }); const discoveredTools: string[] = []; for (const directory of directories) { try { const filterOptions: { includeCategories?: string[]; excludeCategories?: string[]; includeTags?: string[]; excludeTags?: string[]; } = {}; if (includeCategories) filterOptions.includeCategories = includeCategories; if (excludeCategories) filterOptions.excludeCategories = excludeCategories; if (includeTags) filterOptions.includeTags = includeTags; if (excludeTags) filterOptions.excludeTags = excludeTags; const toolsFound = await this.scanDirectory(directory, patterns, recursive, filterOptions); discoveredTools.push(...toolsFound); } catch (error) { logger.warn(`Failed to scan directory: ${directory}`, error); } } logger.info(`Tool discovery complete. Found ${discoveredTools.length} tools`); return discoveredTools; } /** * Scans a directory for tool files (internal) */ private async scanDirectory( directory: string, patterns: string[], recursive: boolean, filters: { includeCategories?: string[]; excludeCategories?: string[]; includeTags?: string[]; excludeTags?: string[]; } ): Promise<string[]> { const discoveredTools: string[] = []; try { const entries = await fs.readdir(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(directory, entry.name); if (entry.isDirectory() && recursive) { const subTools = await this.scanDirectory(fullPath, patterns, recursive, filters); discoveredTools.push(...subTools); } else if (entry.isFile() && ToolRegistry.matchesPattern(entry.name, patterns)) { const toolName = await this.loadToolFromFile(fullPath, filters); if (toolName) { discoveredTools.push(toolName); this.discoveredTools.set(toolName, fullPath); } } } } catch (error) { logger.error(`Failed to scan directory: ${directory}`, error); } return discoveredTools; } /** * Checks if a filename matches any of the patterns */ private static matchesPattern(filename: string, patterns: string[]): boolean { return patterns.some((pattern) => { // Convert glob pattern to regex // Escape special regex characters except for * const escaped = pattern.replace(/[-[\]{}()+?.,\\^$|#\s]/g, '\\$&'); // Replace * with [^/]* (matches any sequence except path separator) // Anchor the pattern to match the entire filename const regexStr = `^${escaped.replace(/\*/g, '[^/]*')}$`; const regex = new RegExp(regexStr); return regex.test(filename); }); } /** * Loads a tool from a file */ private async loadToolFromFile( filePath: string, filters: { includeCategories?: string[]; excludeCategories?: string[]; includeTags?: string[]; excludeTags?: string[]; } ): Promise<string | null> { try { // Security validation: ensure the file path is within expected directories const normalizedPath = normalize(filePath); const resolvedPath = resolve(normalizedPath); // Check for path traversal attempts if (normalizedPath.includes('..') || !resolvedPath.startsWith(process.cwd())) { logger.error(`Security: Rejected file path outside project directory: ${filePath}`); return null; } // Additional validation: ensure it's a JavaScript/TypeScript file if (!/\.(js|mjs|ts)$/.test(normalizedPath)) { logger.error(`Security: Rejected non-JavaScript file: ${filePath}`); return null; } logger.debug(`Loading tool from file: ${filePath}`); const module = (await import(filePath)) as Record<string, unknown>; let toolDef: ToolDefinition | null = null; if (module.toolDefinition && typeof module.toolDefinition === 'object') { toolDef = module.toolDefinition as ToolDefinition; } else if (module.default && typeof module.default === 'object') { const defaultExport = module.default as Record<string, unknown>; if ( 'name' in defaultExport && 'handler' in defaultExport && 'description' in defaultExport ) { toolDef = defaultExport as unknown as ToolDefinition; } } if (!toolDef) { logger.warn(`No valid tool definition found in: ${filePath}`); return null; } if (!ToolRegistry.passesFilters(toolDef, filters)) { logger.debug(`Tool ${toolDef.name} filtered out`); return null; } if (toolDef.metadata?.enabled === false) { logger.info(`Tool ${toolDef.name} is disabled, skipping registration`); return null; } this.registerTool(toolDef); logger.info(`Successfully loaded tool: ${toolDef.name} from ${filePath}`); return toolDef.name; } catch (error) { logger.error(`Failed to load tool from file: ${filePath}`, error); return null; } } /** * Checks if a tool passes the configured filters */ private static passesFilters( tool: ToolDefinition, filters: { includeCategories?: string[]; excludeCategories?: string[]; includeTags?: string[]; excludeTags?: string[]; } ): boolean { const metadata = tool.metadata || {}; if (filters.includeCategories?.length) { if (!metadata.category || !filters.includeCategories.includes(metadata.category)) { return false; } } if (filters.excludeCategories?.length) { if (metadata.category && filters.excludeCategories.includes(metadata.category)) { return false; } } if (filters.includeTags?.length) { if (!metadata.tags || !metadata.tags.some((tag) => filters.includeTags?.includes(tag))) { return false; } } if (filters.excludeTags?.length) { if (metadata.tags?.some((tag) => filters.excludeTags?.includes(tag))) { return false; } } return true; } /** * Gets tool metadata * @param name - Tool name * @returns Tool metadata or undefined */ getToolMetadata(name: string): ToolMetadata | undefined { return this.toolMetadata.get(name); } /** * Gets tools by category (requires metadata) * @param category - Category to filter by * @returns Array of tool names in the category */ getToolsByCategory(category: string): string[] { const tools: string[] = []; for (const [name, metadata] of this.toolMetadata.entries()) { if (metadata.category === category) { tools.push(name); } } return tools; } /** * Gets tools by tag (requires metadata) * @param tag - Tag to filter by * @returns Array of tool names with the tag */ getToolsByTag(tag: string): string[] { const tools: string[] = []; for (const [name, metadata] of this.toolMetadata.entries()) { if (metadata.tags?.includes(tag)) { tools.push(name); } } return tools; } /** * Gets all tool categories * @returns Array of unique categories */ getCategories(): string[] { const categories = new Set<string>(); for (const metadata of this.toolMetadata.values()) { if (metadata.category) { categories.add(metadata.category); } } return Array.from(categories); } /** * Gets all tool tags * @returns Array of unique tags */ getTags(): string[] { const tags = new Set<string>(); for (const metadata of this.toolMetadata.values()) { if (metadata.tags) { metadata.tags.forEach((tag) => tags.add(tag)); } } return Array.from(tags); } /** * Gets enhanced tool information including metadata * @returns Array of tool information objects */ getToolsInfo(): Array<{ name: string; description: string; category?: string; version?: string; tags?: string[]; enabled?: boolean; discovered?: boolean; }> { const toolsInfo = []; for (const name of this.getToolNames()) { const tool = this.getTool(name); const metadata = this.getToolMetadata(name); const discovered = this.discoveredTools.has(name); if (tool) { toolsInfo.push({ name: tool.name, description: tool.description, ...(metadata?.category && { category: metadata.category }), ...(metadata?.version && { version: metadata.version }), ...(metadata?.tags && { tags: metadata.tags }), enabled: metadata?.enabled !== false, discovered, }); } } return toolsInfo; } }

Latest Blog Posts

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/sapientpants/deepsource-mcp-server'

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