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
2 changes: 1 addition & 1 deletion .github/workflows/publish-mcp-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,6 @@ jobs:
'.servers | any(
.server.name == "io.github.sandbaseai/cli"
and .server.version == $version
and ((.server.remotes // []) | any(.url == "https://sandbase.ai/v1/mcp"))
and ((.server.remotes // []) | any(.url == "https://api.sandbase.ai/v1/mcp" or .url == "https://sandbase.ai/v1/mcp"))
)' \
<<< "$response"
9 changes: 4 additions & 5 deletions assets/mcp-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ 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. If the recorded endpoint is unreachable, a known mirror of the same API
// is tried automatically.
// 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.
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 FALLBACK_URL = "https://www.sandbase.ai/v1/mcp";
const BILLING_URL = "https://www.sandbase.ai/console/billing";
const RECONNECT = "npx -y @sandbaseai/cli@latest connect";
// Tool calls get no bridge-side deadline (long synchronous tools must be able to
Expand Down Expand Up @@ -46,10 +45,10 @@ function validEndpoint(value) {
}
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); }
} else {
try { const host = new URL(primary).hostname; if (host === "api.sandbase.ai" || host === "sandbase.ai") out.push(FALLBACK_URL); } catch { /* validated earlier */ }
}
return [...new Set(out)];
}
Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"remotes": [
{
"type": "streamable-http",
"url": "https://sandbase.ai/v1/mcp",
"url": "https://api.sandbase.ai/v1/mcp",
"headers": [
{
"name": "Authorization",
Expand Down
15 changes: 13 additions & 2 deletions src/a1-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import { configPath, sandbaseHome } from "./paths.js";

export const a1Clients = ["opencode", "qwen-code", "windsurf"] as const;
export type A1Client = typeof a1Clients[number];

// Endpoints an installed `sandbase` entry may legitimately point at, used to
// prove SandBase ownership before touching a client config. The canonical API
// host leads; the bare and www hosts stay recognized so configs written by
// older CLI versions (before the api.sandbase.ai migration) are not rejected
// as foreign on upgrade.
export const OWNED_MCP_ENDPOINTS = [
"https://api.sandbase.ai/v1/mcp",
"https://sandbase.ai/v1/mcp",
"https://www.sandbase.ai/v1/mcp",
] as const;
export function isA1Client(client: string): client is A1Client { return a1Clients.includes(client as A1Client); }

export interface A1CommandResult { code: number | null; stdout: string; stderr: string }
Expand Down Expand Up @@ -73,7 +84,7 @@ async function inspectConfig(client: A1Client, env = process.env, expectedBridge
return entry === undefined ? { state: "missing", detail: "SandBase is not registered" } : openCodeOwned(entry, bridges) ? { state: "configured", detail: "global configuration ownership verified" } : { state: "conflict", detail: "the sandbase entry is not SandBase-owned" };
}
const servers = root.mcpServers === undefined ? undefined : objectMap(root.mcpServers, client); const entry = servers?.sandbase;
const endpoints = [...new Set(["https://sandbase.ai/v1/mcp", identity?.endpoint?.replace(/\/$/, ""), expectedEndpoint?.replace(/\/$/, "")].filter((value): value is string => !!value))];
const endpoints = [...new Set([...OWNED_MCP_ENDPOINTS, identity?.endpoint?.replace(/\/$/, ""), expectedEndpoint?.replace(/\/$/, "")].filter((value): value is string => !!value))];
const owned = client === "qwen-code" ? qwenOwned(entry, bridges) : windsurfOwned(entry, endpoints, env);
if (entry === undefined) return { state: "missing", detail: "SandBase is not registered" };
if (!owned) return { state: "conflict", detail: "the sandbase entry is not SandBase-owned" };
Expand Down Expand Up @@ -127,7 +138,7 @@ export async function installA1(client: A1Client, bridge: string, credential: st
catch (error) { await restoreSnapshot(path, backupPath); if (identityChanged) identityPrevious === undefined ? await rm(ownerPath, { force: true }) : await atomicWrite(ownerPath, identityPrevious); throw error; }
}
const root = parseObject(text, client); const servers = root.mcpServers === undefined ? {} : objectMap(root.mcpServers, client); const existing = servers.sandbase;
if (existing !== undefined && !windsurfOwned(existing, ["https://sandbase.ai/v1/mcp", mcpUrl.replace(/\/$/, "")], env)) throw new Error("windsurf: the sandbase entry is not SandBase-owned; no changes were made");
if (existing !== undefined && !windsurfOwned(existing, [...OWNED_MCP_ENDPOINTS, mcpUrl.replace(/\/$/, "")], env)) throw new Error("windsurf: the sandbase entry is not SandBase-owned; no changes were made");
const desired = { serverUrl: mcpUrl, headers: { Authorization: windsurfAuthorization(env) } }; const next = applyJsonc(text, ["mcpServers", "sandbase"], desired);
const secretPath = credentialPath(env); const currentSecret = await readOptional(secretPath); const configBackup = next === raw ? undefined : await backup(path); const secretBackup = currentSecret === credential ? undefined : await backup(secretPath);
try {
Expand Down
10 changes: 6 additions & 4 deletions src/agent/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ 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";
export const FALLBACK_MCP_URL = "https://www.sandbase.ai/v1/mcp";
const PREFERENCE_TTL_MS = 6 * 60 * 60 * 1000;

export type ErrorCode =
Expand All @@ -34,14 +33,17 @@ export function validMcpUrl(value: unknown): string | undefined {
} catch { return undefined; }
}

/** Ordered endpoint candidates: the recorded URL first, then known-good mirrors of the same API. */
/**
* 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); }
} else {
try { const host = new URL(primary).hostname; if (host === "api.sandbase.ai" || host === "sandbase.ai") out.push(FALLBACK_MCP_URL); } catch { /* primary was validated by the caller */ }
}
return [...new Set(out)];
}
Expand Down
12 changes: 6 additions & 6 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async function prepareOpenClawSkill(detected: Detection, log: (message: string)
}

async function connectOpenClaw(deps: CommandDependencies): Promise<void> {
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient;
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient;
if (!(await prepareOpenClawSkill(detect("openclaw"), log, deps.openClawRunner, deps.openClawReadbackVerifier))) return;
const previous = await store.get("openclaw"); const { authorizationId, exchange } = await authorize(api, "openclaw", { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); let bridgeResult;
try {
Expand All @@ -113,7 +113,7 @@ async function connectOpenClaw(deps: CommandDependencies): Promise<void> {
}

async function connectDesktop(client: Extract<Client, "claude-desktop" | "cowork">, deps: CommandDependencies): Promise<void> {
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log;
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log;
const previous = await store.get(client); const { authorizationId, exchange } = await authorize(api, client, { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); let bridgeResult; let identityResult;
try {
await store.save(recordFor(client, exchange)); bridgeResult = await installBridge(); identityResult = await writeDesktopIdentity(client);
Expand All @@ -126,7 +126,7 @@ async function connectDesktop(client: Extract<Client, "claude-desktop" | "cowork
}

async function connectPromptAssisted(client: PromptAssistedClient, deps: CommandDependencies): Promise<void> {
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log;
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log;
const previous = await store.get(client); const { authorizationId, exchange } = await authorize(api, client, { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); let bridgeResult;
try {
await store.save(recordFor(client, exchange)); bridgeResult = await installBridge();
Expand All @@ -139,7 +139,7 @@ async function connectPromptAssisted(client: PromptAssistedClient, deps: Command
}

async function connectChatGPT(deps: CommandDependencies): Promise<void> {
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log;
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log;
const previous = await store.get("chatgpt"); const { authorizationId, exchange } = await authorize(api, "chatgpt", { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); let bridgeResult;
try {
await store.save(recordFor("chatgpt", exchange)); bridgeResult = await installBridge();
Expand All @@ -152,7 +152,7 @@ async function connectChatGPT(deps: CommandDependencies): Promise<void> {
}

async function connectOne(client: Client, deps: CommandDependencies): Promise<void> {
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient;
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient;
const profile = clientProfiles[client]; const capability = nativeCapabilities[client];
const v2 = capabilityRegistry[client];
if (client === "chatgpt") { await connectChatGPT(deps); return; }
Expand Down Expand Up @@ -186,7 +186,7 @@ async function connectOne(client: Client, deps: CommandDependencies): Promise<vo
}

async function connectAuto(deps: CommandDependencies): Promise<void> {
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient;
const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://api.sandbase.ai").replace(/\/$/, "")); const store = deps.store || new FileCredentialStore(); const log = deps.log || console.log; const detect = deps.detect || detectClient;
const targets: Client[] = await resolveAutoSharedSlots(plannedAutoClients(detect).filter(client => client !== "openclaw") as Client[],log);
const nativeSkillTargets = clients.filter(target => targets.includes(target) && !!skillsAgentRegistry[target] && detect(target).installed);
// Native Skills are an independent local lifecycle: do not let their result
Expand Down
2 changes: 1 addition & 1 deletion test/a1-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ test("Windsurf ownership requires the exact SandBase endpoint", async () => {
await mkdir(join(token, ".."), { recursive: true });
await writeFile(path, original);
await writeFile(token, "third-party-secret", { mode: 0o600 });
await assert.rejects(installA1("windsurf", "/unused", "sandbase-secret", "https://sandbase.ai/v1/mcp", env), /not SandBase-owned/);
await assert.rejects(installA1("windsurf", "/unused", "sandbase-secret", "https://api.sandbase.ai/v1/mcp", env), /not SandBase-owned/);
assert.equal(await readFile(path, "utf8"), original);
assert.equal(await readFile(token, "utf8"), "third-party-secret");
});
Loading
Loading