changemaker.lite/mcp-server/src/tool-registry.ts

173 lines
5.2 KiB
TypeScript

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { ApiClient } from './api-client.js';
// ---------- Types ----------
/** Standard tool definition — maps directly to an API endpoint */
export interface ToolDef {
name: string;
description: string;
inputSchema: Record<string, z.ZodTypeAny>;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
path: string; // e.g., '/api/campaigns/:id'
tier: 1 | 2;
}
/** Composite tool — custom handler that chains multiple API calls */
export interface CompositeToolDef {
name: string;
description: string;
inputSchema: Record<string, z.ZodTypeAny>;
handler: (args: Record<string, unknown>, apiClient: ApiClient) => Promise<string>;
tier: 3;
}
export type AnyToolDef = ToolDef | CompositeToolDef;
/** Tool pack metadata */
export interface ToolPack {
name: string;
description: string;
tools: AnyToolDef[];
}
// ---------- Handler Factory ----------
/**
* Create a generic handler for a standard ToolDef.
* Substitutes path params (`:id` → `args.id`), then sends remaining
* fields as query params (GET/DELETE) or body (POST/PUT/PATCH).
*/
function createHandler(tool: ToolDef, apiClient: ApiClient) {
return async (args: Record<string, unknown>): Promise<string> => {
// Clone args so we can remove path params without mutating
const remaining = { ...args };
// Substitute path parameters
let path = tool.path;
const pathParamMatches = tool.path.match(/:(\w+)/g) || [];
for (const match of pathParamMatches) {
const key = match.slice(1);
if (remaining[key] !== undefined) {
path = path.replace(match, String(remaining[key]));
delete remaining[key];
} else {
throw new Error(`Missing required path parameter: ${key}`);
}
}
// Strip undefined values from remaining params
const cleanParams: Record<string, unknown> = {};
for (const [k, v] of Object.entries(remaining)) {
if (v !== undefined) cleanParams[k] = v;
}
const hasParams = Object.keys(cleanParams).length > 0;
const result = await apiClient.request(
tool.method,
path,
hasParams ? cleanParams : undefined
);
return JSON.stringify(result, null, 2);
};
}
// ---------- Pack Manager ----------
export class ToolRegistry {
private server: McpServer;
private apiClient: ApiClient;
private activePacks = new Set<string>();
private availablePacks = new Map<string, ToolPack>();
private registeredTools = new Set<string>();
constructor(server: McpServer, apiClient: ApiClient) {
this.server = server;
this.apiClient = apiClient;
}
/** Register a batch of tools (Tier 1 core tools) */
registerTools(tools: AnyToolDef[]): void {
for (const tool of tools) {
this.registerSingleTool(tool);
}
}
/** Register a tool pack (available for on-demand activation) */
registerPack(pack: ToolPack): void {
this.availablePacks.set(pack.name, pack);
}
/** Activate a tool pack — registers its tools and notifies the client */
enablePack(packName: string): { success: boolean; message: string } {
if (this.activePacks.has(packName)) {
return { success: true, message: `Pack "${packName}" is already active` };
}
const pack = this.availablePacks.get(packName);
if (!pack) {
const available = Array.from(this.availablePacks.keys()).join(', ');
return {
success: false,
message: `Unknown pack "${packName}". Available: ${available}`,
};
}
for (const tool of pack.tools) {
this.registerSingleTool(tool);
}
this.activePacks.add(packName);
return {
success: true,
message: `Enabled "${packName}" pack — ${pack.tools.length} tools added: ${pack.tools.map((t) => t.name).join(', ')}`,
};
}
/** Get status of all available packs */
getPackStatus(): Array<{ name: string; description: string; active: boolean; toolCount: number }> {
return Array.from(this.availablePacks.entries()).map(([name, pack]) => ({
name,
description: pack.description,
active: this.activePacks.has(name),
toolCount: pack.tools.length,
}));
}
// ---------- Internal ----------
private registerSingleTool(tool: AnyToolDef): void {
if (this.registeredTools.has(tool.name)) return; // Prevent duplicate registration
if (tool.tier === 3) {
// Composite tool — custom handler
const composite = tool as CompositeToolDef;
const apiClient = this.apiClient;
this.server.tool(
composite.name,
composite.description,
composite.inputSchema,
async (args) => ({
content: [{ type: 'text' as const, text: await composite.handler(args as Record<string, unknown>, apiClient) }],
})
);
} else {
// Standard tool — generic HTTP handler
const standard = tool as ToolDef;
const handler = createHandler(standard, this.apiClient);
this.server.tool(
standard.name,
standard.description,
standard.inputSchema,
async (args) => ({
content: [{ type: 'text' as const, text: await handler(args as Record<string, unknown>) }],
})
);
}
this.registeredTools.add(tool.name);
}
}