264 lines
7.4 KiB
TypeScript

/**
* 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<string, ToolDefinition>();
private registeredCategories = new Set<string>();
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<void> {
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<void> {
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;
}
}