From 829a2409b250ac021ccacff8c79f12def5a52445 Mon Sep 17 00:00:00 2001 From: CrepuscularIRIS <139546753+CrepuscularIRIS@users.noreply.github.com> Date: Sat, 4 Apr 2026 09:54:18 +0800 Subject: [PATCH 1/2] feat(webuiapps): add minimal MCP stdio bridge endpoints --- apps/webuiapps/vite.config.ts | 383 ++++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) diff --git a/apps/webuiapps/vite.config.ts b/apps/webuiapps/vite.config.ts index d2635e4..2596d3c 100644 --- a/apps/webuiapps/vite.config.ts +++ b/apps/webuiapps/vite.config.ts @@ -9,6 +9,7 @@ import { sentryVitePlugin } from '@sentry/vite-plugin'; import * as fs from 'fs'; import * as os from 'os'; import { join } from 'path'; +import { spawn } from 'child_process'; import { generateLogFileName, createLogMiddleware } from './src/lib/logPlugin'; import { appGeneratorPlugin } from './src/lib/appGeneratorPlugin'; @@ -16,6 +17,8 @@ const LLM_CONFIG_FILE = resolve(os.homedir(), '.openroom', 'config.json'); const SESSIONS_DIR = resolve(os.homedir(), '.openroom', 'sessions'); const CHARACTERS_FILE = resolve(os.homedir(), '.openroom', 'characters.json'); const MODS_FILE = resolve(os.homedir(), '.openroom', 'mods.json'); +const MCP_SERVERS_FILE = resolve(os.homedir(), '.openroom', 'mcp-servers.json'); +const MCP_REQUEST_TIMEOUT_MS = Number(process.env.OPENROOM_MCP_TIMEOUT_MS || 20000); /** LLM config persistence plugin — reads/writes config to ~/.openroom/config.json */ function llmConfigPlugin(): Plugin { @@ -369,6 +372,385 @@ function jsonFilePlugin(name: string, apiPath: string, filePath: string): Plugin }; } +function tryParseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +async function collectRequestBody(req: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + return await new Promise((resolveBody, rejectBody) => { + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf-8'))); + req.on('error', rejectBody); + }); +} + +async function readJsonFileSafe(filePath: string, fallback: T): Promise { + try { + const raw = await fs.promises.readFile(filePath, 'utf-8'); + const parsed = tryParseJson(raw); + return (parsed as T) ?? fallback; + } catch { + return fallback; + } +} + +interface McpServerConfig { + name: string; + command: string; + args?: string[]; + env?: Record; +} + +function normalizeMcpServer(input: unknown): McpServerConfig | null { + if (!input || typeof input !== 'object') return null; + const obj = input as Record; + const name = String(obj.name || '').trim(); + const command = String(obj.command || '').trim(); + if (!name || !command) return null; + + const args = Array.isArray(obj.args) + ? obj.args.map((value) => String(value)).filter((value) => value.length > 0) + : []; + + const env: Record = {}; + if (obj.env && typeof obj.env === 'object' && !Array.isArray(obj.env)) { + for (const [key, value] of Object.entries(obj.env as Record)) { + const safeKey = String(key || '').trim(); + if (!safeKey) continue; + env[safeKey] = String(value ?? ''); + } + } + + return { + name, + command, + args, + env, + }; +} + +async function loadMcpServers(): Promise { + const parsed = await readJsonFileSafe(MCP_SERVERS_FILE, { servers: [] }); + const obj = parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + const rawServers = Array.isArray(obj.servers) ? obj.servers : []; + return rawServers + .map((item) => normalizeMcpServer(item)) + .filter((item): item is McpServerConfig => Boolean(item)); +} + +async function saveMcpServers(servers: McpServerConfig[]): Promise { + await fs.promises.mkdir(resolve(os.homedir(), '.openroom'), { recursive: true }); + await fs.promises.writeFile(MCP_SERVERS_FILE, JSON.stringify({ servers }, null, 2), 'utf-8'); +} + +function mcpFrameEncode(payload: unknown): Buffer { + const body = Buffer.from(JSON.stringify(payload), 'utf-8'); + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf-8'); + return Buffer.concat([header, body]); +} + +function readMcpFrames(onMessage: (message: Record) => void) { + let buffer = Buffer.alloc(0); + return (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const headerRaw = buffer.slice(0, headerEnd).toString('utf-8'); + const match = headerRaw.match(/content-length\s*:\s*(\d+)/i); + if (!match) { + buffer = Buffer.alloc(0); + return; + } + const bodyLen = Number(match[1]); + const frameEnd = headerEnd + 4 + bodyLen; + if (buffer.length < frameEnd) return; + + const body = buffer.slice(headerEnd + 4, frameEnd).toString('utf-8'); + buffer = buffer.slice(frameEnd); + const parsed = tryParseJson(body); + if (parsed && typeof parsed === 'object') { + onMessage(parsed as Record); + } + } + }; +} + +async function runMcpClient( + mcpServer: McpServerConfig, + runner: ( + request: (method: string, params?: Record) => Promise, + ) => Promise, +): Promise { + const child = spawn(mcpServer.command, mcpServer.args || [], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...(mcpServer.env || {}) }, + }); + + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (err: Error) => void; timer: NodeJS.Timeout } + >(); + let reqId = 1; + let stderrBuffer = ''; + + child.stderr.on('data', (chunk: Buffer) => { + stderrBuffer += chunk.toString('utf-8'); + if (stderrBuffer.length > 4000) { + stderrBuffer = stderrBuffer.slice(-4000); + } + }); + + const onData = readMcpFrames((message) => { + const idRaw = message.id; + if (typeof idRaw !== 'number') return; + const pendingReq = pending.get(idRaw); + if (!pendingReq) return; + + clearTimeout(pendingReq.timer); + pending.delete(idRaw); + if (message.error) { + pendingReq.reject(new Error(JSON.stringify(message.error))); + return; + } + pendingReq.resolve(message.result); + }); + child.stdout.on('data', onData); + + const stop = () => { + for (const [, item] of pending.entries()) { + clearTimeout(item.timer); + item.reject(new Error('mcp process terminated')); + } + pending.clear(); + try { + child.kill('SIGTERM'); + } catch { + // ignore + } + }; + + child.on('exit', () => stop()); + + const sendRequest = async ( + method: string, + params: Record = {}, + ): Promise => { + const id = reqId++; + const payload = { + jsonrpc: '2.0', + id, + method, + params, + }; + + return await new Promise((resolveReq, rejectReq) => { + const timer = setTimeout(() => { + pending.delete(id); + rejectReq(new Error(`mcp timeout ${method}`)); + }, MCP_REQUEST_TIMEOUT_MS); + pending.set(id, { resolve: resolveReq, reject: rejectReq, timer }); + child.stdin.write(mcpFrameEncode(payload)); + }); + }; + + try { + await sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'openroom-mcp-bridge', version: '0.1.0' }, + }); + child.stdin.write( + mcpFrameEncode({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), + ); + return await runner(sendRequest); + } catch (err) { + const extra = stderrBuffer.trim(); + const base = err instanceof Error ? err.message : String(err); + throw new Error(extra ? `${base}; stderr=${extra}` : base); + } finally { + stop(); + } +} + +async function listMcpToolsForServer(server: McpServerConfig): Promise< + Array<{ + server: string; + name: string; + description?: string; + inputSchema?: Record; + }> +> { + const result = await runMcpClient(server, async (request) => { + const data = (await request('tools/list')) as Record; + const tools = Array.isArray(data?.tools) ? data.tools : []; + return tools; + }); + + return result + .map((tool) => { + const obj = tool && typeof tool === 'object' ? (tool as Record) : {}; + const name = String(obj.name || '').trim(); + if (!name) return null; + const description = String(obj.description || '').trim() || undefined; + const inputSchema = + obj.inputSchema && typeof obj.inputSchema === 'object' && !Array.isArray(obj.inputSchema) + ? (obj.inputSchema as Record) + : undefined; + return { + server: server.name, + name, + description, + inputSchema, + }; + }) + .filter( + ( + item, + ): item is { + server: string; + name: string; + description?: string; + inputSchema?: Record; + } => Boolean(item), + ); +} + +function mcpBridgePlugin(): Plugin { + return { + name: 'mcp-bridge', + configureServer(server) { + server.middlewares.use('/api/mcp-servers', async (req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET') { + const servers = await loadMcpServers(); + res.writeHead(200); + res.end(JSON.stringify({ servers })); + return; + } + + if (req.method === 'POST') { + try { + const raw = await collectRequestBody(req); + const parsed = (tryParseJson(raw) || {}) as Record; + const items = Array.isArray(parsed.servers) ? parsed.servers : []; + const servers = items + .map((item) => normalizeMcpServer(item)) + .filter((item): item is McpServerConfig => Boolean(item)); + await saveMcpServers(servers); + res.writeHead(200); + res.end(JSON.stringify({ ok: true, servers })); + } catch (err) { + res.writeHead(500); + res.end(JSON.stringify({ ok: false, error: String(err) })); + } + return; + } + + res.writeHead(405); + res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + }); + + server.middlewares.use('/api/mcp-tools', async (req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method !== 'GET') { + res.writeHead(405); + res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + return; + } + + try { + const servers = await loadMcpServers(); + const tools: Array<{ + server: string; + name: string; + description?: string; + inputSchema?: Record; + }> = []; + const errors: Array<{ server: string; error: string }> = []; + for (const item of servers) { + try { + const listed = await listMcpToolsForServer(item); + tools.push(...listed); + } catch (err) { + errors.push({ + server: item.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + res.writeHead(200); + res.end(JSON.stringify({ ok: true, tools, errors })); + } catch (err) { + res.writeHead(500); + res.end(JSON.stringify({ ok: false, error: String(err), tools: [], errors: [] })); + } + }); + + server.middlewares.use('/api/mcp-call', async (req, res) => { + res.setHeader('Content-Type', 'application/json'); + if (req.method !== 'POST') { + res.writeHead(405); + res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + return; + } + + try { + const raw = await collectRequestBody(req); + const parsed = (tryParseJson(raw) || {}) as Record; + const serverName = String(parsed.server || '').trim(); + const toolName = String(parsed.tool || '').trim(); + const args = + parsed.arguments && + typeof parsed.arguments === 'object' && + !Array.isArray(parsed.arguments) + ? (parsed.arguments as Record) + : {}; + + if (!serverName || !toolName) { + res.writeHead(400); + res.end(JSON.stringify({ ok: false, error: 'server and tool are required' })); + return; + } + + const servers = await loadMcpServers(); + const target = servers.find((item) => item.name === serverName); + if (!target) { + res.writeHead(404); + res.end(JSON.stringify({ ok: false, error: `MCP server not found: ${serverName}` })); + return; + } + + const result = await runMcpClient(target, async (request) => { + return await request('tools/call', { + name: toolName, + arguments: args, + }); + }); + res.writeHead(200); + res.end(JSON.stringify({ ok: true, result })); + } catch (err) { + res.writeHead(500); + res.end( + JSON.stringify({ + ok: false, + error: err instanceof Error ? err.message : String(err), + }), + ); + } + }); + }, + }; +} + const config = ({ mode }: ConfigEnv): UserConfigExport => { const env = loadEnv(mode, process.cwd(), ''); const isProd = env.NODE_ENV === 'production'; @@ -396,6 +778,7 @@ const config = ({ mode }: ConfigEnv): UserConfigExport => { sessionDataPlugin(), logServerPlugin(), llmProxyPlugin(), + mcpBridgePlugin(), jsonFilePlugin('characters', '/api/characters', CHARACTERS_FILE), jsonFilePlugin('mods', '/api/mods', MODS_FILE), appGeneratorPlugin({ From 198f6b3fca52e71ef9d61c988b15da95d115c9a0 Mon Sep 17 00:00:00 2001 From: CrepuscularIRIS <139546753+CrepuscularIRIS@users.noreply.github.com> Date: Sat, 4 Apr 2026 09:54:32 +0800 Subject: [PATCH 2/2] test(webuiapps): cover MCP tool name normalization --- .../src/lib/__tests__/mcpBridgeTools.test.ts | 16 ++ apps/webuiapps/src/lib/mcpBridgeTools.ts | 196 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 apps/webuiapps/src/lib/__tests__/mcpBridgeTools.test.ts create mode 100644 apps/webuiapps/src/lib/mcpBridgeTools.ts diff --git a/apps/webuiapps/src/lib/__tests__/mcpBridgeTools.test.ts b/apps/webuiapps/src/lib/__tests__/mcpBridgeTools.test.ts new file mode 100644 index 0000000..3814674 --- /dev/null +++ b/apps/webuiapps/src/lib/__tests__/mcpBridgeTools.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { isMcpToolName, toMcpToolName } from '../mcpBridgeTools'; + +describe('mcpBridgeTools', () => { + it('builds deterministic tool names', () => { + expect(toMcpToolName('OpenClaw Agent', 'call_xiaomei')).toBe( + 'mcp__openclaw_agent__call_xiaomei', + ); + expect(toMcpToolName(' SERVER ', 'Tool Name')).toBe('mcp__server__tool_name'); + }); + + it('detects mcp tool prefix', () => { + expect(isMcpToolName('mcp__alpha__beta')).toBe(true); + expect(isMcpToolName('delegate_to_main_agent')).toBe(false); + }); +}); diff --git a/apps/webuiapps/src/lib/mcpBridgeTools.ts b/apps/webuiapps/src/lib/mcpBridgeTools.ts new file mode 100644 index 0000000..65fd7ba --- /dev/null +++ b/apps/webuiapps/src/lib/mcpBridgeTools.ts @@ -0,0 +1,196 @@ +import type { ToolDef } from './llmClient'; + +export interface McpBridgeServer { + name: string; + command: string; + args?: string[]; + env?: Record; +} + +export interface McpBridgeTool { + server: string; + name: string; + description?: string; + inputSchema?: Record; +} + +export interface McpBridgeToolIndex { + toolDefs: ToolDef[]; + index: Record; + tools: McpBridgeTool[]; + errors: Array<{ server: string; error: string }>; +} + +const MCP_NAME_PREFIX = 'mcp__'; + +function safeJsonParse(raw: string, fallback: T): T { + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function asObject(input: unknown): Record { + return input && typeof input === 'object' && !Array.isArray(input) + ? (input as Record) + : {}; +} + +function sanitizeName(value: string): string { + return (value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80); +} + +export function toMcpToolName(server: string, tool: string): string { + const s = sanitizeName(server) || 'server'; + const t = sanitizeName(tool) || 'tool'; + return `${MCP_NAME_PREFIX}${s}__${t}`; +} + +export function isMcpToolName(toolName: string): boolean { + return toolName.startsWith(MCP_NAME_PREFIX); +} + +function toToolDef(item: McpBridgeTool): ToolDef { + const schema = asObject(item.inputSchema); + const toolName = toMcpToolName(item.server, item.name); + const hasObjectSchema = schema.type === 'object'; + + const normalizedParameters: ToolDef['function']['parameters'] = hasObjectSchema + ? { + type: 'object', + properties: + schema.properties && + typeof schema.properties === 'object' && + !Array.isArray(schema.properties) + ? (schema.properties as Record) + : {}, + required: Array.isArray(schema.required) ? schema.required.map((v) => String(v)) : [], + } + : { + type: 'object', + properties: { + payload_json: { + type: 'string', + description: 'JSON string payload for the MCP tool call', + }, + }, + required: [], + }; + + return { + type: 'function', + function: { + name: toolName, + description: `[MCP:${item.server}] ${item.description || item.name}`, + parameters: normalizedParameters, + }, + }; +} + +export async function loadMcpBridgeToolIndex(): Promise { + try { + const res = await fetch('/api/mcp-tools'); + const data = safeJsonParse<{ + ok?: boolean; + tools?: McpBridgeTool[]; + errors?: Array<{ server: string; error: string }>; + }>(await res.text(), {}); + + const tools = Array.isArray(data.tools) ? data.tools : []; + const index: Record = {}; + const toolDefs: ToolDef[] = []; + + for (const item of tools) { + if (!item?.server || !item?.name) continue; + const toolName = toMcpToolName(item.server, item.name); + index[toolName] = item; + toolDefs.push(toToolDef(item)); + } + + return { + tools, + index, + toolDefs, + errors: Array.isArray(data.errors) ? data.errors : [], + }; + } catch { + return { tools: [], index: {}, toolDefs: [], errors: [] }; + } +} + +function normalizeMcpParams(input: Record): Record { + if (typeof input.payload_json === 'string' && input.payload_json.trim()) { + const parsed = safeJsonParse>(input.payload_json, {}); + return asObject(parsed); + } + + const output: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (k === 'payload_json') continue; + output[k] = v; + } + return output; +} + +export async function executeMcpBridgeTool( + toolName: string, + params: Record, + index: Record, +): Promise { + const target = index[toolName]; + if (!target) { + return `error: MCP tool not found for ${toolName}`; + } + + const payload = { + server: target.server, + tool: target.name, + arguments: normalizeMcpParams(params), + }; + + const res = await fetch('/api/mcp-call', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const text = await res.text(); + const data = safeJsonParse<{ ok?: boolean; result?: unknown; error?: string }>(text, {}); + if (!res.ok || !data.ok) { + return `error: ${data.error || text || `HTTP ${res.status}`}`; + } + + if (typeof data.result === 'string') { + return data.result; + } + return JSON.stringify(data.result, null, 2); +} + +export async function loadMcpBridgeServers(): Promise { + try { + const res = await fetch('/api/mcp-servers'); + const data = safeJsonParse<{ servers?: McpBridgeServer[] }>(await res.text(), {}); + return Array.isArray(data.servers) ? data.servers : []; + } catch { + return []; + } +} + +export async function saveMcpBridgeServers(servers: McpBridgeServer[]): Promise { + try { + const res = await fetch('/api/mcp-servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ servers }), + }); + return res.ok; + } catch { + return false; + } +}