#!/usr/bin/env node // src/cli.ts import { fileURLToPath } from "node:url"; import { Command } from "commander"; // src/agents.ts import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { copyFile, cp, mkdir as mkdir2, readFile as readFile2, readdir, rename as rename2, rm, stat, 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"; import spawn from "cross-spawn"; // 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"); } /** * @param account - 稳定账户标识。 * @returns 账户的缓存发现状态;不存在时返回 `undefined`。 */ async get(account) { return (await this.read()).entries[account]; } /** * 使用原子文件替换持久化账户的发现状态。 * * @param account - 稳定账户标识。 * @param state - MCP OAuth 客户端提供的发现状态。 */ async set(account, state) { const document = await this.read(); document.entries[account] = state; await atomicWriteJson(this.filePath, document); } /** @param account - 要删除其状态的稳定账户标识。 */ 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) { const mcpResult = agent === "opencode" ? await configureOpenCode(input, command) : await configureCliAgent(agent, command, input, runner); const skillAction = await configureAgentSkill(agent, input); results.push({ ...mcpResult, skillAction }); } return results; } async function unconfigureAgents(input) { const agents = expandTarget(input.target); const runner = input.runner ?? new SpawnCommandRunner(); const results = []; for (const agent of agents) { let mcpResult; if (agent === "opencode") { mcpResult = await unconfigureOpenCode(input); } else { mcpResult = await unconfigureCliAgent(agent, input, runner); } const skillAction = await unconfigureAgentSkill(agent, input); results.push({ ...mcpResult, skillAction }); } return results; } async function unconfigureCliAgent(agent, input, runner) { const executable = agent === "codex" ? "codex" : "claude"; const existing = await probeCliAgent(agent, runner); if (!existing.exists) return { agent, action: "absent" }; if (input.dryRun) return { agent, action: "would-remove" }; const args = agent === "codex" ? ["mcp", "remove", SERVER_NAME] : ["mcp", "remove", "--scope", "user", SERVER_NAME]; await requireSuccess(runner.run(executable, args), `${agent} MCP removal`); return { agent, action: "removed" }; } 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"); } function bundledSkillPath(cliEntry) { return path2.resolve(path2.dirname(cliEntry), "..", "skills", SERVER_NAME); } function defaultSkillPath(agent, env = process.env) { const home = env.USERPROFILE || env.HOME || os2.homedir(); if (agent === "codex") return path2.join(env.CODEX_HOME || path2.join(home, ".codex"), "skills", SERVER_NAME); if (agent === "claude") return path2.join(home, ".claude", "skills", SERVER_NAME); return path2.join(path2.dirname(defaultOpenCodePath(env)), "skills", SERVER_NAME); } async function configureAgentSkill(agent, input) { const source = input.skillSource ?? bundledSkillPath(input.cliEntry); const destination = input.skillPaths?.[agent] ?? defaultSkillPath(agent, input.env); const sourceFiles = await readSkillDirectory(source, true); if (!sourceFiles) throw new Error(`Bundled Skill directory is missing: ${source}`); const existingFiles = await readSkillDirectory(destination, false); if (existingFiles && skillFilesEqual(existingFiles, sourceFiles)) return "unchanged"; if (existingFiles && !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} Skill; use --force to replace it`); if (!await confirm(`${agent} already has a different ${SERVER_NAME} Skill. Replace it?`)) { throw new Error(`${agent} Skill configuration was not changed`); } } if (input.dryRun) return existingFiles ? "would-replace" : "would-install"; if (existingFiles) await backupSkillDirectory(destination); await replaceSkillDirectory(source, destination, Boolean(existingFiles)); return existingFiles ? "replaced" : "installed"; } async function unconfigureAgentSkill(agent, input) { const source = input.skillSource ?? bundledSkillPath(input.cliEntry); const destination = input.skillPaths?.[agent] ?? defaultSkillPath(agent, input.env); const existingFiles = await readSkillDirectory(destination, false); if (!existingFiles) return "absent"; const sourceFiles = await readSkillDirectory(source, true); if (!sourceFiles) throw new Error(`Bundled Skill directory is missing: ${source}`); const managed = skillFilesEqual(existingFiles, sourceFiles); if (!managed && !input.force) return input.dryRun ? "would-preserve" : "preserved"; if (input.dryRun) return "would-remove"; if (!managed) await backupSkillDirectory(destination); await rm(destination, { recursive: true, force: true }); return "removed"; } async function readSkillDirectory(directory, required) { const files = /* @__PURE__ */ new Map(); const walk = async (current, relative) => { const entries = await readdir(current, { withFileTypes: true }); entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const entryPath = path2.join(current, entry.name); const entryRelative = relative ? path2.join(relative, entry.name) : entry.name; if (entry.isDirectory()) { await walk(entryPath, entryRelative); } else if (entry.isFile()) { files.set(entryRelative, await readFile2(entryPath)); } else { throw new Error(`Skill directory contains an unsupported entry: ${entryPath}`); } } }; try { await walk(directory, ""); } catch (error) { if (!required && error.code === "ENOENT") return void 0; throw error; } if (!files.has("SKILL.md")) throw new Error(`Skill directory is missing SKILL.md: ${directory}`); return files; } function skillFilesEqual(left, right) { if (left.size !== right.size) return false; return [...left].every(([name, value]) => right.get(name)?.equals(value) === true); } async function backupSkillDirectory(directory) { const backup = `${directory}.crafttable-mcp.backup`; try { await cp(directory, backup, { recursive: true, force: false, errorOnExist: true }); } catch (error) { if (error.code !== "EEXIST") throw error; } return backup; } async function replaceSkillDirectory(source, destination, existed) { await mkdir2(path2.dirname(destination), { recursive: true }); const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`; await cp(source, temporary, { recursive: true, force: false, errorOnExist: true }); try { if (existed) await rm(destination, { recursive: true, force: true }); await rename2(temporary, destination); } catch (error) { if (existed && !await pathExists(destination)) { const backup = `${destination}.crafttable-mcp.backup`; if (await pathExists(backup)) await cp(backup, destination, { recursive: true }); } throw error; } finally { await rm(temporary, { recursive: true, force: true }); } } async function pathExists(filePath) { try { await stat(filePath); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; } } 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 { /** * @param command - 可执行文件名称或路径。 * @param args - 不经 shell 解释而传递的原始参数列表。 * @returns 捕获的退出码、标准输出与标准错误。 * @throws 子进程无法启动时抛出。 */ 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; }); /** * 在配置的固定回调端口监听 `127.0.0.1`。 * * @returns 端口绑定完成后解析的 Promise。 * @throws 服务已运行或端口无法绑定时抛出。 */ 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}`); } /** * 等待有效回调或达到配置的超时时间。 * * @param timeoutMs - 最大等待时间(毫秒)。 * @returns 经过验证的回调中的授权码。 * @throws 授权失败、状态验证失败、缺少授权码或等待超时时抛出。 */ 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); } } /** * 停止接收回调;对已停止的服务调用此方法不会产生任何操作。 * * @returns HTTP 服务关闭后解析的 Promise。 */ 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 CraftTableOAuthProvider = class { /** * @param options - 远端端点与已注册公共客户端设置。 * @param tokenStore - OAuth 令牌安全持久化接口。 * @param discoveryStore - 授权服务器发现状态缓存。 * @param onRedirect - 打开或显示授权 URL 的处理器。 * @param state - 预期回调状态,可注入以实现确定性测试。 */ 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; /** @returns 授权回调必须携带的 state 值。 */ state() { return this.expectedState; } /** * 在长度一致时以常量时间比较回调 state。 * * @param value - 从 loopback 回调收到的 state。 * @returns 回调是否属于本次授权尝试。 */ 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); } /** @returns MCP SDK 使用的静态公共客户端注册信息。 */ clientInformation() { return { client_id: this.options.clientId }; } /** @returns 当前服务/客户端组合已保存的 OAuth 令牌。 */ tokens() { return this.tokenStore.get(this.account); } /** 持久化刷新或新签发的 OAuth 令牌集。 */ saveTokens(tokens) { return this.tokenStore.set(this.account, tokens); } /** 将授权 URL 交给 CLI 的浏览器或手动登录处理器。 */ redirectToAuthorization(url) { return this.onRedirect(url); } /** 在本次登录尝试期间将 PKCE verifier 保存在内存中。 */ saveCodeVerifier(codeVerifier) { this.codeVerifierValue = codeVerifier; } /** * @returns 为当前授权尝试保存的 PKCE verifier。 * @throws 尚未生成 verifier 或其已失效时抛出。 */ codeVerifier() { if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired"); return this.codeVerifierValue; } /** @returns 当前服务/客户端组合的 OAuth 发现缓存。 */ discoveryState() { return this.discoveryStore.get(this.account); } /** 在凭据存储之外持久化 OAuth 发现状态。 */ saveDiscoveryState(state) { return this.discoveryStore.set(this.account, state); } /** * 使 MCP OAuth 客户端指定的凭据材料失效。 * * @param scope - 要清理的凭据类别。 */ 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 CraftTableOAuthProvider(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} `)); await requireOAuthProtectedResourceMetadata(options.url); let authorizationUrl; const provider = new CraftTableOAuthProvider(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 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.3" }); 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); if (localOnly) { const hadCredential = await tokenStore.delete(account); await discoveryStore.delete(account); return { hadCredential, revoked: false }; } const tokens = await tokenStore.get(account); if (tokens) { 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) }; } async function connectWithTransport(options, transport, authentication) { const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.3" }); 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 protectedMetadata = await requireOAuthProtectedResourceMetadata(resource, fetchFn); 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 requireOAuthProtectedResourceMetadata(resource, fetchFn = fetch) { const metadataUrl = new URL(`/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}`, resource.origin); let response; try { response = await fetchFn(metadataUrl, { headers: { accept: "application/json" } }); } catch (error) { throw new Error(`Could not reach OAuth protected-resource metadata at ${metadataUrl}: ${errorMessage(error)}`); } const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; if (!response.ok) { throw new Error(`OAuth is not enabled for ${resource}; protected-resource metadata at ${metadataUrl} returned HTTP ${response.status}`); } if (!contentType.includes("application/json")) { throw new Error(`OAuth is not enabled for ${resource}; protected-resource metadata at ${metadataUrl} returned ${contentType || "an unknown content type"} instead of JSON`); } try { return await response.json(); } catch { throw new Error(`OAuth protected-resource metadata at ${metadataUrl} is not valid JSON`); } } 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.3" }, { 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 import { randomUUID as randomUUID2 } from "node:crypto"; var KEYRING_SERVICE = "CraftTable MCP"; var KEYRING_CHUNK_SIZE = 1e3; var KeyringTokenStore = class { constructor(entryFactory = keyringEntry) { this.entryFactory = entryFactory; } entryFactory; /** * @param account - 稳定凭据账户标识。 * @returns 解码后的令牌集;不存在凭据时返回 `undefined`。 * @throws 凭据存储不可用或已存数据无效时抛出。 */ async get(account) { let serialized; try { serialized = await this.readPassword(account); } catch (error) { throw keyringError(error); } if (!serialized) return void 0; const manifest = parseChunkManifest(serialized); if (manifest) { try { const chunks = await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.readPassword(chunkAccount(account, manifest.generation, index)))); if (chunks.some((chunk) => chunk === void 0)) throw new Error("missing credential chunk"); serialized = Buffer.from(chunks.join(""), "base64").toString("utf8"); } catch (error) { if (error instanceof Error && error.message === "missing credential chunk") throw invalidCredentialError(); throw keyringError(error); } } try { const value = JSON.parse(serialized); if (!value.access_token || !value.token_type) throw new Error("missing token fields"); return value; } catch { throw invalidCredentialError(); } } /** * 原子发布新一代分块,并删除上一代分块。 * * @param account - 稳定凭据账户标识。 * @param tokens - 要安全存储的 OAuth 令牌。 * @throws 任一凭据存储操作失败时抛出。 */ async set(account, tokens) { const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64"); const chunks = splitCredential(encoded); const generation = randomUUID2().replaceAll("-", ""); const manifest = { version: 2, generation, chunks: chunks.length }; let previous; const writtenAccounts = []; try { previous = await this.readPassword(account); for (const [index, chunk] of chunks.entries()) { const partAccount = chunkAccount(account, generation, index); await (await this.entryFactory(partAccount)).setPassword(chunk); writtenAccounts.push(partAccount); } await (await this.entryFactory(account)).setPassword(JSON.stringify(manifest)); } catch (error) { await Promise.allSettled(writtenAccounts.map((partAccount) => this.deletePassword(partAccount))); throw keyringError(error); } const previousManifest = previous && parseChunkManifest(previous); if (previousManifest) { await Promise.allSettled(Array.from({ length: previousManifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, previousManifest.generation, index)))); } } /** * 删除账户清单及其引用的全部分块。 * * @param account - 稳定凭据账户标识。 * @throws 无法访问凭据存储时抛出。 */ async delete(account) { try { const serialized = await this.readPassword(account); const manifest = serialized ? parseChunkManifest(serialized) : void 0; await this.deletePassword(account); if (manifest) { await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, manifest.generation, index)))); } return serialized !== void 0; } catch (error) { throw keyringError(error); } } async readPassword(account) { try { return await (await this.entryFactory(account)).getPassword() ?? void 0; } catch (error) { if (isMissingCredential(error)) return void 0; throw error; } } async deletePassword(account) { try { await (await this.entryFactory(account)).deleteCredential(); } catch (error) { if (!isMissingCredential(error)) throw error; } } }; function splitCredential(value) { const chunks = []; for (let offset = 0; offset < value.length; offset += KEYRING_CHUNK_SIZE) { chunks.push(value.slice(offset, offset + KEYRING_CHUNK_SIZE)); } return chunks.length ? chunks : [""]; } function chunkAccount(account, generation, index) { return `${account}:v2:${generation}:${index}`; } function parseChunkManifest(value) { try { const parsed = JSON.parse(value); if (parsed.version !== 2 || typeof parsed.generation !== "string" || !/^[0-9a-f]{32}$/.test(parsed.generation) || !Number.isInteger(parsed.chunks) || (parsed.chunks ?? 0) < 1 || (parsed.chunks ?? 0) > 100) return void 0; return parsed; } catch { return void 0; } } function isMissingCredential(error) { const message = String(error?.message ?? error).toLowerCase(); return message.includes("no entry") || message.includes("not found"); } function invalidCredentialError() { return new Error("The saved CraftTable MCP credential is invalid; run `crafttable-mcp logout --local-only` and log in again"); } 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.3"); 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 and CraftTable Skill 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 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