Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 28 additions & 75 deletions assets/mcp-bridge.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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."); }
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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(); };
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,7 +26,7 @@
{
"registryType": "npm",
"identifier": "@sandbaseai/cli",
"version": "0.3.1",
"version": "0.3.2",
"packageArguments": [
{
"type": "positional",
Expand Down
2 changes: 1 addition & 1 deletion skills/sandbase/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion skills/sandbase/skill.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading