deepsource-mcp-server
Version:
Model Context Protocol server for DeepSource
536 lines • 22.9 kB
JavaScript
/**
* @fileoverview Tool registry for managing MCP tool definitions and handlers
* @packageDocumentation
*/
import { z } from 'zod';
import { promises as fs } from 'fs';
import { join, normalize, resolve } from 'path';
import { createLogger } from '../utils/logging/logger.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';
const logger = createLogger('ToolRegistry');
/**
* Registry for managing MCP tools
*/
export class ToolRegistry {
tools = new Map();
toolMetadata = new Map();
discoveredTools = new Map(); // tool name -> file path
server;
defaultDeps;
constructor(server, defaultDeps) {
this.server = server;
this.defaultDeps = defaultDeps || createDefaultHandlerDeps();
logger.info('ToolRegistry initialized');
}
/**
* Registers a tool with the MCP server
* @param tool - The tool definition
*/
registerTool(tool) {
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);
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,
schemaKeys: tool.inputSchema ? Object.keys(tool.inputSchema) : [],
}
: null,
});
// MCP SDK expects Zod schemas directly
const toolConfig = {
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, _) => {
// 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;
if (tool.inputSchema && params !== undefined) {
logger.debug(`Validating params for ${tool.name}`, {
schemaType: typeof tool.inputSchema,
hasDefProperty: '_def' in tool.inputSchema,
hasSafeParseMethod: 'safeParse' in tool.inputSchema,
});
// 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)
: tool.inputSchema;
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 ?? {});
}
// 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,
isError: false,
};
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;
}
// 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,
isError: false,
};
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,
isError: true,
};
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) {
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) {
return this.tools.get(name);
}
/**
* Gets all registered tools
* @returns Array of tool names
*/
getToolNames() {
return Array.from(this.tools.keys());
}
/**
* Checks if a tool is registered
* @param name - Tool name
* @returns True if registered
*/
hasTool(name) {
return this.tools.has(name);
}
/**
* Updates the default dependencies
* @param deps - New default dependencies
*/
updateDefaultDeps(deps) {
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 = {}) {
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 = [];
for (const directory of directories) {
try {
const filterOptions = {};
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)
*/
async scanDirectory(directory, patterns, recursive, filters) {
const discoveredTools = [];
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
*/
static matchesPattern(filename, patterns) {
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
*/
async loadToolFromFile(filePath, filters) {
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));
let toolDef = null;
if (module.toolDefinition && typeof module.toolDefinition === 'object') {
toolDef = module.toolDefinition;
}
else if (module.default && typeof module.default === 'object') {
const defaultExport = module.default;
if ('name' in defaultExport &&
'handler' in defaultExport &&
'description' in defaultExport) {
toolDef = defaultExport;
}
}
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
*/
static passesFilters(tool, filters) {
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) {
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) {
const tools = [];
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) {
const tools = [];
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() {
const categories = new Set();
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() {
const tags = new Set();
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() {
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;
}
}
//# sourceMappingURL=tool-registry.js.map