264 lines
7.4 KiB
TypeScript
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;
|
|
}
|
|
}
|