173 lines
5.2 KiB
TypeScript
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);
|
|
}
|
|
}
|