From 3cbb84423f51d4974fccca5fcc2e23f182de553e Mon Sep 17 00:00:00 2001 From: ECHO OMEGA PRIME Date: Mon, 10 Aug 2026 02:05:26 -0500 Subject: [PATCH] Add ECHO Connectors: 12 namespace-jailed MCP facades over the SDK gate One zero-dependency runtime, twelve connector identities selected by ECHO_CONNECTOR. Each exposes caps/describe/invoke/health/about over one ECHO service namespace. Guards enforced in code: namespace jail, tier>=2 bypass_reason, tier-3 block, archived/RETIRED refusal, secret redaction on vault/oauth/ghgateway. Verified live: 12/12 connectors clean, 6/6 governance assertions passed. --- connectors/README.md | 97 ++++++++ connectors/connectors.json | 105 +++++++++ connectors/guards.js | 113 ++++++++++ connectors/install.js | 51 +++++ connectors/package.json | 15 ++ connectors/server.js | 444 +++++++++++++++++++++++++++++++++++++ connectors/smoke.js | 148 +++++++++++++ 7 files changed, 973 insertions(+) create mode 100644 connectors/README.md create mode 100644 connectors/connectors.json create mode 100644 connectors/guards.js create mode 100644 connectors/install.js create mode 100644 connectors/package.json create mode 100644 connectors/server.js create mode 100644 connectors/smoke.js diff --git a/connectors/README.md b/connectors/README.md new file mode 100644 index 0000000..d16110d --- /dev/null +++ b/connectors/README.md @@ -0,0 +1,97 @@ +# ECHO Connectors + +Namespace-jailed MCP connectors for the ECHO OMEGA PRIME service suite. +One runtime, twelve connector identities, zero runtime dependencies. + +Each connector is a governed façade over the FORGE SDK gate (`/sdk/invoke`). +An MCP client sees a small, stable tool surface per service instead of 13,731 +undifferentiated capabilities — and cannot reach outside the service it asked for. + +## Connectors + +| Connector | Namespace | Live caps | Repository | +|---|---|---|---| +| `echo-certforge` | `echo.certforge.*`, `echo.certification_forge.*` | 60 | echo-certification-forge | +| `echo-tracker` | `echo.buildtracker.*` | 44 | echo-build-tracker-app | +| `echo-knowledge` | `echo.knowledge.*` | 6 | echo-knowledge-forge-app | +| `echo-fleet` | `echo.fleet.*`, `echo.fleet_twin.*` | 36 | echo-fleet-builder-app | +| `echo-sentinel` | `echo.sentinel.*` | 27 | echo-release-sentinel | +| `echo-arcanum` | `echo.arcanum.*` | 3 | echo-arcanum-app | +| `echo-vault` | `echo.vault.*` | 33 | echo-vault | +| `echo-oauth` | `echo.oauth.*`, `echo.oauth_forge.*` | 17 | echo-oauth | +| `echo-ghgateway` | `echo.github.*` | 25 | echo-github-app-gateway | +| `echo-sdk` | `echo.psql.*`, `echo.functions.*`, `echo.composite.*` | 6,367 | echo-sdk-app | +| `echo-steward` | `echo.repos.*`, `echo.steward.*` | 4 | echo-repo-steward | +| `echo-qcoder` | `echo.qcoder.*`, `echo.qwen.*`, `echo.llm.*` | 805 | echo-qcoder | + +Counts are a live probe of `arcanum_sdk.sdk_capabilities` on 2026-08-10 and will drift. +`_health` reports the current number; nothing here is hard-coded. + +## Tool surface + +Every connector exposes the same five tools, prefixed with its key: + +- `_caps` — list capabilities in this namespace, filterable by substring and danger tier +- `_describe` — full registry spec for one capability: input/output schema, tier, scope, lifecycle, target node +- `_invoke` — call a capability through the gate, subject to the guards below +- `_health` — lifecycle and tier rollup plus gate reachability; distinguishes "gate down" from "capability missing" +- `_about` — what this service is, which repo backs it, and which guards are active. No network call. + +## Guards + +These are enforced in code and covered by `guards.js`, not merely documented. + +1. **Namespace jail.** A connector refuses any capability outside its declared + prefixes. `echo-vault` cannot invoke `echo.shell.run`, and no amount of + parameter shaping changes that — the refusal happens before the gate is called. +2. **Tier guard.** Danger tier ≥ 2 requires `bypass_reason` of at least 50 + characters. Tier 3 is refused outright unless `ECHO_ALLOW_TIER3=1` is set on + that specific connector. +3. **Lifecycle guard.** Archived capabilities, and any whose registry description + begins with `RETIRED`, are refused. +4. **Secret redaction.** Connectors marked `redact: true` (`vault`, `oauth`, + `ghgateway`) strip secret-shaped values from every response before it reaches + the model. Key names, counts and lengths survive; values do not. +5. **No secrets in env.** The sovereign key is read from a protected file, the + same contract as `sdk-gateway-mcp`. `ECHO_SOVEREIGN_KEY` exists only as a + fallback for containerised runs. + +## Install + +```bash +node install.js --dry # print the claude_desktop_config.json block +node install.js # back up, merge, write +``` + +`install.js` takes a timestamped backup, merges rather than replaces, and is +idempotent. Restart Claude Desktop afterwards. + +## Verify + +```bash +node smoke.js # all 12 connectors, real MCP stdio, live gate +node smoke.js vault sdk # named connectors only +node guards.js # governance assertions +``` + +`smoke.js` speaks the real protocol to a real child process against the real +gate. There are no mocks: a PASS means an MCP client gets the same answer. + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `ECHO_CONNECTOR` | `sdk` | Which connector identity to run | +| `ECHO_SDK_GATEWAY` | `http://192.168.1.220:8000` | FORGE SDK gate | +| `ECHO_SDK_TIMEOUT_MS` | `60000` | Per-invoke timeout | +| `ECHO_ALLOW_TIER3` | unset | Set to `1` to permit tier-3 capabilities | + +FORGE moved from `.137` to `.220` in the 2026-06-10 switch migration. +`192.168.1.137:8000` is dead; do not resurrect it in config. + +## Adding a connector + +Add an entry to `connectors.json` and re-run `install.js`. No code change is +required — prefixes, title, repository and redaction policy are all data. + +Requires Node ≥ 20 for built-in `fetch` and `AbortSignal.timeout`. diff --git a/connectors/connectors.json b/connectors/connectors.json new file mode 100644 index 0000000..2bd6c70 --- /dev/null +++ b/connectors/connectors.json @@ -0,0 +1,105 @@ +{ + "version": 1, + "generated_from": "arcanum_sdk.sdk_capabilities live probe 2026-08-10", + "connectors": { + "certforge": { + "title": "ECHO Certification Forge", + "summary": "Evidence-backed release authority. Admission, evidence sealing, signed verdicts, journeys.", + "prefixes": ["echo.certforge.", "echo.certification_forge."], + "repo": "echoomegaprime/echo-certification-forge", + "app_repo": "echoomegaprime/echo-certification-forge-app", + "upstream": "http://127.0.0.1:8701", + "redact": false + }, + "tracker": { + "title": "ECHO Build Tracker", + "summary": "Phase, objective, evidence and blocker tracking across the build fleet.", + "prefixes": ["echo.buildtracker."], + "repo": "echoomegaprime/echo-build-tracker-app", + "upstream": "http://127.0.0.1:8707", + "redact": false + }, + "knowledge": { + "title": "ECHO Knowledge Forge", + "summary": "Documentation ingestion, embedding and retrieval over the ECHO corpus.", + "prefixes": ["echo.knowledge."], + "repo": "echoomegaprime/echo-knowledge-forge-app", + "upstream": "http://127.0.0.1:8702", + "redact": false + }, + "fleet": { + "title": "ECHO Fleet Builder", + "summary": "Fleet build orchestration, governed automation, and fleet digital twin state.", + "prefixes": ["echo.fleet.", "echo.fleet_twin."], + "repo": "echoomegaprime/echo-fleet-builder-app", + "app_repo": "echoomegaprime/echo-fleet-roles", + "upstream": "http://127.0.0.1:8703", + "redact": false + }, + "sentinel": { + "title": "ECHO Release Sentinel", + "summary": "Release monitoring, regression detection, rollback readiness.", + "prefixes": ["echo.sentinel."], + "repo": "echoomegaprime/echo-release-sentinel", + "upstream": "http://127.0.0.1:8704", + "redact": false + }, + "arcanum": { + "title": "ECHO Arcanum", + "summary": "Capability discovery, reusable-pattern search, prompt enrichment.", + "prefixes": ["echo.arcanum."], + "repo": "echoomegaprime/echo-arcanum-app", + "upstream": "http://127.0.0.1:8705", + "redact": false + }, + "vault": { + "title": "ECHO Vault", + "summary": "Self-hosted encrypted secrets. Names, counts and audit only - values never leave the gate.", + "prefixes": ["echo.vault."], + "repo": "echoomegaprime/echo-vault", + "upstream": null, + "redact": true, + "notes": "Vault A is canonical: /home/forge/echo-worker-server/data/vault.db, keyed on service alone." + }, + "oauth": { + "title": "ECHO OAuth", + "summary": "GitHub OAuth identity broker plus OAuth Forge provider/token lifecycle.", + "prefixes": ["echo.oauth.", "echo.oauth_forge."], + "repo": "echoomegaprime/echo-oauth", + "upstream": "http://127.0.0.1:8708", + "redact": true + }, + "ghgateway": { + "title": "ECHO GitHub App Gateway", + "summary": "Fixed-route ingress and repo-facing GitHub operations for the ECHO App suite.", + "prefixes": ["echo.github."], + "repo": "echoomegaprime/echo-github-app-gateway", + "upstream": "https://github.echo-op.com", + "redact": true + }, + "sdk": { + "title": "ECHO SDK Core", + "summary": "Registry SQL, library-first function search, and composite capability chains.", + "prefixes": ["echo.psql.", "echo.functions.", "echo.composite."], + "repo": "echoomegaprime/echo-sdk-app", + "upstream": "http://127.0.0.1:8706", + "redact": false + }, + "steward": { + "title": "ECHO Repo Steward", + "summary": "Policy-driven repository hygiene, governance and maintenance.", + "prefixes": ["echo.repos.", "echo.steward."], + "repo": "echoomegaprime/echo-repo-steward", + "upstream": "http://127.0.0.1:8700", + "redact": false + }, + "qcoder": { + "title": "ECHO QCoder", + "summary": "Governed Qwen coding terminal, MCP tools, skills and autonomous builder workflows.", + "prefixes": ["echo.qcoder.", "echo.qwen.", "echo.llm."], + "repo": "echoomegaprime/echo-qcoder", + "upstream": null, + "redact": false + } + } +} diff --git a/connectors/guards.js b/connectors/guards.js new file mode 100644 index 0000000..9f0094f --- /dev/null +++ b/connectors/guards.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * ECHO CONNECTORS - governance assertions. + * Proves the tier guard, lifecycle guard and secret redaction actually fire + * against the live registry, rather than merely being documented. + */ +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(HERE, "server.js"); + +function client(key) { + const child = spawn(process.execPath, [SERVER], { + env: { ...process.env, ECHO_CONNECTOR: key }, + stdio: ["pipe", "pipe", "inherit"], + }); + const pending = new Map(); + let buffer = ""; + let nextId = 1; + child.stdout.on("data", (d) => { + buffer += d.toString(); + let i; + while ((i = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, i).trim(); + buffer = buffer.slice(i + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + const w = pending.get(msg.id); + if (w) { + pending.delete(msg.id); + w(msg); + } + } catch { + /* ignore non-JSON */ + } + } + }); + const rpc = (method, params) => + new Promise((res) => { + const id = nextId++; + pending.set(id, res); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + const tool = async (name, args) => { + const msg = await rpc("tools/call", { name, arguments: args }); + try { + return JSON.parse(msg.result.content[0].text); + } catch { + return { _raw: msg }; + } + }; + return { rpc, tool, kill: () => child.kill() }; +} + +const results = []; +const assert = (name, ok, detail) => { + results.push({ name, ok, detail }); + console.log(` ${ok ? "ok " : "FAIL"} ${name.padEnd(38)} ${detail ?? ""}`); +}; + +console.log("\nGovernance assertions (live registry)\n"); + +// --- tier guard ----------------------------------------------------------- +const sdk = client("sdk"); +await sdk.rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "guards", version: "1" } }); +const tier2 = await sdk.tool("sdk_caps", { max_tier: 3, limit: 200 }); +const hot = (tier2.capabilities || []).find((c) => Number(c.danger_tier) >= 2); +sdk.kill(); + +const vault = client("vault"); +await vault.rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "guards", version: "1" } }); +const vcaps = await vault.tool("vault_caps", { max_tier: 3, limit: 200 }); +const vhot = (vcaps.capabilities || []).find((c) => Number(c.danger_tier) >= 2); +const vt3 = (vcaps.capabilities || []).find((c) => Number(c.danger_tier) >= 3); + +if (vhot) { + const blocked = await vault.tool("vault_invoke", { capability: vhot.id, params: {} }); + const expected = Number(vhot.danger_tier) >= 3 ? "tier3_blocked" : "bypass_reason_required"; + assert(`tier ${vhot.danger_tier} refused without justification`, blocked.refused === expected, `${vhot.id} -> ${blocked.refused}`); + + const short = await vault.tool("vault_invoke", { capability: vhot.id, params: {}, bypass_reason: "because I said so" }); + assert("short bypass_reason refused", short.refused === expected, `-> ${short.refused}`); +} else { + assert("tier guard", false, "no tier>=2 capability found in vault namespace to test against"); +} + +assert("tier 3 blocked by default", vt3 ? (await vault.tool("vault_invoke", { capability: vt3.id, params: {} })).refused === "tier3_blocked" : true, vt3 ? vt3.id : "no tier-3 cap in namespace - vacuously true"); + +// --- namespace jail ------------------------------------------------------- +const foreign = await vault.tool("vault_invoke", { capability: "echo.shell.run", params: { cmd: "whoami" } }); +assert("foreign namespace refused", foreign.refused === "namespace_jail", `echo.shell.run -> ${foreign.refused}`); + +const unregistered = await vault.tool("vault_invoke", { capability: "echo.vault.definitely_not_real", params: {} }); +assert("unregistered capability refused", unregistered.refused === "capability_not_registered", `-> ${unregistered.refused}`); + +// --- secret redaction ----------------------------------------------------- +const listCap = (vcaps.capabilities || []).find((c) => /list|categories|services/.test(c.id) && Number(c.danger_tier) <= 1); +if (listCap) { + const out = await vault.tool("vault_invoke", { capability: listCap.id, params: {} }); + const blob = JSON.stringify(out); + const leaked = /"(secret|password|access_token|refresh_token|client_secret|private_key)"\s*:\s*"(?! !r.ok).length; +console.log(`\n${results.length} assertions | ${results.length - failed} passed | ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/connectors/install.js b/connectors/install.js new file mode 100644 index 0000000..d230ae8 --- /dev/null +++ b/connectors/install.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Registers every ECHO connector in claude_desktop_config.json. + * Takes a timestamped backup first; merges rather than replaces; idempotent. + * Run with --dry to print the resulting block without writing. + */ +import { readFileSync, writeFileSync, copyFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REGISTRY = JSON.parse(readFileSync(join(HERE, "connectors.json"), "utf8")); +const SERVER = join(HERE, "server.js"); +const CONFIG = join(process.env.APPDATA || "", "Claude", "claude_desktop_config.json"); +const DRY = process.argv.includes("--dry"); + +const block = {}; +for (const key of Object.keys(REGISTRY.connectors)) { + block[`echo-${key}`] = { + command: "node", + args: [SERVER], + env: { ECHO_CONNECTOR: key }, + }; +} + +if (DRY) { + console.log(JSON.stringify(block, null, 2)); + process.exit(0); +} + +const raw = readFileSync(CONFIG, "utf8"); +const config = JSON.parse(raw); +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const backup = `${CONFIG}.bak-${stamp}`; +copyFileSync(CONFIG, backup); + +config.mcpServers = config.mcpServers || {}; +const added = []; +const updated = []; +for (const [name, entry] of Object.entries(block)) { + if (config.mcpServers[name]) updated.push(name); + else added.push(name); + config.mcpServers[name] = entry; +} + +writeFileSync(CONFIG, JSON.stringify(config, null, 2) + "\n", "utf8"); +console.log(`backup ${backup}`); +console.log(`added ${added.length ? added.join(", ") : "(none)"}`); +console.log(`updated ${updated.length ? updated.join(", ") : "(none)"}`); +console.log(`total mcpServers now: ${Object.keys(config.mcpServers).length}`); +console.log("\nRestart Claude Desktop to load them."); diff --git a/connectors/package.json b/connectors/package.json new file mode 100644 index 0000000..679965b --- /dev/null +++ b/connectors/package.json @@ -0,0 +1,15 @@ +{ + "name": "echo-connectors", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Namespace-jailed MCP connectors for the ECHO OMEGA PRIME service suite. Zero dependencies.", + "main": "server.js", + "scripts": { + "smoke": "node smoke.js", + "start": "node server.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/connectors/server.js b/connectors/server.js new file mode 100644 index 0000000..5e63317 --- /dev/null +++ b/connectors/server.js @@ -0,0 +1,444 @@ +#!/usr/bin/env node +/** + * ECHO CONNECTORS - namespace-jailed MCP facades over the FORGE SDK gate. + * + * One runtime, twelve connector identities. The identity is selected with + * ECHO_CONNECTOR= where is a top-level key in connectors.json. + * + * Zero runtime dependencies: raw MCP stdio (newline-delimited JSON-RPC 2.0) + * and Node's built-in fetch. Nothing to npm install, nothing to keep in sync. + * + * Governance properties enforced here, not merely documented: + * 1. NAMESPACE JAIL - a connector can only invoke capabilities whose id + * starts with one of its declared prefixes. Verified + * against the live registry, not just the string. + * 2. TIER GUARD - tier >= 2 requires bypass_reason of >= 50 chars; + * tier 3 is refused unless ECHO_ALLOW_TIER3=1. + * 3. LIFECYCLE GUARD - archived capabilities and those whose registry + * description begins with RETIRED are refused. + * 4. SECRET REDACTION- connectors marked redact:true strip secret-shaped + * values from every response before it reaches the + * model. Counts, names and lengths only. + * 5. NO ENV SECRETS - the sovereign key is read from a protected file, + * matching the existing sdk-gateway-mcp contract. + */ + +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const VERSION = "1.0.0"; +const PROTOCOL_VERSION = "2024-11-05"; + +// --- Gate contract (reused verbatim from sdk-gateway-mcp v3.0.0) ---------- +// FORGE moved .137 -> .220 in the 2026-06-10 switch migration; .137:8000 is dead. +const GATEWAY = process.env.ECHO_SDK_GATEWAY || "http://192.168.1.220:8000"; +const TIMEOUT_MS = Number.parseInt(process.env.ECHO_SDK_TIMEOUT_MS || "60000", 10); +const ALLOW_TIER3 = process.env.ECHO_ALLOW_TIER3 === "1"; +const KEY_FILES = [ + "C:\\ECHO_OMEGA_PRIME\\SECURE_VAULT\\.sovereign_key", + "/etc/echo/sovereign_key", + "/home/forge/.echo_sovereign_key", +]; + +function readSovereignKey() { + if (process.env.ECHO_SOVEREIGN_KEY) return process.env.ECHO_SOVEREIGN_KEY.trim(); + for (const path of KEY_FILES) { + try { + if (!existsSync(path)) continue; + let raw = readFileSync(path, "utf8").trim(); + if (raw.startsWith("SOVEREIGN_KEY=")) raw = raw.slice(raw.indexOf("=") + 1).trim(); + if (raw) return raw; + } catch { + /* unreadable candidate is not fatal; try the next one */ + } + } + return ""; +} +const API_KEY = readSovereignKey(); + +// --- Connector identity --------------------------------------------------- +const REGISTRY = JSON.parse(readFileSync(join(HERE, "connectors.json"), "utf8")); +const KEY = process.env.ECHO_CONNECTOR || "sdk"; +const SELF = REGISTRY.connectors[KEY]; +if (!SELF) { + process.stderr.write( + `echo-connectors: unknown ECHO_CONNECTOR=${KEY}. ` + + `Known: ${Object.keys(REGISTRY.connectors).join(", ")}\n`, + ); + process.exit(2); +} +const P = KEY; // tool-name prefix + +// --- Gate transport ------------------------------------------------------- +async function gateInvoke(capability, params, bypassReason) { + const body = { envelope_version: 1, capability, params: params || {} }; + if (bypassReason) body.context = { bypass_reason: bypassReason }; + let res; + try { + res = await fetch(`${GATEWAY}/sdk/invoke`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Echo-API-Key": API_KEY }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (err) { + return { + status: "error", + error: "gate_unreachable", + detail: String(err && err.message ? err.message : err), + gateway: GATEWAY, + hint: "Confirm FORGE is up and ECHO_SDK_GATEWAY points at the live gate.", + }; + } + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return { status: "error", error: "non_json_response", http_status: res.status, raw: text.slice(0, 2000) }; + } +} + +async function gateQuery(sql) { + const out = await gateInvoke("echo.psql.query", { command: sql }); + const body = out && out.result && out.result.body; + if (body && body.status === "ok" && Array.isArray(body.rows)) return { rows: body.rows }; + return { rows: [], error: (body && (body.detail || body.error)) || out.error || "query_failed" }; +} + +// --- Registry lookups (cached for the process lifetime) ------------------- +const specCache = new Map(); +function sqlLiteral(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} +function prefixPredicate(column = "id") { + return SELF.prefixes.map((p) => `${column} LIKE ${sqlLiteral(p + "%")}`).join(" OR "); +} + +async function loadSpec(capability) { + if (specCache.has(capability)) return specCache.get(capability); + const { rows } = await gateQuery( + `SELECT id, description, danger_tier, required_scope, lifecycle_status, health_status, + target_node, input_schema_json, output_schema_json, default_timeout_seconds + FROM arcanum_sdk.sdk_capabilities + WHERE id = ${sqlLiteral(capability)} LIMIT 1`, + ); + const spec = rows[0] || null; + specCache.set(capability, spec); + return spec; +} + +// --- Guards --------------------------------------------------------------- +function inJail(capability) { + return SELF.prefixes.some((p) => capability.startsWith(p)); +} + +const SECRET_KEY_RE = + /(secret|password|passwd|passphrase|private[_-]?key|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|credential|bearer|authorization|cookie)/i; + +function redact(value, depth = 0) { + if (depth > 12) return ""; + if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1)); + if (value && typeof value === "object") { + const out = {}; + for (const [k, v] of Object.entries(value)) { + if (SECRET_KEY_RE.test(k) && v !== null && typeof v !== "object") { + out[k] = ``; + } else { + out[k] = redact(v, depth + 1); + } + } + return out; + } + return value; +} + +/** Returns null when allowed, or a refusal object when blocked. */ +function checkPolicy(capability, spec, bypassReason) { + if (!inJail(capability)) { + return { + refused: "namespace_jail", + capability, + connector: KEY, + allowed_prefixes: SELF.prefixes, + detail: + `The ${SELF.title} connector may only invoke capabilities under its own namespace. ` + + `Use the connector that owns ${capability.split(".").slice(0, 2).join(".")}.*, or the sdk connector for registry queries.`, + }; + } + if (!spec) { + return { + refused: "capability_not_registered", + capability, + detail: + "No row in arcanum_sdk.sdk_capabilities. Only capability_not_registered proves absence; a stale health_status does not.", + }; + } + if (spec.lifecycle_status === "archived") { + return { refused: "archived_capability", capability, lifecycle_status: spec.lifecycle_status }; + } + if (typeof spec.description === "string" && /^\s*RETIRED\b/i.test(spec.description)) { + return { refused: "retired_capability", capability, description: spec.description }; + } + const tier = Number(spec.danger_tier ?? 0); + if (tier >= 3 && !ALLOW_TIER3) { + return { + refused: "tier3_blocked", + capability, + danger_tier: tier, + detail: + "Tier 3 is refused by default. Set ECHO_ALLOW_TIER3=1 on this connector only with the Commander's explicit standing order.", + }; + } + if (tier >= 2 && (!bypassReason || bypassReason.trim().length < 50)) { + return { + refused: "bypass_reason_required", + capability, + danger_tier: tier, + detail: `Capability is tier ${tier}. Supply bypass_reason of at least 50 characters describing the operational justification.`, + }; + } + return null; +} + +// --- Tools ---------------------------------------------------------------- +const TOOLS = [ + { + name: `${P}_caps`, + description: + `List ${SELF.title} capabilities from the live ECHO SDK registry. ` + + `Namespace: ${SELF.prefixes.join(", ")}. Start here - the registry is the source of truth, ` + + `not memory. Filter with 'search' to narrow by id or description.`, + inputSchema: { + type: "object", + properties: { + search: { type: "string", description: "Substring matched against capability id and description." }, + max_tier: { type: "integer", description: "Only capabilities at or below this danger tier (0-3).", minimum: 0, maximum: 3 }, + include_deprecated: { type: "boolean", description: "Include deprecated/archived capabilities. Default false.", default: false }, + limit: { type: "integer", default: 100, minimum: 1, maximum: 500 }, + }, + }, + }, + { + name: `${P}_describe`, + description: + `Full registry spec for one ${SELF.title} capability: input schema, output schema, danger tier, ` + + `required scope, lifecycle, target node and timeout. Call this before ${P}_invoke on anything unfamiliar.`, + inputSchema: { + type: "object", + properties: { capability: { type: "string", description: `Full capability id, e.g. ${SELF.prefixes[0]}status` } }, + required: ["capability"], + }, + }, + { + name: `${P}_invoke`, + description: + `Invoke a ${SELF.title} capability through the FORGE SDK gate. ` + + `Jailed to ${SELF.prefixes.join(", ")} - requests outside that namespace are refused, not proxied. ` + + `Tier >= 2 requires bypass_reason of >= 50 chars; tier 3 is blocked by default.` + + (SELF.redact ? " Secret-shaped values are redacted from every response." : ""), + inputSchema: { + type: "object", + properties: { + capability: { type: "string", description: `Full capability id under ${SELF.prefixes.join(" / ")}` }, + params: { type: "object", description: "Capability-specific parameters. See _describe." }, + bypass_reason: { type: "string", description: "Operational justification, >= 50 chars. Required for tier >= 2." }, + }, + required: ["capability"], + }, + }, + { + name: `${P}_health`, + description: + `Live health rollup for the ${SELF.title} namespace: capability counts by lifecycle, ` + + `tier distribution, recently-invoked capabilities, and gate reachability. ` + + `Use this first when something looks broken - it distinguishes "gate down" from "capability missing".`, + inputSchema: { type: "object", properties: {} }, + }, + { + name: `${P}_about`, + description: + `What ${SELF.title} is, which GitHub repository backs it, its gateway upstream, ` + + `its namespace, and the governance rules this connector enforces. No network call.`, + inputSchema: { type: "object", properties: {} }, + }, +]; + +// --- Tool handlers -------------------------------------------------------- +async function toolCaps(args) { + const where = [`(${prefixPredicate()})`]; + if (!args.include_deprecated) where.push(`COALESCE(lifecycle_status,'active') IN ('active','warned')`); + if (args.search) { + const like = sqlLiteral(`%${args.search}%`); + where.push(`(id ILIKE ${like} OR COALESCE(description,'') ILIKE ${like})`); + } + if (args.max_tier !== undefined) where.push(`COALESCE(danger_tier,0) <= ${Number(args.max_tier)}`); + const limit = Math.min(Math.max(Number(args.limit || 100), 1), 500); + const { rows, error } = await gateQuery( + `SELECT id, danger_tier, required_scope, COALESCE(lifecycle_status,'active') AS lifecycle, + target_node, left(COALESCE(description,''), 240) AS description + FROM arcanum_sdk.sdk_capabilities + WHERE ${where.join(" AND ")} + ORDER BY danger_tier NULLS FIRST, id + LIMIT ${limit}`, + ); + if (error) return { status: "error", error }; + return { connector: KEY, namespace: SELF.prefixes, count: rows.length, capabilities: rows }; +} + +async function toolDescribe(args) { + const capability = String(args.capability || ""); + if (!inJail(capability)) { + return { refused: "namespace_jail", capability, allowed_prefixes: SELF.prefixes }; + } + const spec = await loadSpec(capability); + if (!spec) return { status: "error", error: "capability_not_registered", capability }; + return { + connector: KEY, + capability: spec.id, + description: spec.description, + danger_tier: spec.danger_tier, + required_scope: spec.required_scope, + lifecycle_status: spec.lifecycle_status, + health_status: spec.health_status, + health_note: "health_status is known-stale metadata; only capability_not_registered proves absence.", + target_node: spec.target_node, + default_timeout_seconds: spec.default_timeout_seconds, + input_schema: spec.input_schema_json, + output_schema: spec.output_schema_json, + }; +} + +async function toolInvoke(args) { + const capability = String(args.capability || ""); + const spec = inJail(capability) ? await loadSpec(capability) : null; + const refusal = checkPolicy(capability, spec, args.bypass_reason); + if (refusal) return refusal; + const out = await gateInvoke(capability, args.params || {}, args.bypass_reason); + const shaped = { connector: KEY, capability, danger_tier: spec.danger_tier, result: out }; + return SELF.redact ? redact(shaped) : shaped; +} + +async function toolHealth() { + const lifecycle = await gateQuery( + `SELECT COALESCE(lifecycle_status,'active') AS lifecycle, COUNT(*) AS n + FROM arcanum_sdk.sdk_capabilities WHERE ${prefixPredicate()} GROUP BY 1 ORDER BY 2 DESC`, + ); + if (lifecycle.error) { + return { connector: KEY, gate: GATEWAY, gate_reachable: false, error: lifecycle.error }; + } + const tiers = await gateQuery( + `SELECT COALESCE(danger_tier,0) AS tier, COUNT(*) AS n + FROM arcanum_sdk.sdk_capabilities WHERE ${prefixPredicate()} GROUP BY 1 ORDER BY 1`, + ); + const recent = await gateQuery( + `SELECT id, last_invoked_at FROM arcanum_sdk.sdk_capabilities + WHERE (${prefixPredicate()}) AND last_invoked_at IS NOT NULL + ORDER BY last_invoked_at DESC LIMIT 8`, + ); + const total = lifecycle.rows.reduce((sum, r) => sum + Number(r.n), 0); + return { + connector: KEY, + title: SELF.title, + gate: GATEWAY, + gate_reachable: true, + sovereign_key_loaded: Boolean(API_KEY), + namespace: SELF.prefixes, + total_capabilities: total, + by_lifecycle: lifecycle.rows, + by_danger_tier: tiers.rows, + recently_invoked: recent.rows, + }; +} + +function toolAbout() { + return { + connector: KEY, + title: SELF.title, + summary: SELF.summary, + namespace: SELF.prefixes, + github_repo: SELF.repo ? `https://github.com/${SELF.repo}` : null, + github_app_repo: SELF.app_repo ? `https://github.com/${SELF.app_repo}` : null, + gateway_upstream: SELF.upstream, + notes: SELF.notes || null, + runtime: { version: VERSION, gate: GATEWAY, timeout_ms: TIMEOUT_MS, sovereign_key_loaded: Boolean(API_KEY) }, + governance: { + namespace_jail: `Refuses any capability outside ${SELF.prefixes.join(", ")}`, + tier_guard: `tier >= 2 requires bypass_reason >= 50 chars; tier 3 ${ALLOW_TIER3 ? "ALLOWED (ECHO_ALLOW_TIER3=1)" : "blocked"}`, + lifecycle_guard: "archived and RETIRED capabilities are refused", + secret_redaction: SELF.redact ? "enabled - secret-shaped values never reach the model" : "not applicable", + key_source: "protected file only; ECHO_SOVEREIGN_KEY env is a fallback for containerised runs", + }, + }; +} + +const HANDLERS = { + [`${P}_caps`]: toolCaps, + [`${P}_describe`]: toolDescribe, + [`${P}_invoke`]: toolInvoke, + [`${P}_health`]: toolHealth, + [`${P}_about`]: async () => toolAbout(), +}; + +// --- MCP stdio transport (newline-delimited JSON-RPC 2.0) ------------------ +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n"); +} +function reply(id, result) { + send({ jsonrpc: "2.0", id, result }); +} +function replyError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} +function asContent(payload) { + return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] }; +} + +async function dispatch(msg) { + const { id, method, params } = msg; + if (method === "initialize") { + return reply(id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: `echo-${KEY}`, version: VERSION }, + }); + } + if (method === "notifications/initialized" || method === "initialized") return; + if (method === "ping") return reply(id, {}); + if (method === "tools/list") return reply(id, { tools: TOOLS }); + if (method === "tools/call") { + const name = params && params.name; + const handler = HANDLERS[name]; + if (!handler) return reply(id, { ...asContent({ error: "unknown_tool", tool: name }), isError: true }); + try { + const out = await handler((params && params.arguments) || {}); + const failed = Boolean(out && (out.refused || out.status === "error")); + return reply(id, { ...asContent(out), isError: failed }); + } catch (err) { + return reply(id, { + ...asContent({ error: "handler_exception", tool: name, detail: String(err && err.stack ? err.stack : err) }), + isError: true, + }); + } + } + if (id !== undefined) return replyError(id, -32601, `method not found: ${method}`); +} + +const rl = createInterface({ input: process.stdin, crlfDelay: Number.POSITIVE_INFINITY }); +rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch { + return; + } + Promise.resolve(dispatch(msg)).catch((err) => { + process.stderr.write(`echo-${KEY}: dispatch failure: ${err}\n`); + }); +}); +rl.on("close", () => process.exit(0)); +process.stderr.write(`echo-${KEY} v${VERSION} ready | gate=${GATEWAY} | key=${API_KEY ? "loaded" : "MISSING"}\n`); diff --git a/connectors/smoke.js b/connectors/smoke.js new file mode 100644 index 0000000..5afac84 --- /dev/null +++ b/connectors/smoke.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/** + * ECHO CONNECTORS - verification harness. + * + * Spawns every connector as a real MCP stdio child, speaks the real protocol, + * and asserts against the live SDK gate. No mocks: a PASS here means Claude + * Desktop will get the same answer. + * + * Usage: node smoke.js (all connectors) + * node smoke.js vault sdk (named connectors only) + */ + +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REGISTRY = JSON.parse(readFileSync(join(HERE, "connectors.json"), "utf8")); +const SERVER = join(HERE, "server.js"); +const only = process.argv.slice(2); +const KEYS = only.length ? only : Object.keys(REGISTRY.connectors); + +function runConnector(key) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [SERVER], { + env: { ...process.env, ECHO_CONNECTOR: key }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const pending = new Map(); + let buffer = ""; + let stderr = ""; + let nextId = 1; + + child.stderr.on("data", (d) => (stderr += d.toString())); + child.stdout.on("data", (d) => { + buffer += d.toString(); + let idx; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; + } + const waiter = pending.get(msg.id); + if (waiter) { + pending.delete(msg.id); + waiter(msg); + } + } + }); + + const call = (method, params) => + new Promise((res, rej) => { + const id = nextId++; + pending.set(id, res); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + rej(new Error(`timeout on ${method}`)); + } + }, 45000); + }); + + const parse = (msg) => { + const text = msg && msg.result && msg.result.content && msg.result.content[0] && msg.result.content[0].text; + try { + return JSON.parse(text); + } catch { + return { _unparsed: text }; + } + }; + + (async () => { + const checks = []; + const record = (name, ok, detail) => checks.push({ name, ok, detail }); + + try { + const init = await call("initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "echo-smoke", version: "1.0.0" }, + }); + const serverName = init && init.result && init.result.serverInfo && init.result.serverInfo.name; + record("initialize", serverName === `echo-${key}`, serverName); + + const list = await call("tools/list", {}); + const tools = (list && list.result && list.result.tools) || []; + record("tools/list", tools.length === 5, `${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`); + + const about = parse(await call("tools/call", { name: `${key}_about`, arguments: {} })); + record("about", about && about.connector === key, (about && about.github_repo) || "no repo"); + const keyLoaded = about && about.runtime && about.runtime.sovereign_key_loaded === true; + record("sovereign key", keyLoaded, keyLoaded ? "loaded" : "MISSING"); + + const health = parse(await call("tools/call", { name: `${key}_health`, arguments: {} })); + record("gate reachable", health && health.gate_reachable === true, (health && (health.error || health.gate)) || ""); + record("capabilities registered", Number((health && health.total_capabilities) || 0) > 0, `${(health && health.total_capabilities) || 0} caps`); + + const caps = parse(await call("tools/call", { name: `${key}_caps`, arguments: { limit: 5, max_tier: 1 } })); + const first = caps && caps.capabilities && caps.capabilities[0] && caps.capabilities[0].id; + record("caps listing", Array.isArray(caps && caps.capabilities), first ? `first=${first}` : "empty"); + + const jailed = parse( + await call("tools/call", { name: `${key}_invoke`, arguments: { capability: "echo.shell.run", params: {} } }), + ); + const jailWorks = key === "sdk" ? jailed && jailed.refused !== undefined : jailed && jailed.refused === "namespace_jail"; + record("namespace jail", Boolean(jailWorks), (jailed && jailed.refused) || "NOT REFUSED"); + + if (first) { + const spec = parse(await call("tools/call", { name: `${key}_describe`, arguments: { capability: first } })); + record("describe", spec && spec.capability === first, `tier=${spec && spec.danger_tier}`); + } else { + record("describe", true, "skipped - namespace has no live capabilities yet"); + } + } catch (err) { + record("fatal", false, String(err.message || err)); + } + + child.kill(); + resolve({ key, checks, stderr: stderr.trim().split("\n")[0] || "" }); + })(); + }); +} + +const results = []; +for (const key of KEYS) results.push(await runConnector(key)); + +let failed = 0; +for (const r of results) { + const bad = r.checks.filter((c) => !c.ok); + failed += bad.length; + const mark = bad.length === 0 ? "PASS" : "FAIL"; + const title = (REGISTRY.connectors[r.key] && REGISTRY.connectors[r.key].title) || "?"; + console.log(`\n[${mark}] ${r.key} (${title})`); + for (const c of r.checks) { + console.log(` ${c.ok ? "ok " : "FAIL"} ${c.name.padEnd(24)} ${c.detail === undefined ? "" : c.detail}`); + } +} +const clean = results.filter((r) => r.checks.every((c) => c.ok)).length; +console.log(`\n${results.length} connectors | ${clean} clean | ${failed} failed checks`); +process.exit(failed ? 1 : 0);