mcp tool server for claude code — native tool execution via stdio protocol
Replaces prompt injection workaround with a proper MCP server that dynamically discovers marketplace tools and exposes them as callable tools to Claude Code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 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 } from 'node:fs';
|
||||
import { join } 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;
|
||||
}
|
||||
|
||||
// ── 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) => {
|
||||
let executeFn: Function | undefined;
|
||||
try {
|
||||
const mod = await import(capturedEntry);
|
||||
executeFn = mod.execute ?? mod.default?.execute;
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to load ${toolName}: ${String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof executeFn !== 'function') {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `${toolName}/index.ts must export an "execute" function` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await executeFn('mcp', params, undefined, undefined);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
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`);
|
||||
@@ -1,18 +1,18 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { PiEvent, MessageCost } from '../../api/pi/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
||||
import { getState, setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { getProxySecret } from './proxy';
|
||||
import { discoverTools } from '../../tool-registry';
|
||||
import type { ToolDefinition } from '../../tool-registry';
|
||||
import { DATA_PATH, getHomeDir } from '../../data-path';
|
||||
import { DATA_PATH, getHomeDir, getGlobalToolsDir, getUserToolsDir } from '../../data-path';
|
||||
import { generateContainerContext } from '../../generate-container-context';
|
||||
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
||||
|
||||
const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts');
|
||||
|
||||
function refreshClaudeMd(email: string): void {
|
||||
const claudeDir = join(getHomeDir(email), '.claude');
|
||||
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
||||
@@ -20,25 +20,29 @@ function refreshClaudeMd(email: string): void {
|
||||
writeFileSync(join(claudeDir, 'CLAUDE.md'), readFileSync(contextFile, 'utf-8'));
|
||||
}
|
||||
|
||||
function buildToolContext(email: string): string {
|
||||
const tools = discoverTools(email);
|
||||
if (tools.length === 0) return '';
|
||||
function generateMcpConfig(email: string): string {
|
||||
const contextDir = join(DATA_PATH, email, '.container-context');
|
||||
mkdirSync(contextDir, { recursive: true });
|
||||
const configPath = join(contextDir, 'mcp.json');
|
||||
|
||||
const sections = tools.map((tool) => {
|
||||
const lines = [`## ${tool.name}`, tool.description];
|
||||
const inputEntries = Object.entries(tool.inputs);
|
||||
if (inputEntries.length > 0) {
|
||||
const inputParts = inputEntries.map(([name, input]) => {
|
||||
const opt = input.optional ? ', optional' : '';
|
||||
const vals = input.values ? `, values: ${input.values}` : '';
|
||||
return `${name} (${input.type}${vals}${opt}): ${input.description}`;
|
||||
});
|
||||
lines.push(`Inputs: ${inputParts.join('; ')}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
});
|
||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].filter(existsSync).join(':');
|
||||
|
||||
return `The following tools are available on this platform:\n\n${sections.join('\n\n---\n\n')}\n\n---\n\n`;
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'officer-tools': {
|
||||
type: 'stdio',
|
||||
command: 'bun',
|
||||
args: ['run', MCP_SERVER_SCRIPT],
|
||||
env: {
|
||||
PI_TOOLS_DIRS: toolsDirs,
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
const toShellUsername = (username: string, email: string): string => {
|
||||
@@ -97,12 +101,12 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
|
||||
refreshClaudeMd(email);
|
||||
const mcpConfigPath = generateMcpConfig(email);
|
||||
|
||||
const existingSession = getClaudeSession(sessionKey);
|
||||
const isResume = !!existingSession;
|
||||
|
||||
const effectivePrompt = isResume ? prompt : buildToolContext(email) + prompt;
|
||||
const claudeArgs = [CLAUDE_BIN, '-p', effectivePrompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
||||
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json', '--mcp-config', mcpConfigPath];
|
||||
|
||||
const subModel = params.model?.split('/')[1];
|
||||
if (subModel) claudeArgs.push('--model', subModel);
|
||||
@@ -194,20 +198,22 @@ export async function spawnClaudeStreaming(
|
||||
const workDir = cwd ?? homeDir;
|
||||
|
||||
refreshClaudeMd(email);
|
||||
const mcpConfigPath = generateMcpConfig(email);
|
||||
|
||||
const existingSession = getClaudeSession(sessionKey);
|
||||
const isResume = !!existingSession;
|
||||
|
||||
const effectivePrompt = isResume ? prompt : buildToolContext(email) + prompt;
|
||||
const claudeArgs = [
|
||||
CLAUDE_BIN,
|
||||
'-p',
|
||||
effectivePrompt,
|
||||
prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
'--mcp-config',
|
||||
mcpConfigPath,
|
||||
];
|
||||
|
||||
const subModel = params.model?.split('/')[1];
|
||||
|
||||
@@ -10,6 +10,7 @@ type ToolInput = {
|
||||
type: string;
|
||||
description: string;
|
||||
values?: string;
|
||||
optional?: string | boolean;
|
||||
};
|
||||
|
||||
type MarketplaceTool = {
|
||||
@@ -45,6 +46,9 @@ function buildToolMd(tool: MarketplaceTool): string {
|
||||
if (input.values) {
|
||||
lines.push(` values: ${input.values}`);
|
||||
}
|
||||
if (String(input.optional) === 'true') {
|
||||
lines.push(` optional: true`);
|
||||
}
|
||||
lines.push(` description: "${input.description}"`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user