#!/usr/bin/env node import { fileURLToPath } from 'node:url'; import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { decode } from './code.js'; import { AgentClient } from './agent-client.js'; import { loadRegistry, upsertEntry } from './registry.js'; const state = { current: null, servers: new Map(), }; function result(obj) { return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] }; } function guard(handler) { return async (args) => { try { return result(await handler(args ?? {})); } catch (e) { return result({ ok: false, error: String(e && e.message ? e.message : e) }); } }; } async function requireCurrent(op, args) { if (!state.current || !state.current.client || !state.current.client.connected) { return { ok: false, error: 'no server connected — call connect_server first' }; } return state.current.client.request(op, args ?? {}); } const DIAGNOSTIC_TOOLS = [ { name: 'server_health', title: 'Server health (one-shot hub)', description: 'THE one-shot diagnostic hub: answers "what is wrong" in a single call by aggregating recent errors, queue state, rotation and system info.', op: 'server_health', inputSchema: { errorLimit: z.number().optional(), errorSinceMs: z.number().optional(), }, }, { name: 'read_log', title: 'Read a log file', description: 'Read a tail of one of the app log files, optionally a rotated backup. grep is a case-insensitive substring filter; separate alternatives with "|" (e.g. "error|timeout|502") to keep any line matching at least one term. Not a regular expression.', op: 'read_log', inputSchema: { name: z.enum(['debug', 'fileuploader', 'accountRotation', 'crash']), tailKb: z.number().optional(), grep: z.string().optional(), backup: z.number().optional(), }, }, { name: 'list_logs', title: 'List available logs', description: 'List the available log files and their sizes.', op: 'list_logs', inputSchema: {}, }, { name: 'list_errors', title: 'List recent errors', description: 'List recent structured errors, filterable by time window, category and hoster.', op: 'list_errors', inputSchema: { sinceMs: z.number().optional(), category: z.string().optional(), hoster: z.string().optional(), limit: z.number().optional(), }, }, { name: 'get_queue_state', title: 'Get queue state', description: 'Get the upload queue state, optionally including individual jobs.', op: 'get_queue_state', inputSchema: { includeJobs: z.boolean().optional(), maxJobs: z.number().optional(), }, }, { name: 'get_history', title: 'Get upload history', description: 'Get recent upload history, optionally including file names and URLs.', op: 'get_history', inputSchema: { limit: z.number().optional(), includeFiles: z.boolean().optional(), includeUrls: z.boolean().optional(), }, }, { name: 'get_config_redacted', title: 'Get redacted config', description: 'Get the app configuration with secrets redacted. Choose a section to narrow the output.', op: 'get_config_redacted', inputSchema: { section: z.enum(['all', 'hosters', 'hosterSettings', 'globalSettings', 'rotationCursors']).optional(), }, }, { name: 'get_system_info', title: 'Get system info', description: 'Get system and app version info (OS, app version, uptime).', op: 'get_system_info', inputSchema: {}, }, { name: 'get_rotation_state', title: 'Get rotation state', description: 'Get the per-hoster account rotation state and round-robin cursors.', op: 'get_rotation_state', inputSchema: {}, }, { name: 'get_app_events', title: 'Get app events', description: 'Get recent in-app lifecycle events.', op: 'get_app_events', inputSchema: { limit: z.number().optional(), }, }, ]; async function doConnect({ code, host, port, label }) { const registry = await loadRegistry(); let target; if (label && registry[label]) { const e = registry[label]; target = { host: e.host, port: e.port, token: e.token, fp: e.fp, label: e.label }; } else { if (!code) { return { ok: false, error: 'provide a known label, or a code (the host is taken from the code; pass host only to override)' }; } let payload; try { payload = decode(code); } catch (e) { return { ok: false, error: String(e.message ?? e) }; } const effHost = host || payload.host; if (!effHost) { return { ok: false, error: 'no host in the code and none provided — pass host (e.g. the Tailscale IP/MagicDNS name)' }; } target = { host: effHost, port: typeof port === 'number' ? port : payload.port, token: payload.token, fp: payload.fp, label: label || payload.label || effHost, }; } if (typeof port === 'number') target.port = port; const client = new AgentClient(target); try { await client.connect(); } catch (e) { return { ok: false, error: String(e.message ?? e) }; } let version; const info = await client.request('get_system_info', {}); if (info && info.ok && info.data) { const d = info.data; version = d.version ?? d.appVersion ?? (d.app && d.app.version) ?? (d.agent && d.agent.version) ?? undefined; } const id = `${target.label}@${target.host}:${target.port}`; if (state.current && state.current.client && state.current.client !== client) { state.current.client.close(); } state.servers.set(id, { id, client, target }); state.current = { id, client, target }; await upsertEntry({ host: target.host, port: target.port, token: target.token, fp: target.fp, label: target.label, version, lastConnectedAt: new Date().toISOString(), }); return { ok: true, server: { label: target.label, host: target.host, port: target.port, version }, }; } export function buildServer() { const server = new McpServer({ name: 'mhu-diagnostics-gateway', version: '1.0.0' }); server.registerTool( 'list_servers', { title: 'List known servers', description: 'List the registry of known diagnostic servers and which one is currently connected.', inputSchema: {}, }, guard(async () => { const registry = await loadRegistry(); return { ok: true, servers: Object.values(registry), current: state.current ? state.current.id : null, }; }), ); server.registerTool( 'connect_server', { title: 'Connect to a diagnostic server', description: 'Connect to a remote diagnostic agent. Use label for a known server, or a code for a new one — the host (e.g. a Tailscale IP/MagicDNS name) is taken from the code. Pass host only to override what the code carries.', inputSchema: { code: z.string().optional(), host: z.string().optional(), port: z.number().optional(), label: z.string().optional(), }, }, guard((args) => doConnect(args)), ); server.registerTool( 'disconnect_server', { title: 'Disconnect the current server', description: 'Close the connection to the currently connected diagnostic server.', inputSchema: {}, }, guard(async () => { if (!state.current) return { ok: true, disconnected: false }; const id = state.current.id; try { state.current.client.close(); } catch {} state.servers.delete(id); state.current = null; return { ok: true, disconnected: true, id }; }), ); server.registerTool( 'current_server', { title: 'Show the current server', description: 'Show the currently connected diagnostic target, or null if none.', inputSchema: {}, }, guard(async () => { if (!state.current) return { ok: true, current: null }; const t = state.current.target; return { ok: true, current: { id: state.current.id, label: t.label, host: t.host, port: t.port }, }; }), ); for (const tool of DIAGNOSTIC_TOOLS) { server.registerTool( tool.name, { title: tool.title, description: tool.description, inputSchema: tool.inputSchema }, guard((args) => requireCurrent(tool.op, args)), ); } return server; } async function main() { const server = buildServer(); const transport = new StdioServerTransport(); await server.connect(transport); console.error('[mhu-diag] gateway ready (stdio MCP)'); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main().catch((e) => { console.error('[mhu-diag] fatal:', e); process.exit(1); }); }