291 lines
8.5 KiB
TypeScript
291 lines
8.5 KiB
TypeScript
/**
|
|
* MCP Tool Server
|
|
*
|
|
* Standalone stdio MCP server that dynamically discovers and exposes
|
|
* Officer marketplace tools to Claude Code. Reads tool directories from
|
|
* PI_TOOLS_DIRS, parses TOOL.md frontmatter for schemas, and routes
|
|
* tool calls to each tool's execute() function.
|
|
*
|
|
* Usage:
|
|
* PI_TOOLS_DIRS=/data/tools:/data/user/tools bun run src/servers/mcp-tool-server.ts
|
|
*/
|
|
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import { z } from 'zod';
|
|
import { readdirSync, existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
|
|
// ── Types ──
|
|
|
|
type ToolParamType = 'string' | 'number' | 'boolean' | 'enum';
|
|
|
|
type ToolParam = {
|
|
type: ToolParamType;
|
|
description: string;
|
|
values?: string[];
|
|
optional?: boolean;
|
|
};
|
|
|
|
type ToolMeta = {
|
|
name: string;
|
|
label: string;
|
|
description: string;
|
|
inputs: Record<string, ToolParam>;
|
|
};
|
|
|
|
type DiscoveredTool = {
|
|
entryFile: string;
|
|
meta: ToolMeta;
|
|
};
|
|
|
|
// ── Frontmatter parsing (same logic as tool-loader-source.ts) ──
|
|
|
|
function parseFrontmatter(content: string): { meta: Partial<ToolMeta>; body: string } {
|
|
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
if (!match) return { meta: {}, body: content };
|
|
|
|
const yamlBlock = match[1]!;
|
|
const body = match[2]!;
|
|
const meta: Record<string, unknown> = {};
|
|
|
|
const lines = yamlBlock.split('\n');
|
|
let currentKey: string | null = null;
|
|
let currentObj: Record<string, unknown> | null = null;
|
|
let currentSubKey: string | null = null;
|
|
let currentSubObj: Record<string, unknown> | null = null;
|
|
|
|
for (const line of lines) {
|
|
const topMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
if (topMatch && !line.startsWith(' ')) {
|
|
if (currentSubObj && currentSubKey && currentObj) {
|
|
currentObj[currentSubKey] = currentSubObj;
|
|
currentSubObj = null;
|
|
currentSubKey = null;
|
|
}
|
|
if (currentObj && currentKey) {
|
|
meta[currentKey] = currentObj;
|
|
currentObj = null;
|
|
currentKey = null;
|
|
}
|
|
const [, key, value] = topMatch;
|
|
if (!value || value.trim() === '') {
|
|
currentKey = key!;
|
|
currentObj = {};
|
|
} else {
|
|
meta[key!] = value.trim();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const midMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/);
|
|
if (midMatch && currentObj !== null) {
|
|
if (currentSubObj && currentSubKey) {
|
|
currentObj[currentSubKey] = currentSubObj;
|
|
currentSubObj = null;
|
|
currentSubKey = null;
|
|
}
|
|
const [, key, value] = midMatch;
|
|
if (!value || value.trim() === '') {
|
|
currentSubKey = key!;
|
|
currentSubObj = {};
|
|
} else {
|
|
currentObj[key!] = value.trim();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const deepMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/);
|
|
if (deepMatch && currentSubObj !== null) {
|
|
const [, key, value] = deepMatch;
|
|
currentSubObj[key!] = value!.trim();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (currentSubObj && currentSubKey && currentObj) {
|
|
currentObj[currentSubKey] = currentSubObj;
|
|
}
|
|
if (currentObj && currentKey) {
|
|
meta[currentKey] = currentObj;
|
|
}
|
|
|
|
return { meta: meta as Partial<ToolMeta>, body };
|
|
}
|
|
|
|
// ── Tool discovery ──
|
|
|
|
function discoverTools(dirs: string[]): DiscoveredTool[] {
|
|
const seen = new Set<string>();
|
|
const tools: DiscoveredTool[] = [];
|
|
|
|
for (const dir of dirs) {
|
|
if (!existsSync(dir)) continue;
|
|
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
const toolDir = join(dir, entry.name);
|
|
const toolMdPath = join(toolDir, 'TOOL.md');
|
|
if (!existsSync(toolMdPath)) continue;
|
|
|
|
const indexTs = join(toolDir, 'index.ts');
|
|
const indexJs = join(toolDir, 'index.js');
|
|
const entryFile = existsSync(indexTs) ? indexTs : existsSync(indexJs) ? indexJs : null;
|
|
if (!entryFile) continue;
|
|
|
|
const content = readFileSync(toolMdPath, 'utf-8');
|
|
const { meta } = parseFrontmatter(content);
|
|
|
|
if (!meta.name || !meta.description) continue;
|
|
if (seen.has(meta.name)) continue;
|
|
seen.add(meta.name);
|
|
|
|
tools.push({ entryFile, meta: meta as ToolMeta });
|
|
}
|
|
}
|
|
|
|
return tools;
|
|
}
|
|
|
|
// ── Schema building (frontmatter inputs → zod) ──
|
|
|
|
function buildZodSchema(inputs: Record<string, ToolParam>): z.ZodRawShape {
|
|
const shape: z.ZodRawShape = {};
|
|
|
|
for (const [name, param] of Object.entries(inputs)) {
|
|
let field: z.ZodTypeAny;
|
|
|
|
switch (param.type) {
|
|
case 'enum': {
|
|
const values = Array.isArray(param.values)
|
|
? param.values
|
|
: typeof param.values === 'string'
|
|
? (param.values as string).split(',').map((v) => v.trim())
|
|
: [];
|
|
if (values.length > 0) {
|
|
field = z.enum(values as [string, ...string[]]).describe(param.description);
|
|
} else {
|
|
field = z.string().describe(param.description);
|
|
}
|
|
break;
|
|
}
|
|
case 'number':
|
|
field = z.number().describe(param.description);
|
|
break;
|
|
case 'boolean':
|
|
field = z.boolean().describe(param.description);
|
|
break;
|
|
default:
|
|
field = z.string().describe(param.description);
|
|
}
|
|
|
|
shape[name] = param.optional ? field.optional() : field;
|
|
}
|
|
|
|
return shape;
|
|
}
|
|
|
|
// ── Logging ──
|
|
|
|
const LOG_FILE = process.env.MCP_TOOLS_LOG ?? '';
|
|
|
|
function logToolCall(toolName: string, durationMs: number, success: boolean, error?: string): void {
|
|
const ts = new Date().toISOString();
|
|
const status = success ? 'ok' : 'error';
|
|
const line = error
|
|
? `${ts}\t${toolName}\t${status}\t${durationMs}ms\t${error}\n`
|
|
: `${ts}\t${toolName}\t${status}\t${durationMs}ms\n`;
|
|
|
|
// Always log to stderr for process-level visibility
|
|
console.error(`[mcp-tools] ${toolName} ${status} (${durationMs}ms)${error ? ': ' + error : ''}`);
|
|
|
|
// Write to log file if configured
|
|
if (LOG_FILE) {
|
|
try {
|
|
mkdirSync(dirname(LOG_FILE), { recursive: true });
|
|
appendFileSync(LOG_FILE, line);
|
|
} catch {
|
|
// Don't fail tool calls over logging
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Main ──
|
|
|
|
const rawDirs = process.env.PI_TOOLS_DIRS ?? '';
|
|
const toolDirs = rawDirs.split(':').filter(Boolean);
|
|
|
|
if (toolDirs.length === 0) {
|
|
console.error('[mcp-tools] PI_TOOLS_DIRS not set — no tools to serve');
|
|
process.exit(1);
|
|
}
|
|
|
|
const tools = discoverTools(toolDirs);
|
|
|
|
if (tools.length === 0) {
|
|
console.error('[mcp-tools] No tools found in:', toolDirs.join(', '));
|
|
process.exit(1);
|
|
}
|
|
|
|
const server = new McpServer({ name: 'officer-tools', version: '1.0.0' });
|
|
|
|
for (const { entryFile, meta } of tools) {
|
|
const schema = buildZodSchema(meta.inputs ?? {});
|
|
const capturedEntry = entryFile;
|
|
const toolName = meta.name;
|
|
|
|
server.registerTool(
|
|
toolName,
|
|
{
|
|
title: meta.label ?? toolName,
|
|
description: meta.description,
|
|
inputSchema: z.object(schema),
|
|
},
|
|
async (params) => {
|
|
const start = performance.now();
|
|
let executeFn: Function | undefined;
|
|
try {
|
|
const mod = await import(capturedEntry);
|
|
executeFn = mod.execute ?? mod.default?.execute;
|
|
} catch (err) {
|
|
const ms = Math.round(performance.now() - start);
|
|
logToolCall(toolName, ms, false, `load failed: ${String(err)}`);
|
|
return {
|
|
content: [{ type: 'text' as const, text: `Failed to load ${toolName}: ${String(err)}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
if (typeof executeFn !== 'function') {
|
|
const ms = Math.round(performance.now() - start);
|
|
logToolCall(toolName, ms, false, 'no execute function');
|
|
return {
|
|
content: [{ type: 'text' as const, text: `${toolName}/index.ts must export an "execute" function` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const result = await executeFn('mcp', params, undefined, undefined);
|
|
const ms = Math.round(performance.now() - start);
|
|
logToolCall(toolName, ms, !result.isError, result.isError ? 'tool returned error' : undefined);
|
|
return result;
|
|
} catch (err) {
|
|
const ms = Math.round(performance.now() - start);
|
|
logToolCall(toolName, ms, false, String(err));
|
|
return {
|
|
content: [{ type: 'text' as const, text: `${toolName} threw: ${String(err)}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
console.error(`[mcp-tools] Registered: ${toolName} (${capturedEntry})`);
|
|
}
|
|
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
console.error(`[mcp-tools] Server running with ${tools.length} tools`);
|