Files

1067 lines
46 KiB
JavaScript

#!/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("<!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}`);
}
/**
* 等待有效回调或达到配置的超时时间。
*
* @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 <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 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 <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