From 9f1bb4415d9c3c466f739debadf88b983b4a5d1d Mon Sep 17 00:00:00 2001 From: AnubisQuantumCipher Date: Sat, 25 Jul 2026 07:00:13 -0400 Subject: [PATCH] feat: add aztec_network_status tool for local sandbox/node probes Implements issue #1: MCP tool-callable local network status distinct from aztec_status (cloned repos). Probes PXE/node/L1 with role-aware JSON-RPC fallthrough, ready|degraded|down overall status, latency, and agent-branchable error taxonomy. Includes vitest coverage. --- README.md | 12 ++ src/index.ts | 58 +++++- src/tools/index.ts | 5 + src/tools/network-status.ts | 364 +++++++++++++++++++++++++++++++++++ tests/network-status.test.ts | 108 +++++++++++ 5 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/tools/network-status.ts create mode 100644 tests/network-status.test.ts diff --git a/README.md b/README.md index 3661d4f..bfdbb7a 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,18 @@ aztec_sync_repos({ version: "v4.3.0" }) Check the status of cloned repositories. + +### `aztec_network_status` + +Probe a local Aztec sandbox / node / PXE / L1 RPC for reachability, latency, and a structured +ready|degraded|down taxonomy agents can branch on. Complements `aztec_status` (cloned repos only). + +| Argument | Required | Description | +|----------|----------|-------------| +| `urls` | No | RPC base URLs to probe (defaults: `:8080` PXE, `:8081` node, `:8545` L1) | +| `roles` | No | `pxe` / `node` / `l1` / `custom` aligned with `urls` | +| `timeoutMs` | No | Per-request timeout (default 3000) | + ### `aztec_search_code` Search Aztec contract code and source files. Supports regex patterns. diff --git a/src/index.ts b/src/index.ts index 6fbb473..8e0296f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ * aztec_lookup_error — Error diagnosis with semantic fallback * aztec_list_examples, aztec_read_example, aztec_read_file — Repo browsing * aztec_sync_repos, aztec_status — Repo management + * aztec_network_status — Local sandbox/node/PXE/L1 reachability */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; @@ -31,6 +32,8 @@ import { readAztecExample, readRepoFile, lookupAztecError, + checkNetworkStatus, + formatNetworkStatus, } from "./tools/index.js"; import { formatSyncResult, @@ -250,6 +253,41 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { properties: {}, }, }, + // Local network / sandbox status (distinct from aztec_status repo state) + { + name: "aztec_network_status", + description: + "Check whether a local Aztec sandbox / node / PXE / L1 RPC is reachable and usable. " + + "Returns structured JSON with overall ready|degraded|down, per-endpoint latency, " + + "and an agent-branchable error taxonomy. Complements aztec_status (which only " + + "reports cloned repository state). Defaults: PXE http://127.0.0.1:8080, node " + + "http://127.0.0.1:8081, L1 http://127.0.0.1:8545.", + inputSchema: { + type: "object" as const, + properties: { + urls: { + type: "array", + items: { type: "string" }, + description: + "Optional list of RPC base URLs to probe. When omitted, default sandbox endpoints are used.", + }, + roles: { + type: "array", + items: { + type: "string", + enum: ["pxe", "node", "l1", "custom"], + }, + description: + "Optional roles aligned with urls (same order). Inferred from port when omitted.", + }, + timeoutMs: { + type: "number", + description: + "Per-request timeout in milliseconds (default 3000, clamped 200–30000).", + }, + }, + }, + }, // Code search (ripgrep) { name: "aztec_search_code", @@ -392,6 +430,7 @@ function validateToolRequest( switch (name) { case "aztec_sync_repos": case "aztec_status": + case "aztec_network_status": case "aztec_list_examples": break; case "aztec_search_docs": @@ -526,7 +565,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { name === "aztec_search_docs" && docsgptClient != null && args?.useLocalFallback !== true; - if (name !== "aztec_sync_repos" && !semanticOnlyDocsSearch) { + // Network probes do not need local clones — skip auto-resync wait. + if ( + name !== "aztec_sync_repos" && + name !== "aztec_network_status" && + !semanticOnlyDocsSearch + ) { ensureAutoResync(); if (syncInFlight) await syncInFlight.catch(() => {}); } @@ -561,6 +605,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { break; } + case "aztec_network_status": { + const network = await checkNetworkStatus({ + urls: args?.urls as string[] | undefined, + roles: args?.roles as + | ("pxe" | "node" | "l1" | "custom")[] + | undefined, + timeoutMs: args?.timeoutMs as number | undefined, + }); + text = formatNetworkStatus(network); + break; + } + case "aztec_search_docs": { const docsResult = await searchAztecDocs( { diff --git a/src/tools/index.ts b/src/tools/index.ts index 8200c0c..6ea4581 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -13,3 +13,8 @@ export { type SemanticSearchToolResult, } from "./search.js"; export { lookupAztecError } from "./error-lookup.js"; +export { + checkNetworkStatus, + formatNetworkStatus, + type NetworkStatusResult, +} from "./network-status.js"; diff --git a/src/tools/network-status.ts b/src/tools/network-status.ts new file mode 100644 index 0000000..0fd36fa --- /dev/null +++ b/src/tools/network-status.ts @@ -0,0 +1,364 @@ +/** + * Local Aztec network / sandbox status probes for MCP agents. + * + * Complements `aztec_status` (which reports cloned repo state) by checking + * whether a local sandbox/node/PXE/L1 RPC is actually reachable and usable. + */ + +export type ProbeErrorCode = + | "unreachable" + | "timeout" + | "http_error" + | "invalid_json" + | "rpc_error" + | "method_not_found" + | "empty_response"; + +export type EndpointRole = "pxe" | "node" | "l1" | "custom"; + +export interface EndpointProbeResult { + role: EndpointRole; + url: string; + reachable: boolean; + latencyMs: number | null; + httpStatus: number | null; + errorCode: ProbeErrorCode | null; + errorMessage: string | null; + /** Best-effort identity / version fields from the target. */ + details: Record; +} + +export interface NetworkStatusResult { + overall: "ready" | "degraded" | "down"; + checkedAt: string; + timeoutMs: number; + endpoints: EndpointProbeResult[]; + /** Agent-branchable summary of what to do next. */ + taxonomy: { + ready: string[]; + degraded: string[]; + down: string[]; + }; + notes: string[]; +} + +export interface ProbeOptions { + /** Explicit endpoint URLs. When empty, defaults are used. */ + urls?: string[]; + /** Roles aligned with urls (same length). Defaults inferred from port. */ + roles?: EndpointRole[]; + timeoutMs?: number; + /** Inject for tests. */ + fetchImpl?: typeof fetch; +} + +/** Default local Aztec sandbox endpoints (common aztec.js / sandbox layout). */ +export const DEFAULT_ENDPOINTS: { role: EndpointRole; url: string }[] = [ + { role: "pxe", url: "http://127.0.0.1:8080" }, + { role: "node", url: "http://127.0.0.1:8081" }, + { role: "l1", url: "http://127.0.0.1:8545" }, +]; + +function inferRole(url: string): EndpointRole { + try { + const u = new URL(url); + if (u.port === "8080") return "pxe"; + if (u.port === "8081") return "node"; + if (u.port === "8545") return "l1"; + } catch { + /* ignore */ + } + return "custom"; +} + +async function jsonRpc( + fetchImpl: typeof fetch, + url: string, + method: string, + params: unknown[] = [], + timeoutMs: number +): Promise<{ + ok: boolean; + httpStatus: number | null; + latencyMs: number; + result?: unknown; + errorCode: ProbeErrorCode | null; + errorMessage: string | null; +}> { + const started = performance.now(); + try { + const res = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params, + }), + signal: AbortSignal.timeout(timeoutMs), + }); + const latencyMs = Math.round(performance.now() - started); + const httpStatus = res.status; + if (!res.ok) { + return { + ok: false, + httpStatus, + latencyMs, + errorCode: "http_error", + errorMessage: `HTTP ${res.status} ${res.statusText}`, + }; + } + let body: unknown; + try { + body = await res.json(); + } catch { + return { + ok: false, + httpStatus, + latencyMs, + errorCode: "invalid_json", + errorMessage: "Response was not valid JSON", + }; + } + if ( + body && + typeof body === "object" && + "error" in body && + (body as { error?: unknown }).error + ) { + const err = (body as { error: { code?: number; message?: string } }) + .error; + const msg = err?.message ?? JSON.stringify(err); + const code = + typeof err?.code === "number" && err.code === -32601 + ? "method_not_found" + : "rpc_error"; + return { + ok: false, + httpStatus, + latencyMs, + errorCode: code, + errorMessage: msg, + }; + } + if ( + body && + typeof body === "object" && + "result" in body + ) { + return { + ok: true, + httpStatus, + latencyMs, + result: (body as { result: unknown }).result, + errorCode: null, + errorMessage: null, + }; + } + return { + ok: false, + httpStatus, + latencyMs, + errorCode: "empty_response", + errorMessage: "JSON-RPC response missing result", + }; + } catch (e) { + const latencyMs = Math.round(performance.now() - started); + const message = e instanceof Error ? e.message : String(e); + const isTimeout = + (e instanceof Error && e.name === "TimeoutError") || + /timeout|aborted/i.test(message); + return { + ok: false, + httpStatus: null, + latencyMs, + errorCode: isTimeout ? "timeout" : "unreachable", + errorMessage: message, + }; + } +} + +/** + * Probe a single endpoint with role-appropriate methods. + * Falls through a small method list so mixed stacks still surface something useful. + */ +export async function probeEndpoint( + role: EndpointRole, + url: string, + timeoutMs: number, + fetchImpl: typeof fetch = fetch +): Promise { + const details: Record = {}; + + // Method candidates by role (first success wins for "reachable"). + const methods: { method: string; params?: unknown[]; label: string }[] = + role === "l1" + ? [ + { method: "eth_chainId", label: "chainId" }, + { method: "eth_blockNumber", label: "blockNumber" }, + { method: "web3_clientVersion", label: "clientVersion" }, + ] + : role === "pxe" + ? [ + // Aztec PXE / node JSON-RPC surfaces evolve; try several. + { method: "pxe_getNodeInfo", label: "pxe_getNodeInfo" }, + { method: "node_getNodeInfo", label: "node_getNodeInfo" }, + { method: "getNodeInfo", label: "getNodeInfo" }, + { method: "eth_chainId", label: "chainId" }, + ] + : [ + { method: "node_getNodeInfo", label: "node_getNodeInfo" }, + { method: "getNodeInfo", label: "getNodeInfo" }, + { method: "pxe_getNodeInfo", label: "pxe_getNodeInfo" }, + { method: "eth_blockNumber", label: "blockNumber" }, + { method: "eth_chainId", label: "chainId" }, + ]; + + let last: Awaited> | null = null; + for (const candidate of methods) { + const res = await jsonRpc( + fetchImpl, + url, + candidate.method, + candidate.params ?? [], + timeoutMs + ); + last = res; + if (res.ok) { + details[candidate.label] = res.result; + details.methodUsed = candidate.method; + return { + role, + url, + reachable: true, + latencyMs: res.latencyMs, + httpStatus: res.httpStatus, + errorCode: null, + errorMessage: null, + details, + }; + } + // method_not_found → try next; hard network errors → stop early + if ( + res.errorCode === "unreachable" || + res.errorCode === "timeout" || + res.errorCode === "http_error" + ) { + break; + } + details[`attempt_${candidate.method}`] = { + errorCode: res.errorCode, + errorMessage: res.errorMessage, + }; + } + + return { + role, + url, + reachable: false, + latencyMs: last?.latencyMs ?? null, + httpStatus: last?.httpStatus ?? null, + errorCode: last?.errorCode ?? "unreachable", + errorMessage: last?.errorMessage ?? "No probe methods succeeded", + details, + }; +} + +export async function checkNetworkStatus( + options: ProbeOptions = {} +): Promise { + const timeoutMs = Math.max(200, Math.min(options.timeoutMs ?? 3000, 30_000)); + const fetchImpl = options.fetchImpl ?? fetch; + + let endpoints: { role: EndpointRole; url: string }[]; + if (options.urls && options.urls.length > 0) { + endpoints = options.urls.map((url, i) => ({ + url, + role: options.roles?.[i] ?? inferRole(url), + })); + } else { + endpoints = DEFAULT_ENDPOINTS.map((e) => ({ ...e })); + } + + const results: EndpointProbeResult[] = []; + for (const ep of endpoints) { + results.push(await probeEndpoint(ep.role, ep.url, timeoutMs, fetchImpl)); + } + + const ready = results.filter((r) => r.reachable).map((r) => `${r.role}:${r.url}`); + const down = results + .filter((r) => !r.reachable) + .map((r) => `${r.role}:${r.url} (${r.errorCode})`); + + let overall: NetworkStatusResult["overall"]; + if (ready.length === results.length) overall = "ready"; + else if (ready.length === 0) overall = "down"; + else overall = "degraded"; + + const notes: string[] = [ + "aztec_network_status probes live RPC endpoints; aztec_status reports cloned repos only.", + "Defaults: PXE :8080, node :8081, L1 :8545 — override with urls[] for custom stacks.", + ]; + if (overall === "down") { + notes.push( + "Nothing reachable. Is the Aztec sandbox running? Try `aztec start --sandbox` (or your local compose stack)." + ); + } else if (overall === "degraded") { + notes.push( + "Partial reachability — agents should treat missing roles as unavailable and avoid calls that depend on them." + ); + } + + return { + overall, + checkedAt: new Date().toISOString(), + timeoutMs, + endpoints: results, + taxonomy: { ready, degraded: overall === "degraded" ? down : [], down: overall === "down" ? down : overall === "degraded" ? [] : down }, + notes, + }; +} + +export function formatNetworkStatus(result: NetworkStatusResult): string { + const lines = [ + `Aztec local network status: ${result.overall.toUpperCase()}`, + `Checked at: ${result.checkedAt}`, + `Timeout: ${result.timeoutMs}ms`, + "", + "Endpoints:", + ]; + for (const ep of result.endpoints) { + const icon = ep.reachable ? "✓" : "✗"; + const lat = ep.latencyMs != null ? `${ep.latencyMs}ms` : "n/a"; + if (ep.reachable) { + lines.push(` ${icon} [${ep.role}] ${ep.url} — reachable (${lat})`); + if (ep.details.methodUsed) { + lines.push(` method: ${ep.details.methodUsed}`); + } + for (const [k, v] of Object.entries(ep.details)) { + if (k === "methodUsed" || k.startsWith("attempt_")) continue; + const rendered = + typeof v === "string" || typeof v === "number" || typeof v === "boolean" + ? String(v) + : JSON.stringify(v); + lines.push(` ${k}: ${rendered}`); + } + } else { + lines.push( + ` ${icon} [${ep.role}] ${ep.url} — ${ep.errorCode ?? "error"} (${lat})` + ); + if (ep.errorMessage) lines.push(` ${ep.errorMessage}`); + } + } + lines.push(""); + lines.push("Agent taxonomy:"); + lines.push(` ready: ${result.taxonomy.ready.join(", ") || "(none)"}`); + lines.push(` down: ${result.taxonomy.down.join(", ") || result.taxonomy.degraded.join(", ") || "(none)"}`); + lines.push(""); + for (const n of result.notes) lines.push(`Note: ${n}`); + lines.push(""); + lines.push("JSON:"); + lines.push(JSON.stringify(result, null, 2)); + return lines.join("\n"); +} diff --git a/tests/network-status.test.ts b/tests/network-status.test.ts new file mode 100644 index 0000000..8526716 --- /dev/null +++ b/tests/network-status.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { + checkNetworkStatus, + formatNetworkStatus, + probeEndpoint, +} from "../src/tools/network-status.js"; + +function jsonRpcOk(result: unknown) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function jsonRpcErr(code: number, message: string) { + return new Response( + JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code, message } }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +describe("probeEndpoint", () => { + it("marks L1 reachable on eth_chainId success", async () => { + const fetchImpl = vi.fn(async () => jsonRpcOk("0x7a69")); + const res = await probeEndpoint( + "l1", + "http://127.0.0.1:8545", + 1000, + fetchImpl as unknown as typeof fetch + ); + expect(res.reachable).toBe(true); + expect(res.details.chainId).toBe("0x7a69"); + expect(res.errorCode).toBeNull(); + }); + + it("falls through method_not_found to a later method", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonRpcErr(-32601, "Method not found")) + .mockResolvedValueOnce(jsonRpcOk({ nodeVersion: "1.2.3" })); + const res = await probeEndpoint( + "pxe", + "http://127.0.0.1:8080", + 1000, + fetchImpl as unknown as typeof fetch + ); + expect(res.reachable).toBe(true); + expect(res.details.methodUsed).toBeTruthy(); + }); + + it("classifies network failure as unreachable", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("fetch failed"); + }); + const res = await probeEndpoint( + "node", + "http://127.0.0.1:8081", + 1000, + fetchImpl as unknown as typeof fetch + ); + expect(res.reachable).toBe(false); + expect(res.errorCode).toBe("unreachable"); + }); +}); + +describe("checkNetworkStatus", () => { + it("reports ready when all defaults succeed", async () => { + const fetchImpl = vi.fn(async () => jsonRpcOk("0x1")); + const status = await checkNetworkStatus({ + timeoutMs: 500, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(status.overall).toBe("ready"); + expect(status.endpoints).toHaveLength(3); + expect(status.taxonomy.ready).toHaveLength(3); + const text = formatNetworkStatus(status); + expect(text).toContain("READY"); + expect(text).toContain("JSON:"); + }); + + it("reports down when nothing answers", async () => { + const fetchImpl = vi.fn(async () => { + throw new TypeError("ECONNREFUSED"); + }); + const status = await checkNetworkStatus({ + urls: ["http://127.0.0.1:19999"], + timeoutMs: 200, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(status.overall).toBe("down"); + expect(status.taxonomy.down.length).toBeGreaterThan(0); + }); + + it("reports degraded on partial success", async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("8080")) return jsonRpcOk({ ok: true }); + throw new TypeError("down"); + }); + const status = await checkNetworkStatus({ + urls: ["http://127.0.0.1:8080", "http://127.0.0.1:8081"], + roles: ["pxe", "node"], + timeoutMs: 500, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(status.overall).toBe("degraded"); + }); +});