fix: ship prebuilt CraftTable CLI

This commit is contained in:
2026-08-15 20:43:32 +08:00
parent 7c033b6118
commit 4e5ee6c746
4 changed files with 787 additions and 2 deletions
+3 -1
View File
@@ -7,11 +7,13 @@
The repository is private, so first make sure Git Credential Manager can access `git.crash.work`, then run this in PowerShell: The repository is private, so first make sure Git Credential Manager can access `git.crash.work`, then run this in PowerShell:
```powershell ```powershell
npm install --global git+https://git.crash.work/cneicy/crafttable-mcp-client.git; crafttable-mcp login; crafttable-mcp configure all npm install --global --allow-git=all git+https://git.crash.work/cneicy/crafttable-mcp-client.git; crafttable-mcp login; crafttable-mcp configure all
``` ```
This installs the CLI, opens the browser for OAuth login, and configures the same local stdio bridge for Codex, Claude Code, and OpenCode at user scope. This installs the CLI, opens the browser for OAuth login, and configures the same local stdio bridge for Codex, Claude Code, and OpenCode at user scope.
The explicit `--allow-git=all` is required by npm 12, whose default policy rejects Git-based package dependencies. The repository includes the built CLI bundle, so installation does not run a build script.
## Commands ## Commands
```powershell ```powershell
Vendored
+777
View File
@@ -0,0 +1,777 @@
#!/usr/bin/env node
// src/cli.ts
import { fileURLToPath } from "node:url";
import { Command } from "commander";
// src/agents.ts
import { spawn } from "node:child_process";
import { constants } from "node:fs";
import { copyFile, mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
import os2 from "node:os";
import path2 from "node:path";
import { createInterface } from "node:readline/promises";
import { applyEdits, modify, parse } from "jsonc-parser";
// src/config.ts
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
var DEFAULT_MCP_URL = "https://crafttable.crash.work/mcp";
var DEFAULT_CLIENT_ID = "crafttable-mcp-cli";
var DEFAULT_CALLBACK_PORT = 48321;
var OAUTH_SCOPES = "openid profile email offline_access";
function resolveClientOptions(input = {}) {
const env = input.env ?? process.env;
const url = validateMcpUrl(input.url ?? env.CRAFTTABLE_MCP_URL ?? DEFAULT_MCP_URL);
const clientId = (input.clientId ?? env.CRAFTTABLE_MCP_OAUTH_CLIENT_ID ?? DEFAULT_CLIENT_ID).trim();
if (!clientId) throw new Error("OAuth client ID must not be empty");
const callbackPort = Number(input.callbackPort ?? env.CRAFTTABLE_MCP_OAUTH_CALLBACK_PORT ?? DEFAULT_CALLBACK_PORT);
if (!Number.isInteger(callbackPort) || callbackPort < 1 || callbackPort > 65535) {
throw new Error("OAuth callback port must be an integer between 1 and 65535");
}
return { url, clientId, callbackPort };
}
function validateMcpUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new Error("MCP URL must be an absolute URL");
}
if (url.username || url.password || url.search || url.hash) {
throw new Error("MCP URL must not include credentials, query parameters, or a fragment");
}
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
throw new Error("MCP URL must use HTTPS unless it targets localhost");
}
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
return url;
}
function credentialAccount(options) {
return createHash("sha256").update(`${options.url.toString()}\0${options.clientId}`).digest("hex");
}
function platformConfigDir(env = process.env, platform = process.platform) {
if (platform === "win32") return path.join(env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "GameCraftTable", "mcp-client");
if (platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "GameCraftTable", "mcp-client");
return path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "gamecrafttable", "mcp-client");
}
var DiscoveryStore = class {
filePath;
constructor(configDir = platformConfigDir()) {
this.filePath = path.join(configDir, "discovery.json");
}
async get(account) {
return (await this.read()).entries[account];
}
async set(account, state) {
const document = await this.read();
document.entries[account] = state;
await atomicWriteJson(this.filePath, document);
}
async delete(account) {
const document = await this.read();
if (!(account in document.entries)) return;
delete document.entries[account];
await atomicWriteJson(this.filePath, document);
}
async read() {
try {
const value = JSON.parse(await readFile(this.filePath, "utf8"));
return { version: 1, entries: value.entries && typeof value.entries === "object" ? value.entries : {} };
} catch (error) {
if (error.code === "ENOENT") return { version: 1, entries: {} };
throw new Error(`Could not read OAuth discovery cache: ${errorMessage(error)}`);
}
}
};
async function atomicWriteJson(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.${process.pid}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}
`, { encoding: "utf8", mode: 384 });
await rename(temporary, filePath);
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
// src/agents.ts
var SERVER_NAME = "crafttable";
async function configureAgents(input) {
const agents = expandTarget(input.target);
const command = launchCommand(input.options, input.cliEntry, input.nodePath ?? process.execPath);
const runner = input.runner ?? new SpawnCommandRunner();
const results = [];
for (const agent of agents) {
if (agent === "opencode") {
results.push(await configureOpenCode(input, command));
} else {
results.push(await configureCliAgent(agent, command, input, runner));
}
}
return results;
}
async function unconfigureAgents(input) {
const agents = expandTarget(input.target);
const runner = input.runner ?? new SpawnCommandRunner();
const results = [];
for (const agent of agents) {
if (agent === "opencode") {
results.push(await unconfigureOpenCode(input));
continue;
}
const executable = agent === "codex" ? "codex" : "claude";
const existing = await probeCliAgent(agent, runner);
if (!existing.exists) {
results.push({ agent, action: "absent" });
continue;
}
if (input.dryRun) {
results.push({ agent, action: "would-remove" });
continue;
}
const args = agent === "codex" ? ["mcp", "remove", SERVER_NAME] : ["mcp", "remove", "--scope", "user", SERVER_NAME];
await requireSuccess(runner.run(executable, args), `${agent} MCP removal`);
results.push({ agent, action: "removed" });
}
return results;
}
function launchCommand(options, cliEntry, nodePath) {
return [
path2.resolve(nodePath),
path2.resolve(cliEntry),
"serve",
"--url",
options.url.toString(),
"--client-id",
options.clientId,
"--callback-port",
String(options.callbackPort)
];
}
async function configureCliAgent(agent, command, input, runner) {
const existing = await probeCliAgent(agent, runner);
if (existing.exists && outputMatchesCommand(existing.output, command)) return { agent, action: "unchanged" };
if (existing.exists && !input.force && !input.dryRun) {
const confirm = input.confirm ?? terminalConfirm;
if (!process.stdin.isTTY && !input.confirm) throw new Error(`${agent} already has a different ${SERVER_NAME} MCP entry; use --force to replace it`);
if (!await confirm(`${agent} already has a different ${SERVER_NAME} MCP entry. Replace it?`)) {
throw new Error(`${agent} MCP configuration was not changed`);
}
}
const action = existing.exists ? "replaced" : "added";
if (input.dryRun) return { agent, action: existing.exists ? "would-replace" : "would-add" };
const executable = agent === "codex" ? "codex" : "claude";
if (existing.exists) {
const removeArgs = agent === "codex" ? ["mcp", "remove", SERVER_NAME] : ["mcp", "remove", "--scope", "user", SERVER_NAME];
await requireSuccess(runner.run(executable, removeArgs), `${agent} MCP replacement cleanup`);
}
const addArgs = agent === "codex" ? ["mcp", "add", SERVER_NAME, "--", ...command] : ["mcp", "add", "--scope", "user", SERVER_NAME, "--", ...command];
await requireSuccess(runner.run(executable, addArgs), `${agent} MCP registration`);
return { agent, action };
}
async function probeCliAgent(agent, runner) {
const executable = agent === "codex" ? "codex" : "claude";
const args = agent === "codex" ? ["mcp", "get", SERVER_NAME, "--json"] : ["mcp", "get", SERVER_NAME];
const result = await runner.run(executable, args);
if (result.code === 0) return { exists: true, output: result.stdout };
const combined = `${result.stdout}
${result.stderr}`;
if (/not found|does not exist|no mcp server|not configured|unknown server/i.test(combined)) return { exists: false, output: combined };
throw new Error(`Could not inspect ${agent} MCP configuration: ${safeCommandError(result)}`);
}
function outputMatchesCommand(output, command) {
try {
const document = JSON.parse(output);
if (findCommand(document, command)) return true;
} catch {
}
return command.every((part) => output.includes(part));
}
function findCommand(value, command) {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some((item) => findCommand(item, command));
const record = value;
if (typeof record.command === "string" && Array.isArray(record.args)) {
const candidate = [record.command, ...record.args.filter((item) => typeof item === "string")];
if (candidate.length === command.length && candidate.every((item, index) => item === command[index])) return true;
}
return Object.values(record).some((item) => findCommand(item, command));
}
async function configureOpenCode(input, command) {
const filePath = input.opencodePath ?? defaultOpenCodePath(input.env);
const original = await readOptionalFile(filePath) ?? "{}\n";
const document = parse(original);
const existing = document?.mcp?.[SERVER_NAME];
const desired = { type: "local", command, enabled: true };
if (existing && existing.type === desired.type && existing.enabled === true && arraysEqual(existing.command, command)) {
return { agent: "opencode", action: "unchanged" };
}
if (existing && !input.force && !input.dryRun) {
const confirm = input.confirm ?? terminalConfirm;
if (!process.stdin.isTTY && !input.confirm) throw new Error(`opencode already has a different ${SERVER_NAME} MCP entry; use --force to replace it`);
if (!await confirm(`opencode already has a different ${SERVER_NAME} MCP entry. Replace it?`)) {
throw new Error("opencode MCP configuration was not changed");
}
}
if (input.dryRun) return { agent: "opencode", action: existing ? "would-replace" : "would-add" };
const updated = applyEdits(original, modify(original, ["mcp", SERVER_NAME], desired, {
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }
}));
await backupAndAtomicWrite(filePath, updated, await readOptionalFile(filePath) !== void 0);
return { agent: "opencode", action: existing ? "replaced" : "added" };
}
async function unconfigureOpenCode(input) {
const filePath = input.opencodePath ?? defaultOpenCodePath(input.env);
const original = await readOptionalFile(filePath);
if (original === void 0) return { agent: "opencode", action: "absent" };
const document = parse(original);
if (!document?.mcp || !(SERVER_NAME in document.mcp)) return { agent: "opencode", action: "absent" };
if (input.dryRun) return { agent: "opencode", action: "would-remove" };
const updated = applyEdits(original, modify(original, ["mcp", SERVER_NAME], void 0, {
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }
}));
await backupAndAtomicWrite(filePath, updated, true);
return { agent: "opencode", action: "removed" };
}
function defaultOpenCodePath(env = process.env) {
const base = env.XDG_CONFIG_HOME || (process.platform === "win32" ? path2.join(env.USERPROFILE || os2.homedir(), ".config") : path2.join(os2.homedir(), ".config"));
return path2.join(base, "opencode", "opencode.json");
}
async function backupAndAtomicWrite(filePath, updated, existed) {
await mkdir2(path2.dirname(filePath), { recursive: true });
if (existed) {
try {
await copyFile(filePath, `${filePath}.crafttable-mcp.backup`, constants.COPYFILE_EXCL);
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
}
const temporary = `${filePath}.${process.pid}.tmp`;
await writeFile2(temporary, updated, "utf8");
await rename2(temporary, filePath);
}
async function readOptionalFile(filePath) {
try {
return await readFile2(filePath, "utf8");
} catch (error) {
if (error.code === "ENOENT") return void 0;
throw error;
}
}
function arraysEqual(value, expected) {
return Array.isArray(value) && value.length === expected.length && value.every((item, index) => item === expected[index]);
}
function expandTarget(target) {
if (target === "all") return ["codex", "claude", "opencode"];
if (["codex", "claude", "opencode"].includes(target)) return [target];
throw new Error("Agent must be one of: codex, claude, opencode, all");
}
async function terminalConfirm(message) {
const readline = createInterface({ input: process.stdin, output: process.stderr });
try {
return /^y(es)?$/i.test((await readline.question(`${message} [y/N] `)).trim());
} finally {
readline.close();
}
}
async function requireSuccess(resultPromise, operation) {
const result = await resultPromise;
if (result.code !== 0) throw new Error(`${operation} failed: ${safeCommandError(result)}`);
}
function safeCommandError(result) {
return (result.stderr || result.stdout || `exit code ${result.code}`).trim();
}
var SpawnCommandRunner = class {
run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.setEncoding("utf8").on("data", (chunk) => {
stderr += String(chunk);
});
child.once("error", (error) => reject(new Error(`Could not run ${command}: ${errorMessage(error)}`)));
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
};
// src/bridge.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema
} from "@modelcontextprotocol/sdk/types.js";
// src/remote.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import open from "open";
// src/callbackServer.ts
import { createServer } from "node:http";
var OAuthCallbackServer = class {
constructor(port, validateState) {
this.port = port;
this.validateState = validateState;
void this.result.catch(() => void 0);
}
port;
validateState;
server;
resolveResult;
rejectResult;
result = new Promise((resolve, reject) => {
this.resolveResult = resolve;
this.rejectResult = reject;
});
async listen() {
if (this.server) throw new Error("OAuth callback server is already running");
this.server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://127.0.0.1:${this.port}`);
if (request.method !== "GET" || url.pathname !== "/oauth/callback") {
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
return;
}
const oauthError = url.searchParams.get("error");
if (oauthError) {
response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("OAuth login failed. Return to the terminal.");
this.rejectResult?.(new Error(`OAuth authorization failed: ${oauthError}`));
return;
}
if (!this.validateState(url.searchParams.get("state"))) {
response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("OAuth state did not match. Return to the terminal.");
this.rejectResult?.(new Error("OAuth callback state did not match"));
return;
}
const code = url.searchParams.get("code");
if (!code) {
response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("OAuth authorization code is missing.");
this.rejectResult?.(new Error("OAuth callback did not include an authorization code"));
return;
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end("<!doctype html><title>CraftTable MCP</title><p>Login complete. You can close this window.</p>");
this.resolveResult?.({ code });
});
await new Promise((resolve, reject) => {
const onError = (error) => reject(new Error(`Could not listen on OAuth callback port ${this.port}: ${error.message}`));
this.server.once("error", onError);
this.server.listen(this.port, "127.0.0.1", () => {
this.server.off("error", onError);
resolve();
});
});
const address = this.server.address();
if (!address || address.port !== this.port) throw new Error(`OAuth callback server did not bind port ${this.port}`);
}
async wait(timeoutMs = 10 * 60 * 1e3) {
let timeout;
try {
return await Promise.race([
this.result,
new Promise((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error("OAuth callback timed out")), timeoutMs);
})
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
async close() {
if (!this.server) return;
const server = this.server;
this.server = void 0;
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
};
// src/oauthProvider.ts
import { randomBytes, timingSafeEqual } from "node:crypto";
var GameCraftOAuthProvider = class {
constructor(options, tokenStore, discoveryStore, onRedirect, state = randomBytes(32).toString("base64url")) {
this.options = options;
this.tokenStore = tokenStore;
this.discoveryStore = discoveryStore;
this.onRedirect = onRedirect;
this.redirectUrl = new URL(`http://127.0.0.1:${options.callbackPort}/oauth/callback`);
this.clientMetadata = {
client_name: "CraftTable MCP CLI",
redirect_uris: [this.redirectUrl.toString()],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
scope: OAUTH_SCOPES
};
this.account = credentialAccount(options);
this.expectedState = state;
}
options;
tokenStore;
discoveryStore;
onRedirect;
redirectUrl;
clientMetadata;
account;
expectedState;
codeVerifierValue;
state() {
return this.expectedState;
}
validateState(value) {
if (!value) return false;
const expected = Buffer.from(this.expectedState);
const actual = Buffer.from(value);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
clientInformation() {
return { client_id: this.options.clientId };
}
tokens() {
return this.tokenStore.get(this.account);
}
saveTokens(tokens) {
return this.tokenStore.set(this.account, tokens);
}
redirectToAuthorization(url) {
return this.onRedirect(url);
}
saveCodeVerifier(codeVerifier) {
this.codeVerifierValue = codeVerifier;
}
codeVerifier() {
if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired");
return this.codeVerifierValue;
}
discoveryState() {
return this.discoveryStore.get(this.account);
}
saveDiscoveryState(state) {
return this.discoveryStore.set(this.account, state);
}
async invalidateCredentials(scope) {
if (scope === "all" || scope === "tokens") await this.tokenStore.delete(this.account);
if (scope === "all" || scope === "discovery") await this.discoveryStore.delete(this.account);
if (scope === "all" || scope === "verifier") this.codeVerifierValue = void 0;
}
};
// src/remote.ts
async function connectRemote(options, tokenStore, discoveryStore, serviceToken = process.env.CRAFTTABLE_MCP_TOKEN?.trim()) {
const tokens = await tokenStore.get(credentialAccount(options));
if (tokens) {
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, () => {
throw new Error("OAuth login is required; run `crafttable-mcp login`");
});
return connectWithTransport(options, new StreamableHTTPClientTransport(options.url, { authProvider: provider }), "oauth");
}
if (serviceToken) {
return connectWithTransport(options, new StreamableHTTPClientTransport(options.url, {
requestInit: { headers: { authorization: `Bearer ${serviceToken}` } }
}), "service-token");
}
throw new Error("Not logged in; run `crafttable-mcp login` or set CRAFTTABLE_MCP_TOKEN for the legacy service-account path");
}
async function loginRemote(options, tokenStore, discoveryStore, input = { browser: true }) {
const writeLine = input.writeLine ?? ((value) => process.stderr.write(`${value}
`));
let authorizationUrl;
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, async (url) => {
authorizationUrl = url;
if (!input.browser) {
writeLine(`Open this URL to log in:
${url.toString()}`);
return;
}
try {
await open(url.toString(), { wait: false });
writeLine("Opened the system browser for Game-CraftTable login.");
} catch (error) {
writeLine(`Could not open the browser (${errorMessage(error)}). Open this URL manually:
${url.toString()}`);
}
});
const callback = new OAuthCallbackServer(options.callbackPort, (state) => provider.validateState(state));
await callback.listen();
const firstClient = new Client({ name: "crafttable-mcp-cli", version: "0.1.0" });
const firstTransport = new StreamableHTTPClientTransport(options.url, { authProvider: provider });
try {
try {
await firstClient.connect(firstTransport);
const tools = await firstClient.listTools();
return { alreadyAuthenticated: true, toolCount: tools.tools.length };
} catch (error) {
if (!(error instanceof UnauthorizedError) && !authorizationUrl) throw error;
const { code } = await callback.wait(input.timeoutMs);
await firstTransport.finishAuth(code);
}
} finally {
await firstClient.close().catch(() => void 0);
await callback.close().catch(() => void 0);
}
const connection = await connectRemote(options, tokenStore, discoveryStore, "");
try {
const tools = await connection.client.listTools();
return { alreadyAuthenticated: false, toolCount: tools.tools.length };
} finally {
await connection.close();
}
}
async function logoutRemote(options, tokenStore, discoveryStore, localOnly, fetchFn = fetch) {
const account = credentialAccount(options);
const tokens = await tokenStore.get(account);
if (tokens && !localOnly) {
const endpoint = await discoverRevocationEndpoint(options.url, fetchFn);
await revokeTokens(endpoint, options.clientId, tokens, fetchFn);
}
await tokenStore.delete(account);
await discoveryStore.delete(account);
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens && !localOnly) };
}
async function connectWithTransport(options, transport, authentication) {
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.0" });
try {
await client.connect(transport);
} catch (error) {
await client.close().catch(() => void 0);
if (error instanceof UnauthorizedError) throw new Error("OAuth login is required; run `crafttable-mcp login`");
throw error;
}
return {
client,
transport,
authentication,
close: () => client.close()
};
}
async function discoverRevocationEndpoint(resource, fetchFn) {
const metadataUrl = new URL(`/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`, resource.origin);
const protectedResponse = await fetchFn(metadataUrl, { headers: { accept: "application/json" } });
if (!protectedResponse.ok) throw new Error(`OAuth protected-resource discovery returned HTTP ${protectedResponse.status}`);
const protectedMetadata = await protectedResponse.json();
const issuer = Array.isArray(protectedMetadata.authorization_servers) ? protectedMetadata.authorization_servers.find((value) => typeof value === "string") : void 0;
if (!issuer) throw new Error("OAuth protected-resource metadata has no authorization server");
const issuerUrl = new URL(issuer);
const candidates = [
new URL(`${issuerUrl.toString().replace(/\/$/, "")}/.well-known/openid-configuration`),
new URL(`/.well-known/openid-configuration${issuerUrl.pathname === "/" ? "" : issuerUrl.pathname}`, issuerUrl.origin)
];
for (const candidate of candidates) {
const response = await fetchFn(candidate, { headers: { accept: "application/json" } });
if (!response.ok) continue;
try {
const metadata = await response.json();
if (typeof metadata.revocation_endpoint === "string") return new URL(metadata.revocation_endpoint);
} catch {
}
}
throw new Error("OAuth authorization server does not advertise a revocation endpoint");
}
async function revokeTokens(endpoint, clientId, tokens, fetchFn) {
const candidates = [tokens.refresh_token ? { token: tokens.refresh_token, hint: "refresh_token" } : { token: tokens.access_token, hint: "access_token" }];
for (const value of candidates) {
const response = await fetchFn(endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
body: new URLSearchParams({ token: value.token, token_type_hint: value.hint, client_id: clientId })
});
if (!response.ok) throw new Error(`OAuth token revocation returned HTTP ${response.status}; local credentials were retained`);
}
}
// src/bridge.ts
async function serveBridge(options, tokenStore, discoveryStore, serviceToken = process.env.CRAFTTABLE_MCP_TOKEN?.trim()) {
const upstream = await connectRemote(options, tokenStore, discoveryStore, serviceToken);
if (upstream.authentication === "service-token") {
process.stderr.write("CraftTable MCP: using the legacy service-account token path.\n");
}
const server = createProxyServer(upstream.client);
const transport = new StdioServerTransport();
const close = async () => {
await server.close().catch(() => void 0);
await upstream.close().catch(() => void 0);
};
process.once("SIGINT", () => void close());
process.once("SIGTERM", () => void close());
try {
await server.connect(transport);
} catch (error) {
await close();
throw error;
}
}
function createProxyServer(upstream) {
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.0" }, {
capabilities: {
tools: {},
resources: {}
}
});
server.setRequestHandler(ListToolsRequestSchema, (request) => upstream.listTools(request.params));
server.setRequestHandler(CallToolRequestSchema, (request) => upstream.callTool(request.params));
server.setRequestHandler(ListResourcesRequestSchema, (request) => upstream.listResources(request.params));
server.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => upstream.listResourceTemplates(request.params));
server.setRequestHandler(ReadResourceRequestSchema, (request) => upstream.readResource(request.params));
return server;
}
// src/credentials.ts
var KEYRING_SERVICE = "CraftTable MCP";
var KeyringTokenStore = class {
async get(account) {
let serialized;
try {
const entry = await keyringEntry(account);
serialized = await entry.getPassword();
} catch (error) {
throw keyringError(error);
}
if (!serialized) return void 0;
try {
const value = JSON.parse(serialized);
if (!value.access_token || !value.token_type) throw new Error("missing token fields");
return value;
} catch {
throw new Error("The saved CraftTable MCP credential is invalid; run `crafttable-mcp logout --local-only` and log in again");
}
}
async set(account, tokens) {
try {
const entry = await keyringEntry(account);
await entry.setPassword(JSON.stringify(tokens));
} catch (error) {
throw keyringError(error);
}
}
async delete(account) {
try {
const entry = await keyringEntry(account);
await entry.deleteCredential();
} catch (error) {
const message = String(error?.message ?? error).toLowerCase();
if (message.includes("no entry") || message.includes("not found")) return;
throw keyringError(error);
}
}
};
async function keyringEntry(account) {
try {
const { AsyncEntry } = await import("@napi-rs/keyring");
return new AsyncEntry(KEYRING_SERVICE, account);
} catch (error) {
throw keyringError(error);
}
}
function keyringError(error) {
if (error instanceof Error && error.message.startsWith("The operating-system credential store is unavailable")) return error;
const detail = error instanceof Error ? error.message : String(error);
return new Error(`The operating-system credential store is unavailable (${detail}). Enable Windows Credential Manager, macOS Keychain, or a Secret Service provider, then retry; plaintext token storage is not supported.`);
}
// src/cli.ts
var program = new Command().name("crafttable-mcp").description("OAuth client and stdio bridge for CraftTable MCP").version("0.1.0");
withConnection(program.command("login").description("Log in through the system browser")).option("--no-browser", "print the authorization URL instead of opening it").option("--timeout <milliseconds>", "OAuth callback timeout", "600000").action(async (flags) => {
const options = connectionOptions(flags);
const timeoutMs = positiveInteger(flags.timeout, "OAuth callback timeout");
const result = await loginRemote(options, new KeyringTokenStore(), new DiscoveryStore(), { browser: flags.browser, timeoutMs });
printJson({ loggedIn: true, alreadyAuthenticated: result.alreadyAuthenticated, server: options.url.toString(), tools: result.toolCount });
});
withConnection(program.command("status").description("Check saved login state and remote MCP connectivity")).action(async (flags) => {
const options = connectionOptions(flags);
const tokens = await new KeyringTokenStore().get(credentialAccount(options));
const connection = await connectRemote(options, new KeyringTokenStore(), new DiscoveryStore());
try {
const tools = await connection.client.listTools();
printJson({
loggedIn: Boolean(tokens),
authentication: connection.authentication,
connected: true,
server: options.url.toString(),
tools: tools.tools.length
});
} finally {
await connection.close();
}
});
withConnection(program.command("logout").description("Revoke OAuth tokens and remove the local credential")).option("--local-only", "remove local credentials without contacting the authorization server").action(async (flags) => {
const options = connectionOptions(flags);
const result = await logoutRemote(options, new KeyringTokenStore(), new DiscoveryStore(), Boolean(flags.localOnly));
printJson({ loggedIn: false, credentialRemoved: result.hadCredential, revoked: result.revoked, localOnly: Boolean(flags.localOnly) });
});
withConnection(program.command("tools").description("List remote MCP tools")).action(async (flags) => withClient(connectionOptions(flags), async (client) => printJson(await client.listTools())));
withConnection(program.command("resources").description("List remote MCP resources and resource templates")).action(async (flags) => withClient(connectionOptions(flags), async (client) => printJson({
resources: (await client.listResources()).resources,
resourceTemplates: (await client.listResourceTemplates()).resourceTemplates
})));
withConnection(program.command("read <uri>").description("Read an MCP resource")).action(async (uri, flags) => withClient(connectionOptions(flags), async (client) => printJson(await client.readResource({ uri }))));
withConnection(program.command("call <tool> [json]").description("Call an MCP tool")).action(async (tool, json, flags) => withClient(connectionOptions(flags), async (client) => {
printJson(await client.callTool({ name: tool, arguments: parseObject(json ?? "{}") }));
}));
withConnection(program.command("serve").description("Run the local stdio bridge")).action(async (flags) => {
await serveBridge(connectionOptions(flags), new KeyringTokenStore(), new DiscoveryStore());
});
for (const operation of ["configure", "unconfigure"]) {
withConnection(program.command(`${operation} <agent>`).description(`${operation === "configure" ? "Add" : "Remove"} the stdio bridge in Codex, Claude Code, or OpenCode`)).option("--dry-run", "show the planned changes without writing").option("--force", "replace a conflicting entry without prompting").action(async (agent, flags) => {
const cliEntry = fileURLToPath(import.meta.url);
if (!cliEntry.endsWith(".js")) throw new Error("Agent configuration requires the built CLI; run `npm --prefix apps/mcp-client run build` first");
const input = {
target: agent,
options: connectionOptions(flags),
cliEntry,
dryRun: Boolean(flags.dryRun),
force: Boolean(flags.force)
};
const result = operation === "configure" ? await configureAgents(input) : await unconfigureAgents(input);
printJson({ result });
});
}
function withConnection(command) {
return command.option("--url <url>", "MCP Streamable HTTP URL").option("--client-id <id>", "pre-registered OAuth public client ID").option("--callback-port <port>", "fixed localhost OAuth callback port");
}
function connectionOptions(flags) {
return resolveClientOptions(flags);
}
async function withClient(options, action) {
const connection = await connectRemote(options, new KeyringTokenStore(), new DiscoveryStore());
try {
await action(connection.client);
} finally {
await connection.close();
}
}
function parseObject(raw) {
let value;
try {
value = JSON.parse(raw);
} catch {
throw new Error("Tool arguments must be a JSON object");
}
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("Tool arguments must be a JSON object");
return value;
}
function positiveInteger(value, name) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
return parsed;
}
function printJson(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}
`);
}
program.parseAsync().catch((error) => {
process.stderr.write(`CraftTable MCP failed: ${error instanceof Error ? error.message : String(error)}
`);
process.exitCode = 1;
});
//# sourceMappingURL=cli.js.map
+7
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -22,7 +22,6 @@
"dev": "tsx src/cli.ts", "dev": "tsx src/cli.ts",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"build": "npm run typecheck && esbuild src/cli.ts --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/cli.js", "build": "npm run typecheck && esbuild src/cli.ts --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/cli.js",
"prepare": "npm run build",
"test": "tsx --test test/*.test.ts" "test": "tsx --test test/*.test.ts"
}, },
"dependencies": { "dependencies": {