#!/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("CraftTable MCP

Login complete. You can close this window.

"); 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 ", "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 ").description("Read an MCP resource")).action(async (uri, flags) => withClient(connectionOptions(flags), async (client) => printJson(await client.readResource({ uri })))); withConnection(program.command("call [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} `).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 ", "MCP Streamable HTTP URL").option("--client-id ", "pre-registered OAuth public client ID").option("--callback-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