Repo-side hardening and tooling from the intensive test round (none of this ships in the app installer). - gateway: registry.json (holds bearer tokens) now gets a best-effort owner-only NTFS ACL on Windows via `icacls /inheritance:r /grant:r <user>:F` (the chmod 0600 is a no-op on NTFS); verified the file ends up <user>:(F) only. - gateway: connect_server now reads the app version from the real get_system_info shape (data.app.version / data.agent.version), so "connected to vX.Y.Z" works. - gateway: read_log tool description documents grep as a case-insensitive substring filter with "|" alternation (not a regex), matching the agent-side change. - gateway: standalone verification harnesses moved to gateway/verify/ (so `node --test` only sweeps real unit tests) and exposed via `npm run verify`: e2e-verify, integration-mcp (live gateway-MCP <-> agent, all 14 tools), and adversarial-probe (redaction fuzz + ReDoS + lockout). `npm test` runs the units. - eslint: gateway/** now lints as ESM (sourceType module) via a dedicated block; global ignores fixed so `eslint .` is clean across the whole project (0 errors). - docs/remote-diagnostics-setup.md: made the transport story honest — the agent speaks plaintext ws:// over enforced loopback; the SSH/WireGuard tunnel is the ONLY confidentiality layer (wss/TLS + cert-pin is a documented future mode, not active). Removed the stale "bind to a LAN/VPN IP" guidance (loopback is enforced). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
8.5 KiB
JavaScript
296 lines
8.5 KiB
JavaScript
#!/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 plus host' };
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = decode(code);
|
|
} catch (e) {
|
|
return { ok: false, error: String(e.message ?? e) };
|
|
}
|
|
if (!host) {
|
|
return { ok: false, error: 'host is required when connecting with a code' };
|
|
}
|
|
target = {
|
|
host,
|
|
port: typeof port === 'number' ? port : payload.port,
|
|
token: payload.token,
|
|
fp: payload.fp,
|
|
label: label || payload.label,
|
|
};
|
|
}
|
|
|
|
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 code+host for a new one. The host is always supplied by you, never taken from the code.',
|
|
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);
|
|
});
|
|
}
|