/** * 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; }; type DiscoveredTool = { entryFile: string; meta: ToolMeta; }; // ── Frontmatter parsing (same logic as tool-loader-source.ts) ── function parseFrontmatter(content: string): { meta: Partial; 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 = {}; const lines = yamlBlock.split('\n'); let currentKey: string | null = null; let currentObj: Record | null = null; let currentSubKey: string | null = null; let currentSubObj: Record | 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, body }; } // ── Tool discovery ── function discoverTools(dirs: string[]): DiscoveredTool[] { const seen = new Set(); 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): 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`);