/** * MCP Server with lazy tool loading * Starts with core tools, dynamically registers categories on demand */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; import type { ToolDefinition, ToolModule } from './tools/types.js'; import type { GSCClient } from './lib/gsc-client.js'; import type { Cache } from './lib/cache.js'; import type { RateLimiter } from './lib/rate-limit.js'; // Import always-registered tools import discoveryTools from './tools/discovery.js'; export interface ServerConfig { gscClient: GSCClient; cache: Cache; rateLimiter: RateLimiter; } export class GSCServer { private server: Server; private gscClient: GSCClient; private cache: Cache; private rateLimiter: RateLimiter; // Tool registry private toolRegistry = new Map(); private registeredCategories = new Set(); constructor(config: ServerConfig) { this.gscClient = config.gscClient; this.cache = config.cache; this.rateLimiter = config.rateLimiter; this.server = new Server( { name: 'google-search-console-mcp', version: '1.0.0' }, { capabilities: { tools: {} } } ); // Register always-available discovery tools this.registerCategory('discovery', discoveryTools); this.setupHandlers(); } /** * Register a tool category */ private registerCategory(category: string, module: ToolModule): void { if (this.registeredCategories.has(category)) { return; // Already registered } for (const tool of module.tools) { this.toolRegistry.set(tool.name, tool); } this.registeredCategories.add(category); console.error(`Registered category: ${category} (${module.tools.length} tools)`); } /** * Lazy load a tool category */ private async loadCategory(category: string): Promise { if (this.registeredCategories.has(category)) { return; // Already loaded } console.error(`Lazy loading category: ${category}`); try { let module: ToolModule; switch (category) { case 'analytics': module = await import('./tools/analytics.js'); break; case 'intelligence': // Will be implemented in phase 2 throw new Error('Intelligence tools not yet implemented'); case 'indexing': // Will be implemented in phase 2 throw new Error('Indexing tools not yet implemented'); case 'sitemaps': // Will be implemented in phase 2 throw new Error('Sitemaps tools not yet implemented'); case 'management': // Will be implemented in phase 2 throw new Error('Management tools not yet implemented'); default: throw new Error(`Unknown category: ${category}`); } this.registerCategory(category, (module as any).default || module); // Send tools/list_changed notification await this.server.notification({ method: 'notifications/tools/list_changed', params: {} }); } catch (error: any) { console.error(`Failed to load category ${category}:`, error); throw error; } } /** * Setup MCP protocol handlers */ private setupHandlers(): void { // List tools handler this.server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = Array.from(this.toolRegistry.values()).map(tool => ({ name: tool.name, description: tool.description, inputSchema: zodToJsonSchema(tool.inputSchema), _meta: tool._meta })); return { tools }; }); // Call tool handler this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const toolName = request.params.name; const tool = this.toolRegistry.get(toolName); if (!tool) { // Check if this is a discover_tools call that should trigger lazy loading if (toolName === 'discover_tools' && request.params.arguments?.category) { const category = request.params.arguments.category as string; try { await this.loadCategory(category); // Re-execute discover_tools to show the new tools const discoveryTool = this.toolRegistry.get('discover_tools'); if (discoveryTool) { const result = await discoveryTool.handler(request.params.arguments, this.gscClient); return { content: result.content, isError: result.isError || false }; } } catch (error: any) { return { content: [{ type: 'text' as const, text: `Failed to load category: ${error.message}` }], isError: true }; } } throw new McpError( ErrorCode.MethodNotFound, `Tool not found: ${toolName}. Use discover_tools to see available tools.` ); } try { // Parse and validate arguments const args = tool.inputSchema.parse(request.params.arguments || {}); // Check cache for read-only operations if (tool.annotations.readOnlyHint) { const cached = this.cache.get<{ content: any[]; isError?: boolean }>(toolName, args); if (cached) { console.error(`Cache hit: ${toolName}`); return { content: cached.content, isError: cached.isError || false }; } } // Acquire rate limit token const apiType = this.getAPIType(toolName); await this.rateLimiter.acquire(apiType); // Execute tool console.error(`Executing tool: ${toolName}`); const result = await tool.handler(args, this.gscClient); // Cache read-only results if (tool.annotations.readOnlyHint && !result.isError) { this.cache.set(toolName, args, result); } return { content: result.content, isError: result.isError }; } catch (error: any) { console.error(`Tool execution error (${toolName}):`, error); if (error.name === 'ZodError') { return { content: [{ type: 'text', text: `Invalid arguments: ${JSON.stringify(error.errors, null, 2)}` }], isError: true }; } return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; } }); } /** * Determine API type for rate limiting */ private getAPIType(toolName: string): 'gsc' | 'inspection' | 'indexing' { if (toolName.includes('inspect')) { return 'inspection'; } if (toolName.includes('indexing') || toolName === 'request_indexing') { return 'indexing'; } return 'gsc'; } /** * Connect server to stdio transport */ async connect(): Promise { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Google Search Console MCP server running on stdio'); } /** * Get server instance for testing */ getServer(): Server { return this.server; } }