Ferndiagnose (MCP): Verbindungscode + abgesicherter Fernzugriff + stdio-Bridge
Neue Funktion, um Diagnose eines laufenden Servers aus der Ferne zu ermoeglichen:
Hilfe -> Remote-Support -> "Ferndiagnose (MCP)". Erzeugt einen Verbindungscode,
den der Assistent nutzt, um Status, Logs, Fehler und Accounts read-only zu lesen.
App-Seite:
- Live (re)startbarer Debug-Server ohne App-Neustart (restartDebugServer wartet auf
'close' + closeAllConnections, behandelt EADDRINUSE).
- IP-Allowlist (debug_allowlist.txt, exakte IP + CIDR), erzwungen VOR der Auth.
Fail-closed: Netzwerk-Bind (0.0.0.0) ohne Allowlist akzeptiert nur Loopback.
- One-Click Aktivieren/Aktualisieren/Deaktivieren + Token-Rotation (alter Code sofort
ungueltig). Sichtbarkeit waehlbar: "Nur lokal" (Tunnel-Empfehlung) vs "Im Netzwerk".
- Verbindungscode rddiag:v1:base64url({v,h,p,t,n?,fp?,s?}); oeffentlicher Host frei
waehlbar, Netzwerk-IPs als Schnellauswahl.
- Neue IPC: get/enable/disable/rotate Remote-Diagnostics; Controller-Methoden; Typen.
Bridge (tools/rd-diagnostics-mcp, standalone, KEINE App-Dependency):
- stdio MCP-Server (@modelcontextprotocol/sdk) mit 14 Tools, proxyt die bestehende
HTTP-Debug-API. Multi-Server ueber code/server/RDDIAG_CODE/RDDIAG_SERVERS.
- TLS-Fingerprint-Pinning auf secureConnect (vor Token-Versand), falls https genutzt.
- test/harness.mjs: faehrt einen Fake-Debug-Server hoch und treibt die Bridge als
echten stdio-Child per JSON-RPC -> voller Protokollpfad gruen.
Sicherheit (Audit): keine persistenten Secrets in den Logs der Debug-API (Passwoerter
redigiert, keine Debrid-Keys/aufgeloesten Download-URLs geloggt; settings/accounts
redigiert). Empfohlener Transport: Loopback + privater Tunnel; Direkt-Bind nur mit
Allowlist in vertrauenswuerdigen Netzen.
Tests: connection-code-Cross-Check (App-Encoder <-> Bridge-Decoder), Allowlist-Matrix
(Loopback, exakt, CIDR, fail-closed, Live-Restart). Volle Suite 905 gruen, tsc=6.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# rd-diagnostics-mcp
|
||||
|
||||
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
|
||||
AI assistant (Claude Code) runs, takes a **connection code** for a downloader server, and exposes that server's
|
||||
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
|
||||
One bridge serves all 5–6 servers; you pass a `code` (or a configured `server` name) per call.
|
||||
|
||||
This bridge is **not** bundled into the Electron app and adds **no** dependencies to it.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd tools/rd-diagnostics-mcp
|
||||
npm install
|
||||
```
|
||||
|
||||
Register it with Claude Code (single default server):
|
||||
|
||||
```bash
|
||||
claude mcp add rd-diag -- node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
|
||||
```
|
||||
|
||||
Provide servers via environment variables (codes contain a token — treat like passwords):
|
||||
|
||||
- `RDDIAG_CODE` — a single default connection code (`rddiag:v1:...`)
|
||||
- `RDDIAG_SERVERS` — JSON map of name → code, e.g. `{"berlin":"rddiag:v1:...","fra":"rddiag:v1:..."}`
|
||||
|
||||
Without env config, every tool simply takes a `code` argument.
|
||||
|
||||
## Tools
|
||||
|
||||
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`,
|
||||
`rd_history`, `rd_accounts`, `rd_host`, `rd_self_check`, `rd_get` (raw escape-hatch, any read-only path).
|
||||
|
||||
Each tool accepts `code` or `server` to pick the target.
|
||||
|
||||
## Connection code
|
||||
|
||||
Format: `rddiag:v1:<base64url(JSON)>` with `{ v:1, h:host, p:port, t:token, n?:name, fp?:certFingerprint, s?:scheme }`.
|
||||
Generated by the app (Hilfe → Remote-Support → Ferndiagnose (MCP)) or via `node src/gen-code.mjs --host H --port P --token T`.
|
||||
|
||||
## Security model (read before exposing a server)
|
||||
|
||||
- The debug surface is **read-only** for state/logs; the one control endpoint is `/trace/config` (toggles the
|
||||
optional, time-bounded support trace). No persistent secrets are written into the logs it serves: passwords are
|
||||
redacted, debrid API keys/tokens and resolved download URLs are never logged; `/settings` and `/accounts` are redacted.
|
||||
- Auth is a bearer token (24 random bytes). Over plain HTTP on a public network the token is sniffable, so:
|
||||
- **Preferred:** keep the server bound to `127.0.0.1` ("Nur lokal") and reach it through a private tunnel
|
||||
(Tailscale / SSH / Cloudflare Tunnel). The tunnel encrypts and authenticates; no public exposure.
|
||||
- **Direct network bind (`0.0.0.0`)** requires a non-empty **IP allowlist** (enforced fail-closed: with an empty
|
||||
allowlist only loopback is accepted). Use only inside a trusted LAN/VPN.
|
||||
- Revoke instantly from the app ("Token neu" or "Deaktivieren") — the old code stops working immediately.
|
||||
- `fp` pins a self-signed cert fingerprint and is verified on `secureConnect` (before the token is sent). HTTPS is
|
||||
not the v1 default; plain HTTP behind a tunnel is the recommended transport.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # spins a fake debug-server, runs the bridge as a stdio child, asserts the full protocol path
|
||||
```
|
||||
+1172
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "rd-diagnostics-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Standalone stdio MCP bridge to the Real-Debrid-Downloader debug-server. Connects via connection code, proxies the read-only HTTP diagnostics API as MCP tools.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"rd-diagnostics-mcp": "src/bridge.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/bridge.mjs",
|
||||
"test": "node test/harness.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { decodeConnectionCode } from "./code.mjs";
|
||||
import { debugGet } from "./http.mjs";
|
||||
|
||||
function loadServerMap() {
|
||||
const map = new Map();
|
||||
const raw = process.env.RDDIAG_SERVERS;
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
for (const [name, code] of Object.entries(parsed || {})) {
|
||||
map.set(String(name), String(code));
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write("rd-diagnostics-mcp: RDDIAG_SERVERS ist kein gueltiges JSON, wird ignoriert\n");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const SERVER_MAP = loadServerMap();
|
||||
const DEFAULT_CODE = process.env.RDDIAG_CODE ? String(process.env.RDDIAG_CODE) : "";
|
||||
|
||||
function listAvailableServers() {
|
||||
const names = [...SERVER_MAP.keys()];
|
||||
if (DEFAULT_CODE) names.push("(RDDIAG_CODE-Default)");
|
||||
return names;
|
||||
}
|
||||
|
||||
function resolveTarget(args) {
|
||||
let code = "";
|
||||
if (args && args.code) {
|
||||
code = String(args.code);
|
||||
} else if (args && args.server) {
|
||||
const found = SERVER_MAP.get(String(args.server));
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`Server "${args.server}" nicht konfiguriert. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
|
||||
);
|
||||
}
|
||||
code = found;
|
||||
} else if (DEFAULT_CODE) {
|
||||
code = DEFAULT_CODE;
|
||||
} else if (SERVER_MAP.size === 1) {
|
||||
code = [...SERVER_MAP.values()][0];
|
||||
} else {
|
||||
throw new Error(
|
||||
`Kein Verbindungscode. Uebergib "code" oder "server", oder setze RDDIAG_CODE/RDDIAG_SERVERS. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
|
||||
);
|
||||
}
|
||||
return decodeConnectionCode(code);
|
||||
}
|
||||
|
||||
function targetLabel(target) {
|
||||
return target.name ? `${target.name} (${target.host}:${target.port})` : `${target.host}:${target.port}`;
|
||||
}
|
||||
|
||||
function buildQuery(params) {
|
||||
const usable = Object.entries(params || {}).filter(
|
||||
([, v]) => v !== undefined && v !== null && String(v).length > 0
|
||||
);
|
||||
if (usable.length === 0) return "";
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of usable) sp.set(k, String(v));
|
||||
return "?" + sp.toString();
|
||||
}
|
||||
|
||||
function prettyBody(body) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
function connectionHint(err) {
|
||||
const m = String((err && err.code) || err && err.message || "");
|
||||
if (/ECONNREFUSED/.test(m)) return "Debug-Server nicht erreichbar — auf dem Server aktiviert? Port/Firewall offen?";
|
||||
if (/ENOTFOUND|EAI_AGAIN/.test(m)) return "Host nicht aufloesbar — stimmt die Adresse im Verbindungscode?";
|
||||
if (/ETIMEDOUT|Zeitueberschreitung/.test(m)) return "Zeitueberschreitung — Server/Netz langsam oder Port geblockt.";
|
||||
if (/ECONNRESET|EPIPE/.test(m)) return "Verbindung abgebrochen — falscher Port/Scheme (http vs https)?";
|
||||
if (/Fingerprint/.test(m)) return "TLS-Fingerprint passt nicht — Code stammt evtl. von einem anderen Server.";
|
||||
return "";
|
||||
}
|
||||
|
||||
async function requestTool(args, path, params, opts = {}) {
|
||||
let target;
|
||||
try {
|
||||
target = resolveTarget(args);
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text", text: `# Verbindungsfehler\n${err.message}` }], isError: true };
|
||||
}
|
||||
const fullPath = path + buildQuery(params);
|
||||
const label = targetLabel(target);
|
||||
try {
|
||||
const res = await debugGet(target, fullPath, { timeoutMs: opts.timeoutMs || 20000 });
|
||||
const isError = res.status < 200 || res.status >= 300;
|
||||
let extra = "";
|
||||
if (res.status === 401) extra = "\n(401 = Token im Verbindungscode ist abgelaufen/rotiert. Neuen Code anfordern.)";
|
||||
if (res.status === 503) extra = "\n(503 = Download-Manager nicht bereit. App laeuft, aber noch nicht initialisiert?)";
|
||||
const head = `# ${label} ${fullPath} → HTTP ${res.status}${extra}`;
|
||||
return { content: [{ type: "text", text: head + "\n" + prettyBody(res.body) }], isError };
|
||||
} catch (err) {
|
||||
const hint = connectionHint(err);
|
||||
const text = `# ${label} ${fullPath} → FEHLER\n${err.message}${hint ? "\n→ " + hint : ""}`;
|
||||
return { content: [{ type: "text", text }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
const CODE_FIELD = {
|
||||
code: z.string().optional().describe("Verbindungscode (rddiag:v1:...). Optional, wenn server/RDDIAG_CODE gesetzt ist."),
|
||||
server: z.string().optional().describe("Name eines via RDDIAG_SERVERS konfigurierten Servers statt eines vollen Codes.")
|
||||
};
|
||||
|
||||
const server = new McpServer({ name: "rd-diagnostics-mcp", version: "1.0.0" });
|
||||
|
||||
server.registerTool(
|
||||
"rd_servers",
|
||||
{
|
||||
title: "Konfigurierte Server",
|
||||
description: "Listet die in dieser Bridge konfigurierten Server (RDDIAG_SERVERS / RDDIAG_CODE). Verbindet sich nicht.",
|
||||
inputSchema: {}
|
||||
},
|
||||
async () => {
|
||||
const names = [...SERVER_MAP.keys()];
|
||||
const lines = [];
|
||||
lines.push(`Konfigurierte Server: ${names.length}`);
|
||||
for (const n of names) lines.push(`- ${n}`);
|
||||
lines.push(`Default (RDDIAG_CODE): ${DEFAULT_CODE ? "gesetzt" : "nicht gesetzt"}`);
|
||||
lines.push("");
|
||||
lines.push("Tools akzeptieren entweder code:<rddiag:v1:...> oder server:<name>.");
|
||||
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_ping",
|
||||
{
|
||||
title: "Erreichbarkeit pruefen",
|
||||
description: "Schneller Health-Check (GET /health): App-Version, Uptime, Speicher. Zuerst aufrufen, um Erreichbarkeit + Token zu pruefen.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/health", {}, { timeoutMs: 10000 })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_diagnostics",
|
||||
{
|
||||
title: "Gesamtdiagnose",
|
||||
description: "Aggregierter Zustand (GET /diagnostics): Meta, Status, Settings, Stats, Accounts, History, Host + die wichtigsten Logs. Der 'alles auf einen Blick'-Endpunkt.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
lines: z.number().int().positive().optional().describe("Anzahl Log-Zeilen pro Log (Default 150)."),
|
||||
grep: z.string().optional().describe("Filter fuer Log-Zeilen."),
|
||||
package: z.string().optional().describe("Optional auf ein Paket fokussieren.")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/diagnostics", { lines: args.lines, grep: args.grep, package: args.package }, { timeoutMs: 30000 })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_status",
|
||||
{
|
||||
title: "Live-Status",
|
||||
description: "Laufzeit-Status (GET /status): aktive Downloads, Queue, Provider-Zustand.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/status", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_items",
|
||||
{
|
||||
title: "Download-Items",
|
||||
description: "Einzelne Download-Items (GET /items), optional gefiltert nach Status/Paket.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
status: z.string().optional().describe("Status-Filter (z.B. downloading, error, done)."),
|
||||
package: z.string().optional().describe("Paket-Filter.")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/items", { status: args.status, package: args.package })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_packages",
|
||||
{
|
||||
title: "Pakete",
|
||||
description: "Pakete (GET /packages), optional mit enthaltenen Items.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
package: z.string().optional().describe("Bestimmtes Paket."),
|
||||
includeItems: z.boolean().optional().describe("Items mitliefern.")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/packages", { package: args.package, includeItems: args.includeItems ? "1" : "" })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_errors",
|
||||
{
|
||||
title: "Letzte Fehler",
|
||||
description: "Fehler-Ring (GET /errors): die letzten Fehler mit Level/Quelle. 'Was ist schiefgelaufen'.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
level: z.string().optional().describe("Level-Filter (ERROR, WARN, ...)."),
|
||||
limit: z.number().int().positive().optional().describe("Anzahl (Default 100).")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/errors", { level: args.level, limit: args.limit })
|
||||
);
|
||||
|
||||
const LOG_PATHS = {
|
||||
main: "/logs/main",
|
||||
audit: "/logs/audit",
|
||||
rename: "/logs/rename",
|
||||
trace: "/logs/trace",
|
||||
session: "/logs/session",
|
||||
package: "/logs/package",
|
||||
item: "/logs/item"
|
||||
};
|
||||
|
||||
server.registerTool(
|
||||
"rd_logs",
|
||||
{
|
||||
title: "Log lesen",
|
||||
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|package|item. Fuer package/item zusaetzlich package/item angeben.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
name: z.enum(["main", "audit", "rename", "trace", "session", "package", "item"]).describe("Welches Log."),
|
||||
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
|
||||
grep: z.string().optional().describe("Filter."),
|
||||
package: z.string().optional().describe("Nur fuer name=package."),
|
||||
item: z.string().optional().describe("Nur fuer name=item.")
|
||||
}
|
||||
},
|
||||
async (args) => {
|
||||
const path = LOG_PATHS[args.name];
|
||||
return requestTool(args, path, { lines: args.lines, grep: args.grep, package: args.package, item: args.item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_history",
|
||||
{
|
||||
title: "Verlauf",
|
||||
description: "Abgeschlossener Verlauf (GET /history), optional nach Status/Suchbegriff.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
limit: z.number().int().positive().optional().describe("Anzahl (Default 50)."),
|
||||
status: z.string().optional().describe("Status-Filter."),
|
||||
grep: z.string().optional().describe("Suchbegriff.")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/history", { limit: args.limit, status: args.status, grep: args.grep })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_accounts",
|
||||
{
|
||||
title: "Accounts",
|
||||
description: "Debrid-Accounts (GET /accounts, Token redigiert): Gueltigkeit, Premium, Cooldown/Rotation.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/accounts", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_host",
|
||||
{
|
||||
title: "Host-Diagnose",
|
||||
description: "Windows-Host-Diagnose (GET /host/diagnostics): Laufwerke, Speicher, Pfade.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/host/diagnostics", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_self_check",
|
||||
{
|
||||
title: "Self-Check",
|
||||
description: "Setup/Self-Check (GET /self-check): erkennt Konfigurations-/Pfadprobleme.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/self-check", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_get",
|
||||
{
|
||||
title: "Roh-Endpunkt (Escape-Hatch)",
|
||||
description: "Beliebigen Debug-Server-Pfad lesen (GET <path>), wenn kein spezialisiertes Tool passt. Pfad inkl. fuehrendem / und optionalem Query-String, z.B. /meta oder /stats.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
path: z.string().describe("Pfad mit fuehrendem /, optional ?query. Nur GET, read-only.")
|
||||
}
|
||||
},
|
||||
async (args) => {
|
||||
const p = String(args.path || "");
|
||||
if (!p.startsWith("/")) {
|
||||
return { content: [{ type: "text", text: "# Fehler\npath muss mit / beginnen" }], isError: true };
|
||||
}
|
||||
return requestTool(args, p, {}, { timeoutMs: 30000 });
|
||||
}
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.stderr.write(
|
||||
`rd-diagnostics-mcp bereit. Server: ${listAvailableServers().join(", ") || "(keine vorkonfiguriert; code pro Aufruf uebergeben)"}\n`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`rd-diagnostics-mcp Startfehler: ${err && err.stack ? err.stack : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface DecodedConnectionCode {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
scheme: string;
|
||||
name: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export interface EncodeConnectionCodeInput {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
name?: string;
|
||||
fingerprint?: string;
|
||||
scheme?: string;
|
||||
}
|
||||
|
||||
export function encodeConnectionCode(input: EncodeConnectionCodeInput): string;
|
||||
export function decodeConnectionCode(code: string): DecodedConnectionCode;
|
||||
@@ -0,0 +1,56 @@
|
||||
const PREFIX = "rddiag:v1:";
|
||||
|
||||
function base64urlEncode(str) {
|
||||
return Buffer.from(str, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64urlDecode(str) {
|
||||
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
|
||||
const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
||||
return Buffer.from(b64, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
export function encodeConnectionCode({ host, port, token, name, fingerprint, scheme }) {
|
||||
if (!host || typeof host !== "string") throw new Error("host fehlt");
|
||||
const p = Number(port);
|
||||
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("port ungueltig");
|
||||
if (!token || typeof token !== "string") throw new Error("token fehlt");
|
||||
const payload = { v: 1, h: host, p, t: token };
|
||||
if (name) payload.n = String(name);
|
||||
if (fingerprint) payload.fp = String(fingerprint);
|
||||
if (scheme && scheme !== "http") payload.s = String(scheme);
|
||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function decodeConnectionCode(code) {
|
||||
const raw = String(code || "").trim();
|
||||
if (!raw.startsWith(PREFIX)) {
|
||||
throw new Error(`Verbindungscode muss mit "${PREFIX}" beginnen`);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(base64urlDecode(raw.slice(PREFIX.length)));
|
||||
} catch {
|
||||
throw new Error("Verbindungscode ist beschaedigt (kein gueltiges base64url/JSON)");
|
||||
}
|
||||
if (!json || typeof json !== "object") throw new Error("Verbindungscode-Inhalt ungueltig");
|
||||
const host = String(json.h || "").trim();
|
||||
const port = Number(json.p);
|
||||
const token = String(json.t || "");
|
||||
if (!host) throw new Error("Verbindungscode ohne Host");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Verbindungscode mit ungueltigem Port");
|
||||
if (!token) throw new Error("Verbindungscode ohne Token");
|
||||
const scheme = json.s === "https" ? "https" : "http";
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
token,
|
||||
scheme,
|
||||
name: json.n ? String(json.n) : "",
|
||||
fingerprint: json.fp ? String(json.fp) : ""
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
import { encodeConnectionCode } from "./code.mjs";
|
||||
|
||||
function arg(name, fallback) {
|
||||
const i = process.argv.indexOf("--" + name);
|
||||
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const host = arg("host");
|
||||
const port = arg("port");
|
||||
const token = arg("token");
|
||||
const name = arg("name");
|
||||
const scheme = arg("scheme");
|
||||
const fingerprint = arg("fp");
|
||||
|
||||
if (!host || !port || !token) {
|
||||
process.stderr.write("Usage: node src/gen-code.mjs --host <h> --port <p> --token <t> [--name <n>] [--scheme https] [--fp <sha256>]\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(encodeConnectionCode({ host, port, token, name, scheme, fingerprint }) + "\n");
|
||||
@@ -0,0 +1,63 @@
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
|
||||
function normalizeFp(fp) {
|
||||
return String(fp || "").replace(/:/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function debugGet(target, path, { timeoutMs = 20000 } = {}) {
|
||||
const scheme = target.scheme === "https" ? "https" : "http";
|
||||
const lib = scheme === "https" ? https : http;
|
||||
const rel = path.startsWith("/") ? path : "/" + path;
|
||||
const url = new URL(rel, `${scheme}://${target.host}:${target.port}`);
|
||||
const pinning = scheme === "https" && !!target.fingerprint;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${target.token}`,
|
||||
Accept: "application/json"
|
||||
},
|
||||
timeout: timeoutMs
|
||||
};
|
||||
if (scheme === "https") {
|
||||
options.rejectUnauthorized = !target.fingerprint;
|
||||
}
|
||||
|
||||
const req = lib.request(url, options, (res) => {
|
||||
let data = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({ status: res.statusCode || 0, body: data, headers: res.headers });
|
||||
});
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error(`Zeitueberschreitung nach ${timeoutMs}ms`));
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
if (pinning) {
|
||||
req.on("socket", (socket) => {
|
||||
socket.on("secureConnect", () => {
|
||||
const cert = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate() : null;
|
||||
const got = normalizeFp(cert && cert.fingerprint256);
|
||||
const want = normalizeFp(target.fingerprint);
|
||||
if (!got || got !== want) {
|
||||
req.destroy(new Error(`TLS-Fingerprint stimmt nicht (erwartet ${want || "?"}, erhalten ${got || "?"})`));
|
||||
return;
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { encodeConnectionCode } from "../src/code.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BRIDGE = join(__dirname, "..", "src", "bridge.mjs");
|
||||
const TOKEN = "test-token-abc123";
|
||||
|
||||
const failures = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${name}\n`);
|
||||
} else {
|
||||
failures.push(name);
|
||||
process.stdout.write(` FAIL ${name}${detail ? " — " + detail : ""}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function startFakeServer() {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const auth = req.headers.authorization || "";
|
||||
const tokenOk = auth === `Bearer ${TOKEN}` || url.searchParams.get("token") === TOKEN;
|
||||
if (!tokenOk) {
|
||||
res.writeHead(401, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
return;
|
||||
}
|
||||
const p = url.pathname;
|
||||
const q = Object.fromEntries(url.searchParams.entries());
|
||||
const send = (obj) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
if (p === "/health") return send({ status: "ok", appVersion: "1.7.222", uptime: 42 });
|
||||
if (p === "/diagnostics") return send({ meta: { appVersion: "1.7.222" }, status: { active: 1 }, query: q });
|
||||
if (p === "/errors") return send({ errors: [{ level: "ERROR", message: "boom" }], query: q });
|
||||
if (p === "/logs/main") return send({ lines: ["line1", "line2"], count: 2, query: q });
|
||||
if (p === "/status") return send({ active: 1, queued: 3 });
|
||||
if (p === "/items") return send({ items: [], query: q });
|
||||
if (p === "/accounts") return send({ accounts: [{ name: "acc1", premium: true }] });
|
||||
if (p === "/meta") return send({ appVersion: "1.7.222", endpoints: ["/health", "/diagnostics"] });
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found", path: p }));
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function startBridge() {
|
||||
const child = spawn(process.execPath, [BRIDGE], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
child.stderr.on("data", (d) => process.stderr.write(`[bridge] ${d}`));
|
||||
const pending = new Map();
|
||||
let buf = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buf += chunk.toString("utf8");
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
let nextId = 1;
|
||||
function rpc(method, params) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, resolve);
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`RPC timeout: ${method}`));
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
||||
}
|
||||
return { child, rpc, notify };
|
||||
}
|
||||
|
||||
function textOf(callResult) {
|
||||
const c = callResult && callResult.result && callResult.result.content;
|
||||
if (!Array.isArray(c)) return "";
|
||||
return c.map((x) => x.text || "").join("\n");
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const fake = await startFakeServer();
|
||||
const port = fake.address().port;
|
||||
const code = encodeConnectionCode({ host: "127.0.0.1", port, token: TOKEN, name: "testserver" });
|
||||
const badCode = encodeConnectionCode({ host: "127.0.0.1", port, token: "WRONG", name: "testserver" });
|
||||
|
||||
const bridge = startBridge();
|
||||
try {
|
||||
const init = await bridge.rpc("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "harness", version: "1.0.0" }
|
||||
});
|
||||
check("initialize handshake", !!(init.result && init.result.serverInfo), JSON.stringify(init.error || {}));
|
||||
check("server name reported", init.result && init.result.serverInfo && init.result.serverInfo.name === "rd-diagnostics-mcp");
|
||||
bridge.notify("notifications/initialized", {});
|
||||
|
||||
const tools = await bridge.rpc("tools/list", {});
|
||||
const names = (tools.result && tools.result.tools || []).map((t) => t.name);
|
||||
check("tools/list returns tools", names.length >= 10, `got ${names.length}`);
|
||||
for (const expected of ["rd_ping", "rd_diagnostics", "rd_errors", "rd_logs", "rd_get", "rd_servers"]) {
|
||||
check(`tool present: ${expected}`, names.includes(expected));
|
||||
}
|
||||
|
||||
const ping = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code } });
|
||||
const pingText = textOf(ping);
|
||||
check("rd_ping reaches server", /HTTP 200/.test(pingText) && /"status": "ok"/.test(pingText), pingText.slice(0, 200));
|
||||
check("rd_ping shows server label", /testserver \(127\.0\.0\.1:/.test(pingText));
|
||||
|
||||
const diag = await bridge.rpc("tools/call", { name: "rd_diagnostics", arguments: { code, lines: 50, grep: "err" } });
|
||||
const diagText = textOf(diag);
|
||||
check("rd_diagnostics returns aggregate", /"appVersion": "1\.7\.222"/.test(diagText));
|
||||
check("rd_diagnostics passes query params", /"lines": "50"/.test(diagText) && /"grep": "err"/.test(diagText), diagText.slice(0, 300));
|
||||
|
||||
const logs = await bridge.rpc("tools/call", { name: "rd_logs", arguments: { code, name: "main", lines: 5 } });
|
||||
const logsText = textOf(logs);
|
||||
check("rd_logs maps name→path + lines", /logs\/main\?lines=5/.test(logsText) && /"count": 2/.test(logsText), logsText.slice(0, 200));
|
||||
|
||||
const errs = await bridge.rpc("tools/call", { name: "rd_errors", arguments: { code, level: "ERROR" } });
|
||||
check("rd_errors returns ring", /"message": "boom"/.test(textOf(errs)));
|
||||
|
||||
const raw = await bridge.rpc("tools/call", { name: "rd_get", arguments: { code, path: "/meta" } });
|
||||
check("rd_get escape hatch hits arbitrary path", /"endpoints"/.test(textOf(raw)));
|
||||
|
||||
const unauthorized = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code: badCode } });
|
||||
check("bad token → HTTP 401 + isError", /HTTP 401/.test(textOf(unauthorized)) && unauthorized.result.isError === true);
|
||||
|
||||
const noCode = await bridge.rpc("tools/call", { name: "rd_ping", arguments: {} });
|
||||
check("missing code → graceful isError", noCode.result && noCode.result.isError === true && /Kein Verbindungscode/.test(textOf(noCode)));
|
||||
|
||||
const unreachable = await bridge.rpc("tools/call", {
|
||||
name: "rd_ping",
|
||||
arguments: { code: encodeConnectionCode({ host: "127.0.0.1", port: 1, token: TOKEN }) }
|
||||
});
|
||||
check("unreachable → isError + hint", unreachable.result.isError === true && /nicht erreichbar|abgebrochen|FEHLER/.test(textOf(unreachable)), textOf(unreachable).slice(0, 160));
|
||||
} finally {
|
||||
bridge.child.kill();
|
||||
fake.close();
|
||||
}
|
||||
|
||||
process.stdout.write("\n");
|
||||
if (failures.length) {
|
||||
process.stdout.write(`RESULT: ${failures.length} FAIL\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("RESULT: ALL PASS\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
process.stderr.write(`harness error: ${err && err.stack ? err.stack : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user