diff --git a/assets/mcp-bridge.mjs b/assets/mcp-bridge.mjs index 2ceb9da..848bef7 100644 --- a/assets/mcp-bridge.mjs +++ b/assets/mcp-bridge.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -14,8 +14,8 @@ import { pathToFileURL } from "node:url"; // silently drops the server. // 2. Every tool failure reaches the model as an `isError` tool result that // names the cause and the next step, instead of an opaque protocol error. -// 3. The API is reached only at api.sandbase.ai; extra mirrors are tried only -// when the user opts in via SANDBASE_MCP_FALLBACK_URLS. +// 3. The API is reached only at api.sandbase.ai (the single API host); a +// transport failure keeps the server usable via the offline responder. const allowedClients = new Set(["codex", "claude-code", "cursor", "gemini-cli", "hermes", "openclaw", "opencode", "qwen-code", "windsurf", "cursor-cli", "kimi-cli", "kiro", "kiro-cli", "amp", "crush", "iflow-cli", "warp", "claude-desktop", "cowork", "antigravity", "trae", "qoder", "workbuddy", "pi"]); const BILLING_URL = "https://www.sandbase.ai/console/billing"; const RECONNECT = "npx -y @sandbaseai/cli@latest connect"; @@ -43,15 +43,6 @@ function validEndpoint(value) { return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash && url.pathname === "/v1/mcp" ? url.href : undefined; } catch { return undefined; } } -export function endpointCandidates(primary, env = process.env) { - const out = [primary]; - // The API is served only from api.sandbase.ai. There is no implicit - // cross-host fallback; users can still opt into extra mirrors explicitly. - if (env.SANDBASE_MCP_FALLBACK_URLS !== undefined) { - for (const item of env.SANDBASE_MCP_FALLBACK_URLS.split(",")) { const url = validEndpoint(item.trim()); if (url) out.push(url); } - } - return [...new Set(out)]; -} function parseCredential(raw, client) { let all; try { all = JSON.parse(raw); } catch { throw bridgeError("SandBase credential store is invalid. Run sandbase connect again."); } @@ -138,24 +129,9 @@ export async function runMCPBridge(options = {}) { try { raw = await readFile(join(home, "credentials.json"), "utf8"); } catch { throw bridgeError("SandBase credential is unavailable. Run sandbase connect again."); } const record = parseCredential(raw, client); - const candidates = endpointCandidates(record.mcpUrl, env); + const url = record.mcpUrl; + const host = new URL(url).host; const toolsCache = join(home, "cache", "tools.json"); - // Shared with the CLI: once a mirror answered, start there for 6 hours so an - // unreachable primary does not add a connect timeout to every new session. - const preferenceFile = join(home, "cache", "endpoint.json"); - let preferred = 0; - try { - const cached = JSON.parse(await readFile(preferenceFile, "utf8")); - const index = candidates.indexOf(cached?.url); - if (cached?.primary === candidates[0] && index > 0 && Date.now() - (cached.savedAt || 0) < 6 * 60 * 60 * 1000) preferred = index; - } catch { /* no preference yet */ } - const rememberPreference = async index => { - try { - if (index === 0) { await rm(preferenceFile, { force: true }); return; } - await mkdir(join(home, "cache"), { recursive: true, mode: 0o700 }); - await writeFile(preferenceFile, JSON.stringify({ primary: candidates[0], url: candidates[index], savedAt: Date.now() }) + "\n", { mode: 0o600 }); - } catch { /* optimization only */ } - }; let buffer = Buffer.alloc(0); let session; @@ -192,60 +168,37 @@ export async function runMCPBridge(options = {}) { try { request = JSON.parse(line); } catch { return; } if (!request || typeof request !== "object" || Array.isArray(request)) return; const idempotent = isIdempotentRequest(request); - const failures = []; - let lastServerFailure; - const order = [...candidates.slice(preferred), ...candidates.slice(0, preferred)]; - for (const [position, url] of order.entries()) { - const host = new URL(url).host; - const isLast = position === order.length - 1; - let response; - try { - const headers = { authorization: `Bearer ${record.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream" }; - if (session) headers["mcp-session-id"] = session; - const timeout = request.method === "tools/call" ? undefined : AbortSignal.timeout(CONTROL_TIMEOUT_MS); - const combined = signal && timeout && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal || timeout; - response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(request), ...(combined ? { signal: combined } : {}) }); - } catch (error) { - if (signal?.aborted) return; - const reason = describeNetworkFailure(error); - if (!idempotent && !isPreSendFailure(error)) { - // The run may already be executing; replaying it on the mirror would bill twice. - if (request.id !== undefined) output.write(toolError(request.id, `The request to ${host} was interrupted after it was sent (${reason}); a run may have started. Do not call sandbase_run again yet: check sandbase_runs for the new run and poll it with sandbase_run_get.`)); - return; - } - failures.push(`${host}: ${reason}`); - continue; - } - const text = await response.text(); - if (response.status >= 500 && idempotent && !isLast) { - // Gateway/origin errors are what a mirror can fix; safe only for idempotent requests. - lastServerFailure = { status: response.status, text, host }; - failures.push(`${host}: HTTP ${response.status}`); - continue; - } - const index = candidates.indexOf(url); - if (index !== preferred || failures.length) await rememberPreference(index); - preferred = index; - const nextSession = response.headers.get("mcp-session-id"); - if (nextSession) session = nextSession; - if (!response.ok) { - if (request.id === undefined) return; - // Never let a server error during the handshake make the client drop SandBase. - if (response.status >= 500 && (request.method === "initialize" || request.method === "tools/list")) { await offline(request, `${host}: HTTP ${response.status}`); return; } - const message = httpFailure(response.status, text, host); - output.write(request.method === "tools/call" ? toolError(request.id, message) : responseError(request.id, message)); + let response; + try { + const headers = { authorization: `Bearer ${record.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream" }; + if (session) headers["mcp-session-id"] = session; + const timeout = request.method === "tools/call" ? undefined : AbortSignal.timeout(CONTROL_TIMEOUT_MS); + const combined = signal && timeout && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal || timeout; + response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(request), ...(combined ? { signal: combined } : {}) }); + } catch (error) { + if (signal?.aborted) return; + const reason = describeNetworkFailure(error); + if (!idempotent && !isPreSendFailure(error)) { + // The run may already be executing; blindly retrying would bill twice. + if (request.id !== undefined) output.write(toolError(request.id, `The request to ${host} was interrupted after it was sent (${reason}); a run may have started. Do not call sandbase_run again yet: check sandbase_runs for the new run and poll it with sandbase_run_get.`)); return; } - for (const payload of payloadsOf(text, response.headers.get("content-type") || "")) await deliver(request, payload); + // Transport failure: keep the server usable (tools stay listed, calls get an actionable error). + await offline(request, `${host}: ${reason}`); return; } - if (lastServerFailure && failures.every(failure => /HTTP 5\d\d$/.test(failure)) && request.method !== "initialize" && request.method !== "tools/list") { + const text = await response.text(); + const nextSession = response.headers.get("mcp-session-id"); + if (nextSession) session = nextSession; + if (!response.ok) { if (request.id === undefined) return; - const message = httpFailure(lastServerFailure.status, lastServerFailure.text, lastServerFailure.host); + // Never let a server error during the handshake make the client drop SandBase. + if (response.status >= 500 && (request.method === "initialize" || request.method === "tools/list")) { await offline(request, `${host}: HTTP ${response.status}`); return; } + const message = httpFailure(response.status, text, host); output.write(request.method === "tools/call" ? toolError(request.id, message) : responseError(request.id, message)); return; } - await offline(request, failures.join("; ") || "network error"); + for (const payload of payloadsOf(text, response.headers.get("content-type") || "")) await deliver(request, payload); }; const stopInput = () => { if (typeof input.destroy === "function") input.destroy(); }; diff --git a/package-lock.json b/package-lock.json index 4a0fc8b..ff85080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sandbaseai/cli", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sandbaseai/cli", - "version": "0.3.1", + "version": "0.3.2", "license": "Apache-2.0", "dependencies": { "jsonc-parser": "3.3.1", diff --git a/package.json b/package.json index 20fd894..ae88804 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sandbaseai/cli", - "version": "0.3.1", + "version": "0.3.2", "mcpName": "io.github.sandbaseai/cli", "description": "Give 25 AI clients—Claude Code, Codex, Cursor, Hermes, OpenClaw and more—2,000+ models and APIs through one MCP server and an agent-friendly CLI", "type": "module", diff --git a/server.json b/server.json index e89d525..1755a6b 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.sandbaseai/cli", "title": "SandBase", "description": "Connect MCP clients to 2,000+ AI models without managing provider API keys.", - "version": "0.3.1", + "version": "0.3.2", "repository": { "url": "https://github.com/sandbaseai/cli", "source": "github" @@ -26,7 +26,7 @@ { "registryType": "npm", "identifier": "@sandbaseai/cli", - "version": "0.3.1", + "version": "0.3.2", "packageArguments": [ { "type": "positional", diff --git a/skills/sandbase/SKILL.md b/skills/sandbase/SKILL.md index d2f65fe..4b3ef1e 100644 --- a/skills/sandbase/SKILL.md +++ b/skills/sandbase/SKILL.md @@ -1,6 +1,6 @@ --- name: sandbase -version: 0.3.1 +version: 0.3.2 description: >- Discover better ways to complete tasks. Proactively search SandBase (the sandbase_discover MCP tool, or `sandbase discover` in a shell) before writing a diff --git a/skills/sandbase/skill.json b/skills/sandbase/skill.json index 2bfec8e..440b3fe 100644 --- a/skills/sandbase/skill.json +++ b/skills/sandbase/skill.json @@ -1,6 +1,6 @@ { "name": "sandbase", - "version": "0.3.1", + "version": "0.3.2", "description": "Discover and run 2,000+ AI models and APIs (media generation, social data, search, scraping) through one MCP interface or CLI, paid per call from one balance.", "author": "SandBase AI", "license": "Apache-2.0", diff --git a/src/agent/client.ts b/src/agent/client.ts index f2bc9ff..6a3cedf 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -3,14 +3,13 @@ // `sandbase doctor`. Every failure becomes a SandBaseError carrying a stable // code plus a concrete next step, so an agent reading the output knows what to // do instead of giving up. -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { sandbaseHome } from "../paths.js"; import { clients, type Client, type CredentialRecord } from "../types.js"; export const DEFAULT_MCP_URL = "https://api.sandbase.ai/v1/mcp"; -const PREFERENCE_TTL_MS = 6 * 60 * 60 * 1000; +export const UNREACHABLE_HINT = "Check this machine's network, proxy, or VPN, then run `sandbase doctor` and retry."; export type ErrorCode = | "AUTH_REQUIRED" | "AUTH_FAILED" | "INSUFFICIENT_BALANCE" | "FORBIDDEN" | "NOT_FOUND" | "RATE_LIMITED" @@ -33,22 +32,7 @@ export function validMcpUrl(value: unknown): string | undefined { } catch { return undefined; } } -/** - * Ordered endpoint candidates: the recorded URL first, then any extra mirrors - * the user explicitly opted into via SANDBASE_MCP_FALLBACK_URLS. The API is - * served only from api.sandbase.ai, so there is no implicit cross-host - * fallback — the main-site and bare hosts are not API entry points. - */ -export function endpointCandidates(primary: string, env = process.env): string[] { - const out = [primary]; - const configured = env.SANDBASE_MCP_FALLBACK_URLS; - if (configured !== undefined) { - for (const item of configured.split(",").map(value => value.trim()).filter(Boolean)) { const url = validMcpUrl(item); if (url) out.push(url); } - } - return [...new Set(out)]; -} - -export interface Endpoint { credential: string; urls: string[]; source: string } +export interface Endpoint { credential: string; url: string; source: string } export interface StoredKey { key: string; mcpUrl?: string; addedAt: string } export function keyFilePath(env = process.env): string { return join(sandbaseHome(env), "cli-key.json"); } export async function readStoredKey(env = process.env): Promise { @@ -69,21 +53,21 @@ function usable(record: CredentialRecord | undefined, client: Client): record is export async function resolveEndpoint(options: { client?: string } = {}, env = process.env): Promise { const override = env.SANDBASE_MCP_URL ? validMcpUrl(env.SANDBASE_MCP_URL) : undefined; if (env.SANDBASE_MCP_URL && !override) throw new SandBaseError("USAGE", "SANDBASE_MCP_URL must be an http(s) URL ending in /v1/mcp."); - if (env.SANDBASE_API_KEY) return { credential: env.SANDBASE_API_KEY, urls: endpointCandidates(override || DEFAULT_MCP_URL, env), source: "env:SANDBASE_API_KEY" }; + if (env.SANDBASE_API_KEY) return { credential: env.SANDBASE_API_KEY, url: override || DEFAULT_MCP_URL, source: "env:SANDBASE_API_KEY" }; const wanted = options.client || env.SANDBASE_CLIENT; if (!wanted) { const stored = await readStoredKey(env); - if (stored) return { credential: stored.key, urls: endpointCandidates(override || validMcpUrl(stored.mcpUrl) || DEFAULT_MCP_URL, env), source: "keys" }; + if (stored) return { credential: stored.key, url: override || validMcpUrl(stored.mcpUrl) || DEFAULT_MCP_URL, source: "keys" }; } const records = await readCredentials(env); if (wanted) { const record = records[wanted as Client]; if (!usable(record, wanted as Client)) throw new SandBaseError("AUTH_REQUIRED", `No SandBase credential is stored for client "${wanted}".`, `Run \`${CONNECT_COMMAND} --client ${wanted}\`, or omit --client to use any connected client.`); - return { credential: record.credential, urls: endpointCandidates(override || record.mcpUrl, env), source: `client:${wanted}` }; + return { credential: record.credential, url: override || validMcpUrl(record.mcpUrl) || DEFAULT_MCP_URL, source: `client:${wanted}` }; } for (const client of clients) { const record = records[client]; - if (usable(record, client)) return { credential: record.credential, urls: endpointCandidates(override || record.mcpUrl, env), source: `client:${client}` }; + if (usable(record, client)) return { credential: record.credential, url: override || validMcpUrl(record.mcpUrl) || DEFAULT_MCP_URL, source: `client:${client}` }; } throw new SandBaseError("AUTH_REQUIRED", "No SandBase credential found on this machine.", `Run \`${CONNECT_COMMAND}\` (browser sign-in, nothing to paste), or create an API key at ${keysUrl(env)} and run \`sandbase keys add -k \` (or set SANDBASE_API_KEY).`); } @@ -158,77 +142,42 @@ export type FetchLike = (url: string, init: { method: string; headers: Record = []; - constructor(readonly endpoint: Endpoint, private readonly fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike, private readonly timeoutMs = 120_000, private readonly env = process.env) { - // Each CLI invocation is a new process: remember which mirror answered so an - // unreachable primary costs one connect timeout per 6 hours, not per command. - try { - const cached = JSON.parse(readFileSync(this.preferenceFile(), "utf8")) as { primary?: string; url?: string; savedAt?: number }; - const index = cached.url ? endpoint.urls.indexOf(cached.url) : -1; - if (cached.primary === endpoint.urls[0] && index > 0 && Date.now() - (cached.savedAt || 0) < PREFERENCE_TTL_MS) this.preferred = index; - } catch { /* no preference yet */ } - } - private preferenceFile(): string { return join(sandbaseHome(this.env), "cache", "endpoint.json"); } - private rememberPreference(index: number): void { - const primary = this.endpoint.urls[0]; - try { - if (index === 0) { rmSync(this.preferenceFile(), { force: true }); return; } - mkdirSync(dirname(this.preferenceFile()), { recursive: true, mode: 0o700 }); - writeFileSync(this.preferenceFile(), JSON.stringify({ primary, url: this.endpoint.urls[index], savedAt: Date.now() }) + "\n", { mode: 0o600 }); - } catch { /* preference is an optimization only */ } - } - /** The endpoint that last answered (or the first candidate before any call). */ - activeUrl(): string { return this.endpoint.urls[this.preferred] || this.endpoint.urls[0] || DEFAULT_MCP_URL; } + constructor(readonly endpoint: Endpoint, private readonly fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike, private readonly timeoutMs = 120_000, private readonly env = process.env) {} + /** The single API endpoint for this session. */ + activeUrl(): string { return this.endpoint.url; } async rpc(method: string, params?: unknown): Promise { const id = this.nextId++; const body = JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) }); - const idempotent = isIdempotentRequest(method, params); - const failures: string[] = []; - let lastServerError: SandBaseError | undefined; - const order = [...this.endpoint.urls.slice(this.preferred), ...this.endpoint.urls.slice(0, this.preferred)]; - for (const [position, url] of order.entries()) { - const host = new URL(url).host; - const isLast = position === order.length - 1; - let response; - try { - const headers: Record = { authorization: `Bearer ${this.endpoint.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream", "x-sandbase-client": "cli" }; - if (this.session) headers["mcp-session-id"] = this.session; - response = await this.fetchImpl(url, { method: "POST", headers, body, signal: AbortSignal.timeout(this.timeoutMs) }); - } catch (error) { - const reason = describeNetworkFailure(error); this.attempts.push({ url, outcome: reason }); - if (!idempotent && !isPreSendFailure(error)) { - // The request may already be running server-side; replaying it would bill twice. - throw new SandBaseError("NETWORK", `The request to ${host} was interrupted after it was sent (${reason}); it may have started a run.`, "Do not re-run yet. Check `sandbase runs list` for the run and poll it with `sandbase runs get -r -w`."); - } - failures.push(`${host}: ${reason}`); - continue; - } - this.attempts.push({ url, outcome: `HTTP ${response.status}` }); - const session = response.headers.get("mcp-session-id"); if (session) this.session = session; - const text = await response.text(); - if (response.status >= 500 && idempotent && !isLast) { - // Gateway/origin errors are what a mirror can fix; only safe for idempotent calls. - lastServerError = httpError(response.status, text, host, this.env); failures.push(`${host}: HTTP ${response.status}`); - continue; - } - const index = this.endpoint.urls.indexOf(url); - if (index !== this.preferred || failures.length) this.rememberPreference(index); - this.preferred = index; - if (!response.ok) throw httpError(response.status, text, host, this.env); - const parsed = parseRpcBody(text, response.headers.get("content-type") || "", id); - if (!parsed) throw new SandBaseError("INVALID_RESPONSE", `Expected JSON-RPC from ${host} but got ${text ? `"${text.slice(0, 80).replace(/\s+/g, " ")}"` : "an empty body"}.`, "A proxy or captive portal may be intercepting traffic. Run `sandbase doctor`."); - if (parsed.error) { - const message = parsed.error.message || "request failed"; - const code: ErrorCode = parsed.error.code === -32602 ? "INVALID_INPUT" : /unknown tool|tool(?: '[^']*')? (?:was )?not found|run not found|invalid service request/i.test(message) ? "NOT_FOUND" : "TOOL_ERROR"; - throw new SandBaseError(code, message, hintForMessage(message, this.env)); + const url = this.endpoint.url; + const host = new URL(url).host; + let response; + try { + const headers: Record = { authorization: `Bearer ${this.endpoint.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream", "x-sandbase-client": "cli" }; + if (this.session) headers["mcp-session-id"] = this.session; + response = await this.fetchImpl(url, { method: "POST", headers, body, signal: AbortSignal.timeout(this.timeoutMs) }); + } catch (error) { + const reason = describeNetworkFailure(error); this.attempts.push({ url, outcome: reason }); + if (!isIdempotentRequest(method, params) && !isPreSendFailure(error)) { + // The request may already be running server-side; blindly retrying would bill twice. + throw new SandBaseError("NETWORK", `The request to ${host} was interrupted after it was sent (${reason}); it may have started a run.`, "Do not re-run yet. Check `sandbase runs list` for the run and poll it with `sandbase runs get -r -w`."); } - return parsed.result; + throw new SandBaseError("NETWORK", `Cannot reach SandBase at ${host} (${reason}).`, UNREACHABLE_HINT); + } + this.attempts.push({ url, outcome: `HTTP ${response.status}` }); + const session = response.headers.get("mcp-session-id"); if (session) this.session = session; + const text = await response.text(); + if (!response.ok) throw httpError(response.status, text, host, this.env); + const parsed = parseRpcBody(text, response.headers.get("content-type") || "", id); + if (!parsed) throw new SandBaseError("INVALID_RESPONSE", `Expected JSON-RPC from ${host} but got ${text ? `"${text.slice(0, 80).replace(/\s+/g, " ")}"` : "an empty body"}.`, "A proxy or captive portal may be intercepting traffic. Run `sandbase doctor`."); + if (parsed.error) { + const message = parsed.error.message || "request failed"; + const code: ErrorCode = parsed.error.code === -32602 ? "INVALID_INPUT" : /unknown tool|tool(?: '[^']*')? (?:was )?not found|run not found|invalid service request/i.test(message) ? "NOT_FOUND" : "TOOL_ERROR"; + throw new SandBaseError(code, message, hintForMessage(message, this.env)); } - if (lastServerError && failures.every(failure => /HTTP 5\d\d$/.test(failure))) throw lastServerError; - throw new SandBaseError("NETWORK", `Cannot reach SandBase (${failures.join("; ")}).`, "Check your network, proxy, or VPN, then run `sandbase doctor`. Set SANDBASE_MCP_URL to use a different endpoint."); + return parsed.result; } async tool(name: string, args: Record = {}): Promise { diff --git a/src/agent/probe.ts b/src/agent/probe.ts index 9a43b94..7eb27e2 100644 --- a/src/agent/probe.ts +++ b/src/agent/probe.ts @@ -1,27 +1,25 @@ // Real connectivity check used by `sandbase doctor` and `sandbase setup`: -// initialize → tools/list → sandbase_account (free) against the same endpoint -// candidates the bridge uses. Local file checks alone cannot tell "configured" -// from "configured but unreachable", which is the failure agents actually hit. -import { endpointCandidates, SandBaseError, SandBaseMCPClient, type FetchLike } from "./client.js"; +// initialize → tools/list → sandbase_account (free) against the API endpoint. +// Local file checks alone cannot tell "configured" from "configured but +// unreachable", which is the failure agents actually hit. +import { SandBaseError, SandBaseMCPClient, validMcpUrl, DEFAULT_MCP_URL, type FetchLike } from "./client.js"; import { cliVersion } from "./update.js"; export interface ProbeResult { ok: boolean; line: string } export async function probeEndpoint(label: string, credential: string, mcpUrl: string, env = process.env, fetchImpl?: FetchLike, timeoutMs = 15_000): Promise { - const client = new SandBaseMCPClient({ credential, urls: endpointCandidates(mcpUrl, env), source: label }, fetchImpl, timeoutMs, env); + const url = validMcpUrl(mcpUrl) || DEFAULT_MCP_URL; + const client = new SandBaseMCPClient({ credential, url, source: label }, fetchImpl, timeoutMs, env); const started = Date.now(); try { await client.rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "sandbase-doctor", version: cliVersion() } }); const listed = await client.rpc("tools/list", {}) as { tools?: unknown[] }; const account = await client.tool("sandbase_account", {}); - const host = new URL(client.activeUrl()).host; - const primaryHost = new URL(mcpUrl).host; - const primaryFailure = client.attempts.find(attempt => new URL(attempt.url).host === primaryHost && !attempt.outcome.startsWith("HTTP")); - const fallback = host !== primaryHost && primaryFailure ? `, primary=${primaryHost} (${primaryFailure.outcome}), fallback=${host} (the MCP bridge switches automatically)` : ""; + const host = new URL(url).host; const tools = Array.isArray(listed?.tools) ? listed.tools.length : 0; const balance = account.data.balance === undefined ? "unknown" : `$${String(account.data.balance)}`; const low = Number(account.data.balance) <= 0 ? ", next_step=Balance is empty; top up before running paid tools." : ""; - return { ok: tools > 0, line: `${label}: network=ok, endpoint=${host}, latency=${Date.now() - started}ms, tools=${tools}, balance=${balance}${fallback}${low}` }; + return { ok: tools > 0, line: `${label}: network=ok, endpoint=${host}, latency=${Date.now() - started}ms, tools=${tools}, balance=${balance}${low}` }; } catch (error) { const failure = error instanceof SandBaseError ? error : new SandBaseError("NETWORK", error instanceof Error ? error.message : "unknown failure"); return { ok: false, line: `${label}: network=failed, code=${failure.code}, reason=${failure.message}${failure.hint ? `, next_step=${failure.hint}` : ""}` }; diff --git a/test/agent.test.ts b/test/agent.test.ts index 1406f81..8e65fd1 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; import test from "node:test"; -import { endpointCandidates, httpError, resolveEndpoint, SandBaseError, SandBaseMCPClient, type FetchLike } from "../src/agent/client.js"; +import { httpError, resolveEndpoint, SandBaseError, SandBaseMCPClient, type FetchLike } from "../src/agent/client.js"; import { formatPrice, resolveSchema, runAgentCommand } from "../src/agent/commands.js"; import { probeEndpoint } from "../src/agent/probe.js"; import { compareVersions, updateNotice } from "../src/agent/update.js"; @@ -51,31 +51,26 @@ test("credential precedence: env key, then stored key, then a connected client", await assert.rejects(resolveEndpoint({ client: "cursor" }, env), /No SandBase credential is stored for client "cursor"/); }); -test("the API is reached only at its own host; explicit overrides are honored", () => { - // The API lives only on api.sandbase.ai: no implicit cross-host fallback. - assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", {}), ["https://api.sandbase.ai/v1/mcp"]); - assert.deepEqual(endpointCandidates("http://127.0.0.1:9/v1/mcp", {}), ["http://127.0.0.1:9/v1/mcp"]); - assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", { SANDBASE_MCP_FALLBACK_URLS: "" }), ["https://api.sandbase.ai/v1/mcp"]); - // Users can still opt into extra mirrors explicitly. - assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", { SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }), ["https://api.sandbase.ai/v1/mcp", "https://sandbase.ai/v1/mcp"]); +test("resolveEndpoint carries the single API host; SANDBASE_MCP_URL overrides it", async () => { + const env = await home(); await connected(env); + assert.equal((await resolveEndpoint({}, env)).url, "https://api.sandbase.ai/v1/mcp"); + assert.equal((await resolveEndpoint({}, { ...env, SANDBASE_MCP_URL: "https://api.test/v1/mcp" })).url, "https://api.test/v1/mcp"); + await assert.rejects(resolveEndpoint({}, { ...env, SANDBASE_MCP_URL: "https://api.test/not-mcp" }), /SANDBASE_MCP_URL must be/); }); -test("an explicitly configured mirror is used when the primary is unreachable, and the preference persists across processes", async () => { - const env = { ...(await home()), SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }; await connected(env); - const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : toolResult({ balance: "1.5", currency: "USD" })); - const first = new SandBaseMCPClient(await resolveEndpoint({}, env), fetch, 1000, env); - assert.equal((await first.tool("sandbase_account")).data.balance, "1.5"); - assert.deepEqual(calls.map(call => new URL(call.url).host), ["api.sandbase.ai", "sandbase.ai"]); - const second = new SandBaseMCPClient(await resolveEndpoint({}, env), fetch, 1000, env); - await second.tool("sandbase_account"); - assert.equal(new URL(calls.at(-1)!.url).host, "sandbase.ai"); assert.equal(calls.length, 3, "second process skips the dead primary"); +test("a single endpoint failure is reported as-is; there is no cross-host retry", async () => { + const env = await home(); await connected(env); + const { fetch, calls } = fakeFetch(() => connectTimeout()); + const client = new SandBaseMCPClient(await resolveEndpoint({}, env), fetch, 1000, env); + await assert.rejects(client.tool("sandbase_account"), (error: SandBaseError) => error.code === "NETWORK" && /api\.sandbase\.ai/.test(error.message)); + assert.equal(calls.length, 1, "the one host is tried once, not fanned out"); }); test("network and HTTP failures carry a code and a next step", async () => { const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); const { fetch } = fakeFetch(() => connectTimeout()); const result = await cli("balance", [], env, fetch); - assert.equal(result.code, 1); assert.match(result.err, /Error \[NETWORK\]: Cannot reach SandBase \(api\.sandbase\.ai: connection timed out\)/); assert.match(result.err, /Next: .*sandbase doctor/); + assert.equal(result.code, 1); assert.match(result.err, /Error \[NETWORK\]: Cannot reach SandBase at api\.sandbase\.ai \(connection timed out\)/); assert.match(result.err, /Next: .*sandbase doctor/); assert.doesNotMatch(result.err, /FALLBACK/); assert.equal(httpError(402, '{"error":"API key spending limit exceeded"}', "api.sandbase.ai").code, "INSUFFICIENT_BALANCE"); assert.match(httpError(401, "", "api.sandbase.ai").hint ?? "", /connect/); const json = await cli("balance", ["-j"], env, fetch); @@ -172,13 +167,8 @@ test("doctor probe reports the active host and balance, or the exact failure", a const ok = fakeFetch(call => call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover" }] } } } : call.method === "initialize" ? { body: { result: {} } } : toolResult({ balance: "3.2" })); const env = await home(); const good = await probeEndpoint("codex", "secret", "https://api.sandbase.ai/v1/mcp", env, ok.fetch, 1000); - assert.equal(good.ok, true); assert.match(good.line, /network=ok, endpoint=api\.sandbase\.ai/); assert.match(good.line, /balance=\$3\.2/); assert.doesNotMatch(good.line, /fallback=/); - // With an explicitly configured mirror, a dead primary is reported and the probe switches hosts. - const mirrorEnv = { ...env, SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }; - const okMirror = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover" }] } } } : call.method === "initialize" ? { body: { result: {} } } : toolResult({ balance: "3.2" })); - const viaMirror = await probeEndpoint("codex", "secret", "https://api.sandbase.ai/v1/mcp", mirrorEnv, okMirror.fetch, 1000); - assert.equal(viaMirror.ok, true); assert.match(viaMirror.line, /endpoint=sandbase\.ai/); assert.match(viaMirror.line, /primary=api\.sandbase\.ai \(connection timed out\)/); - // Unreachable with no mirror: a plain network failure on the one host. + assert.equal(good.ok, true); assert.match(good.line, /network=ok, endpoint=api\.sandbase\.ai/); assert.match(good.line, /balance=\$3\.2/); assert.doesNotMatch(good.line, /fallback=|mirror/); + // Unreachable: a plain network failure on the one host, no cross-host switch. const down = await probeEndpoint("codex", "secret", "https://api.sandbase.ai/v1/mcp", env, fakeFetch(() => connectTimeout()).fetch, 1000); assert.equal(down.ok, false); assert.match(down.line, /network=failed, code=NETWORK/); assert.match(down.line, /api\.sandbase\.ai/); const denied = fakeFetch(() => ({ status: 401, body: '{"error":"invalid API key"}' })); @@ -212,48 +202,33 @@ test("bridge keeps the server usable offline and turns tool failures into visibl assert.ok(list!.result.tools.some((tool: { name: string }) => tool.name === "sandbase_discover")); assert.equal(call!.result.isError, true); assert.match(call!.result.content[0].text, /unreachable from this machine \(api\.sandbase\.ai: connection timed out\).*sandbase doctor/); - // The mixed/failover scenario needs a second host to switch to; there is no - // implicit fallback, so configure one explicitly for this session. The API - // ships only on api.sandbase.ai — this mirror is a test fixture. - const mirrorEnv = { ...env, SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }; - const mixed = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover", description: "live" }] } } } : call.method === "tools/call" && (call.params as { name: string }).name === "sandbase_account" ? { status: 402, body: '{"error":"API key spending limit exceeded"}' } : { body: { error: { code: -32603, message: "capability call failed" } } }); - const [listed, failed, broke] = await bridge(mirrorEnv, mixed.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/list" }, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_run", arguments: {} } }, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "sandbase_account", arguments: {} } }]); + // When the single host answers, live tool results and server errors are + // mapped to isError with an actionable hint; the live tools/list is cached. + const mixed = fakeFetch(call => call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover", description: "live" }] } } } : call.method === "tools/call" && (call.params as { name: string }).name === "sandbase_account" ? { status: 402, body: '{"error":"API key spending limit exceeded"}' } : { body: { error: { code: -32603, message: "capability call failed" } } }); + const [listed, failed, broke] = await bridge(env, mixed.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/list" }, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_run", arguments: {} } }, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "sandbase_account", arguments: {} } }]); assert.equal(listed!.result.tools[0].description, "live"); assert.equal(failed!.result.isError, true); assert.match(failed!.result.content[0].text, /capability call failed\. The upstream provider failed\. Retry once/); assert.equal(broke!.result.isError, true); assert.match(broke!.result.content[0].text, /\(402\): API key spending limit exceeded.*console\/billing/); - assert.ok(mixed.calls.filter(call => call.url.includes("api.sandbase.ai")).length <= 1, "after one fallback the session stays on the mirror"); - const cachedOffline = await bridge(mirrorEnv, offline.fetch, [{ jsonrpc: "2.0", id: 9, method: "tools/list" }]); + const cachedOffline = await bridge(env, offline.fetch, [{ jsonrpc: "2.0", id: 9, method: "tools/list" }]); assert.equal(cachedOffline[0]!.result.tools[0].description, "live", "offline tools/list serves the last live list"); }); function resetAfterSend(): Error { const error = new TypeError("fetch failed") as TypeError & { cause?: unknown }; error.cause = { code: "ECONNRESET" }; return error; } -// Failover is exercised through an explicitly configured mirror, since there -// is no implicit cross-host fallback anymore. The mirror host here is only a -// test fixture for the failover rules; the API itself ships only on -// api.sandbase.ai. -async function homeWithMirror(): Promise { return { ...(await home()), SANDBASE_MCP_FALLBACK_URLS: "https://sandbase.ai/v1/mcp" }; } - -test("a paid run interrupted after sending is never replayed on the mirror", async () => { - const env = await homeWithMirror(); await connected(env); - const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? resetAfterSend() : toolResult({ prediction_id: "pred_dup", status: "completed", output: "x" })); +test("a paid run interrupted after sending is never retried (double-bill guard)", async () => { + const env = await home(); await connected(env); + const { fetch, calls } = fakeFetch(() => resetAfterSend()); const result = await cli("run", ["sandbase_paid", "-i", "{}", "-j"], env, fetch); assert.equal(result.code, 1); assert.equal(calls.filter(call => call.method === "tools/call").length, 1, "no second sandbase_run"); assert.match(JSON.parse(result.out).error.message, /may have started a run/); - const reads = fakeFetch(call => call.url.includes("api.sandbase.ai") ? resetAfterSend() : toolResult({ balance: "2" })); - assert.equal((await cli("balance", [], await (async () => { const e = await homeWithMirror(); await connected(e); return e; })(), reads.fetch)).code, 0, "idempotent reads may fail over"); }); -test("HTTP 5xx on the primary fails over for idempotent calls only", async () => { - const env = await homeWithMirror(); await connected(env); - const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? { status: 502, body: "bad gateway" } : toolResult({ balance: "4" })); - const ok = await cli("balance", [], env, fetch); - assert.equal(ok.code, 0); assert.match(ok.out, /\$4/); assert.equal(calls.length, 2); - const env2 = await homeWithMirror(); await connected(env2); - const paid = fakeFetch(call => call.url.includes("api.sandbase.ai") ? { status: 502, body: "" } : toolResult({ prediction_id: "p", status: "completed" })); - const failed = await cli("run", ["sandbase_paid", "-i", "{}"], env2, paid.fetch); +test("HTTP 5xx on the single host is reported, not retried across hosts", async () => { + const env = await home(); await connected(env); + const paid = fakeFetch(() => ({ status: 502, body: "bad gateway" })); + const failed = await cli("balance", [], env, paid.fetch); assert.equal(failed.code, 1); assert.equal(paid.calls.length, 1); assert.match(failed.err, /\[UPSTREAM\].*HTTP 502/); }); @@ -339,7 +314,7 @@ test("setup refreshes the bridge and SandBase-owned Skills but leaves edited Ski const lines: string[] = []; assert.equal(await setup(new FileCredentialStore(env.SANDBASE_HOME!), line => void lines.push(line)), true); assert.match(lines.join("\n"), /bridge: status=updated/); assert.match(lines.join("\n"), /claude-code: skill=updated/); assert.match(lines.join("\n"), /kiro: skill=modified/); - assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.1/); + assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.2/); assert.match(await readFile(join(kiroDir, "SKILL.md"), "utf8"), /user edit/); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } } });