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