feat: add CraftTable MCP OAuth CLI
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.tgz
|
||||
@@ -0,0 +1,37 @@
|
||||
# CraftTable MCP CLI
|
||||
|
||||
`@game-crafttable/mcp-client` provides the `crafttable-mcp` command and the compatibility alias `crafttable-mcp-client`.
|
||||
|
||||
## Quick install and configure
|
||||
|
||||
The repository is private, so first make sure Git Credential Manager can access `git.crash.work`, then run this in PowerShell:
|
||||
|
||||
```powershell
|
||||
npm install --global 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.
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
crafttable-mcp login
|
||||
crafttable-mcp status
|
||||
crafttable-mcp tools
|
||||
crafttable-mcp call list_spaces '{}'
|
||||
crafttable-mcp configure all
|
||||
```
|
||||
|
||||
`login` uses Authorization Code with PKCE and saves OAuth tokens in the operating-system credential store. Tokens are isolated by MCP server URL and OAuth client ID. The CLI never falls back to a plaintext token file.
|
||||
|
||||
`serve` is a stdio MCP bridge for Codex, Claude Code, and OpenCode. It writes MCP JSON-RPC only to stdout. If no OAuth credential exists, it can use the legacy `CRAFTTABLE_MCP_TOKEN` environment variable and reports that path only on stderr.
|
||||
|
||||
Connection defaults:
|
||||
|
||||
- URL: `https://crafttable.crash.work/mcp`
|
||||
- OAuth client ID: `crafttable-mcp-cli`
|
||||
- callback: `http://127.0.0.1:48321/oauth/callback`
|
||||
|
||||
Override them with `--url`, `--client-id`, and `--callback-port`, or with `CRAFTTABLE_MCP_URL`, `CRAFTTABLE_MCP_OAUTH_CLIENT_ID`, and `CRAFTTABLE_MCP_OAUTH_CALLBACK_PORT`.
|
||||
|
||||
Only HTTPS remote URLs are accepted. Plain HTTP is limited to `localhost`, `127.0.0.1`, and `::1` development servers.
|
||||
Generated
+2503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@game-crafttable/mcp-client",
|
||||
"version": "0.1.0",
|
||||
"description": "OAuth CLI and stdio bridge for the CraftTable MCP server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.crash.work/cneicy/crafttable-mcp-client.git"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"crafttable-mcp": "dist/cli.js",
|
||||
"crafttable-mcp-client": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/cli.ts",
|
||||
"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",
|
||||
"prepare": "npm run build",
|
||||
"test": "tsx --test test/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "latest",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"commander": "^14.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"open": "^10.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"esbuild": "latest",
|
||||
"tsx": "latest",
|
||||
"typescript": "latest"
|
||||
},
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { applyEdits, modify, parse } from "jsonc-parser";
|
||||
import type { ClientOptions } from "./config.js";
|
||||
import { errorMessage } from "./config.js";
|
||||
|
||||
export const SERVER_NAME = "crafttable";
|
||||
export type AgentName = "codex" | "claude" | "opencode";
|
||||
export type AgentTarget = AgentName | "all";
|
||||
|
||||
export type CommandResult = { code: number; stdout: string; stderr: string };
|
||||
export interface CommandRunner {
|
||||
run(command: string, args: string[]): Promise<CommandResult>;
|
||||
}
|
||||
|
||||
export type ConfigureInput = {
|
||||
target: AgentTarget;
|
||||
options: ClientOptions;
|
||||
cliEntry: string;
|
||||
nodePath?: string;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runner?: CommandRunner;
|
||||
confirm?: (message: string) => Promise<boolean>;
|
||||
opencodePath?: string;
|
||||
};
|
||||
|
||||
export type ConfigureResult = {
|
||||
agent: AgentName;
|
||||
action: "added" | "replaced" | "removed" | "unchanged" | "absent" | "would-add" | "would-replace" | "would-remove";
|
||||
};
|
||||
|
||||
export async function configureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
|
||||
const agents = expandTarget(input.target);
|
||||
const command = launchCommand(input.options, input.cliEntry, input.nodePath ?? process.execPath);
|
||||
const runner = input.runner ?? new SpawnCommandRunner();
|
||||
const results: ConfigureResult[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
export async function unconfigureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
|
||||
const agents = expandTarget(input.target);
|
||||
const runner = input.runner ?? new SpawnCommandRunner();
|
||||
const results: ConfigureResult[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
export function launchCommand(options: ClientOptions, cliEntry: string, nodePath: string): string[] {
|
||||
return [
|
||||
path.resolve(nodePath),
|
||||
path.resolve(cliEntry),
|
||||
"serve",
|
||||
"--url", options.url.toString(),
|
||||
"--client-id", options.clientId,
|
||||
"--callback-port", String(options.callbackPort),
|
||||
];
|
||||
}
|
||||
|
||||
async function configureCliAgent(
|
||||
agent: "codex" | "claude",
|
||||
command: string[],
|
||||
input: ConfigureInput,
|
||||
runner: CommandRunner,
|
||||
): Promise<ConfigureResult> {
|
||||
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: "codex" | "claude", runner: CommandRunner): Promise<{ exists: boolean; output: string }> {
|
||||
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}\n${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: string, command: string[]): boolean {
|
||||
try {
|
||||
const document = JSON.parse(output) as unknown;
|
||||
if (findCommand(document, command)) return true;
|
||||
} catch {
|
||||
// Claude currently returns a human-readable record.
|
||||
}
|
||||
return command.every((part) => output.includes(part));
|
||||
}
|
||||
|
||||
function findCommand(value: unknown, command: string[]): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (Array.isArray(value)) return value.some((item) => findCommand(item, command));
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.command === "string" && Array.isArray(record.args)) {
|
||||
const candidate = [record.command, ...record.args.filter((item): item is string => 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: ConfigureInput, command: string[]): Promise<ConfigureResult> {
|
||||
const filePath = input.opencodePath ?? defaultOpenCodePath(input.env);
|
||||
const original = await readOptionalFile(filePath) ?? "{}\n";
|
||||
const document = parse(original) as { mcp?: Record<string, unknown> } | undefined;
|
||||
const existing = document?.mcp?.[SERVER_NAME] as { type?: unknown; command?: unknown; enabled?: unknown } | undefined;
|
||||
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) !== undefined);
|
||||
return { agent: "opencode", action: existing ? "replaced" : "added" };
|
||||
}
|
||||
|
||||
async function unconfigureOpenCode(input: ConfigureInput): Promise<ConfigureResult> {
|
||||
const filePath = input.opencodePath ?? defaultOpenCodePath(input.env);
|
||||
const original = await readOptionalFile(filePath);
|
||||
if (original === undefined) return { agent: "opencode", action: "absent" };
|
||||
const document = parse(original) as { mcp?: Record<string, unknown> } | undefined;
|
||||
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], undefined, {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
|
||||
}));
|
||||
await backupAndAtomicWrite(filePath, updated, true);
|
||||
return { agent: "opencode", action: "removed" };
|
||||
}
|
||||
|
||||
export function defaultOpenCodePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const base = env.XDG_CONFIG_HOME || (process.platform === "win32"
|
||||
? path.join(env.USERPROFILE || os.homedir(), ".config")
|
||||
: path.join(os.homedir(), ".config"));
|
||||
return path.join(base, "opencode", "opencode.json");
|
||||
}
|
||||
|
||||
async function backupAndAtomicWrite(filePath: string, updated: string, existed: boolean): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
if (existed) {
|
||||
try {
|
||||
await copyFile(filePath, `${filePath}.crafttable-mcp.backup`, constants.COPYFILE_EXCL);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
}
|
||||
const temporary = `${filePath}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, updated, "utf8");
|
||||
await rename(temporary, filePath);
|
||||
}
|
||||
|
||||
async function readOptionalFile(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function arraysEqual(value: unknown, expected: string[]): boolean {
|
||||
return Array.isArray(value) && value.length === expected.length && value.every((item, index) => item === expected[index]);
|
||||
}
|
||||
|
||||
function expandTarget(target: AgentTarget): AgentName[] {
|
||||
if (target === "all") return ["codex", "claude", "opencode"];
|
||||
if (["codex", "claude", "opencode"].includes(target)) return [target as AgentName];
|
||||
throw new Error("Agent must be one of: codex, claude, opencode, all");
|
||||
}
|
||||
|
||||
async function terminalConfirm(message: string): Promise<boolean> {
|
||||
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: Promise<CommandResult>, operation: string): Promise<void> {
|
||||
const result = await resultPromise;
|
||||
if (result.code !== 0) throw new Error(`${operation} failed: ${safeCommandError(result)}`);
|
||||
}
|
||||
|
||||
function safeCommandError(result: CommandResult): string {
|
||||
return (result.stderr || result.stdout || `exit code ${result.code}`).trim();
|
||||
}
|
||||
|
||||
export class SpawnCommandRunner implements CommandRunner {
|
||||
run(command: string, args: string[]): Promise<CommandResult> {
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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";
|
||||
import type { ClientOptions } from "./config.js";
|
||||
import { DiscoveryStore } from "./config.js";
|
||||
import type { TokenStore } from "./credentials.js";
|
||||
import { connectRemote } from "./remote.js";
|
||||
|
||||
export async function serveBridge(
|
||||
options: ClientOptions,
|
||||
tokenStore: TokenStore,
|
||||
discoveryStore: DiscoveryStore,
|
||||
serviceToken = process.env.CRAFTTABLE_MCP_TOKEN?.trim(),
|
||||
): Promise<void> {
|
||||
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 (): Promise<void> => {
|
||||
await server.close().catch(() => undefined);
|
||||
await upstream.close().catch(() => undefined);
|
||||
};
|
||||
process.once("SIGINT", () => void close());
|
||||
process.once("SIGTERM", () => void close());
|
||||
try {
|
||||
await server.connect(transport);
|
||||
} catch (error) {
|
||||
await close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createProxyServer(upstream: {
|
||||
listTools: (params?: { cursor?: string }) => Promise<unknown>;
|
||||
callTool: (params: { name: string; arguments?: Record<string, unknown> }) => Promise<unknown>;
|
||||
listResources: (params?: { cursor?: string }) => Promise<unknown>;
|
||||
listResourceTemplates: (params?: { cursor?: string }) => Promise<unknown>;
|
||||
readResource: (params: { uri: string }) => Promise<unknown>;
|
||||
}): Server {
|
||||
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.0" }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
},
|
||||
});
|
||||
server.setRequestHandler(ListToolsRequestSchema, (request) => upstream.listTools(request.params) as never);
|
||||
server.setRequestHandler(CallToolRequestSchema, (request) => upstream.callTool(request.params) as never);
|
||||
server.setRequestHandler(ListResourcesRequestSchema, (request) => upstream.listResources(request.params) as never);
|
||||
server.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => upstream.listResourceTemplates(request.params) as never);
|
||||
server.setRequestHandler(ReadResourceRequestSchema, (request) => upstream.readResource(request.params) as never);
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export type OAuthCallback = { code: string };
|
||||
|
||||
export class OAuthCallbackServer {
|
||||
private server?: Server;
|
||||
private resolveResult?: (value: OAuthCallback) => void;
|
||||
private rejectResult?: (reason: Error) => void;
|
||||
private readonly result = new Promise<OAuthCallback>((resolve, reject) => {
|
||||
this.resolveResult = resolve;
|
||||
this.rejectResult = reject;
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly port: number,
|
||||
private readonly validateState: (state: string | null) => boolean,
|
||||
) {
|
||||
// A very fast browser callback can arrive before loginRemote starts awaiting it.
|
||||
// Keep rejection handled while preserving the original promise for wait().
|
||||
void this.result.catch(() => undefined);
|
||||
}
|
||||
|
||||
async listen(): Promise<void> {
|
||||
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<void>((resolve, reject) => {
|
||||
const onError = (error: 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() as AddressInfo | null;
|
||||
if (!address || address.port !== this.port) throw new Error(`OAuth callback server did not bind port ${this.port}`);
|
||||
}
|
||||
|
||||
async wait(timeoutMs = 10 * 60 * 1000): Promise<OAuthCallback> {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.result,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error("OAuth callback timed out")), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!this.server) return;
|
||||
const server = this.server;
|
||||
this.server = undefined;
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { configureAgents, type AgentTarget, unconfigureAgents } from "./agents.js";
|
||||
import { serveBridge } from "./bridge.js";
|
||||
import { type ClientOptions, credentialAccount, DiscoveryStore, resolveClientOptions } from "./config.js";
|
||||
import { KeyringTokenStore } from "./credentials.js";
|
||||
import { connectRemote, loginRemote, logoutRemote } from "./remote.js";
|
||||
|
||||
type ConnectionFlags = { url?: string; clientId?: string; callbackPort?: string };
|
||||
|
||||
const 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: ConnectionFlags & { browser: boolean; timeout: string }) => {
|
||||
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: ConnectionFlags) => {
|
||||
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: ConnectionFlags & { localOnly?: boolean }) => {
|
||||
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: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => printJson(await client.listTools())));
|
||||
|
||||
withConnection(program.command("resources").description("List remote MCP resources and resource templates"))
|
||||
.action(async (flags: ConnectionFlags) => 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: string, flags: ConnectionFlags) => withClient(connectionOptions(flags), async (client) => printJson(await client.readResource({ uri }))));
|
||||
|
||||
withConnection(program.command("call <tool> [json]").description("Call an MCP tool"))
|
||||
.action(async (tool: string, json: string | undefined, flags: ConnectionFlags) => 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: ConnectionFlags) => {
|
||||
await serveBridge(connectionOptions(flags), new KeyringTokenStore(), new DiscoveryStore());
|
||||
});
|
||||
|
||||
for (const operation of ["configure", "unconfigure"] as const) {
|
||||
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: AgentTarget, flags: ConnectionFlags & { dryRun?: boolean; force?: boolean }) => {
|
||||
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: Command): 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: ConnectionFlags): ClientOptions {
|
||||
return resolveClientOptions(flags);
|
||||
}
|
||||
|
||||
async function withClient(options: ClientOptions, action: (client: Awaited<ReturnType<typeof connectRemote>>["client"]) => Promise<void>): Promise<void> {
|
||||
const connection = await connectRemote(options, new KeyringTokenStore(), new DiscoveryStore());
|
||||
try {
|
||||
await action(connection.client);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
function parseObject(raw: string): Record<string, unknown> {
|
||||
let value: unknown;
|
||||
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 as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string, name: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printJson(value: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
program.parseAsync().catch((error: unknown) => {
|
||||
process.stderr.write(`CraftTable MCP failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
|
||||
export const DEFAULT_MCP_URL = "https://crafttable.crash.work/mcp";
|
||||
export const DEFAULT_CLIENT_ID = "crafttable-mcp-cli";
|
||||
export const DEFAULT_CALLBACK_PORT = 48321;
|
||||
export const OAUTH_SCOPES = "openid profile email offline_access";
|
||||
|
||||
export type ClientOptions = {
|
||||
url: URL;
|
||||
clientId: string;
|
||||
callbackPort: number;
|
||||
};
|
||||
|
||||
export function resolveClientOptions(input: {
|
||||
url?: string;
|
||||
clientId?: string;
|
||||
callbackPort?: string | number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): ClientOptions {
|
||||
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 };
|
||||
}
|
||||
|
||||
export function validateMcpUrl(value: string): URL {
|
||||
let url: 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;
|
||||
}
|
||||
|
||||
export function credentialAccount(options: Pick<ClientOptions, "url" | "clientId">): string {
|
||||
return createHash("sha256").update(`${options.url.toString()}\0${options.clientId}`).digest("hex");
|
||||
}
|
||||
|
||||
export function platformConfigDir(env: NodeJS.ProcessEnv = process.env, platform = process.platform): string {
|
||||
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");
|
||||
}
|
||||
|
||||
type DiscoveryFile = {
|
||||
version: 1;
|
||||
entries: Record<string, OAuthDiscoveryState>;
|
||||
};
|
||||
|
||||
export class DiscoveryStore {
|
||||
readonly filePath: string;
|
||||
|
||||
constructor(configDir = platformConfigDir()) {
|
||||
this.filePath = path.join(configDir, "discovery.json");
|
||||
}
|
||||
|
||||
async get(account: string): Promise<OAuthDiscoveryState | undefined> {
|
||||
return (await this.read()).entries[account];
|
||||
}
|
||||
|
||||
async set(account: string, state: OAuthDiscoveryState): Promise<void> {
|
||||
const document = await this.read();
|
||||
document.entries[account] = state;
|
||||
await atomicWriteJson(this.filePath, document);
|
||||
}
|
||||
|
||||
async delete(account: string): Promise<void> {
|
||||
const document = await this.read();
|
||||
if (!(account in document.entries)) return;
|
||||
delete document.entries[account];
|
||||
await atomicWriteJson(this.filePath, document);
|
||||
}
|
||||
|
||||
private async read(): Promise<DiscoveryFile> {
|
||||
try {
|
||||
const value = JSON.parse(await readFile(this.filePath, "utf8")) as Partial<DiscoveryFile>;
|
||||
return { version: 1, entries: value.entries && typeof value.entries === "object" ? value.entries : {} };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, entries: {} };
|
||||
throw new Error(`Could not read OAuth discovery cache: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporary = `${filePath}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
await rename(temporary, filePath);
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
export const KEYRING_SERVICE = "CraftTable MCP";
|
||||
|
||||
export interface TokenStore {
|
||||
get(account: string): Promise<OAuthTokens | undefined>;
|
||||
set(account: string, tokens: OAuthTokens): Promise<void>;
|
||||
delete(account: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class KeyringTokenStore implements TokenStore {
|
||||
async get(account: string): Promise<OAuthTokens | undefined> {
|
||||
let serialized: string | undefined;
|
||||
try {
|
||||
const entry = await keyringEntry(account);
|
||||
serialized = await entry.getPassword();
|
||||
} catch (error) {
|
||||
throw keyringError(error);
|
||||
}
|
||||
if (!serialized) return undefined;
|
||||
try {
|
||||
const value = JSON.parse(serialized) as OAuthTokens;
|
||||
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: string, tokens: OAuthTokens): Promise<void> {
|
||||
try {
|
||||
const entry = await keyringEntry(account);
|
||||
await entry.setPassword(JSON.stringify(tokens));
|
||||
} catch (error) {
|
||||
throw keyringError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(account: string): Promise<void> {
|
||||
try {
|
||||
const entry = await keyringEntry(account);
|
||||
await entry.deleteCredential();
|
||||
} catch (error) {
|
||||
const message = String((error as Error)?.message ?? error).toLowerCase();
|
||||
if (message.includes("no entry") || message.includes("not found")) return;
|
||||
throw keyringError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function keyringEntry(account: string): Promise<import("@napi-rs/keyring").AsyncEntry> {
|
||||
try {
|
||||
const { AsyncEntry } = await import("@napi-rs/keyring");
|
||||
return new AsyncEntry(KEYRING_SERVICE, account);
|
||||
} catch (error) {
|
||||
throw keyringError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function keyringError(error: unknown): 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.`);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import type { ClientOptions } from "./config.js";
|
||||
import { credentialAccount, DiscoveryStore, OAUTH_SCOPES } from "./config.js";
|
||||
import type { TokenStore } from "./credentials.js";
|
||||
|
||||
export class GameCraftOAuthProvider implements OAuthClientProvider {
|
||||
readonly redirectUrl: URL;
|
||||
readonly clientMetadata: OAuthClientMetadata;
|
||||
private readonly account: string;
|
||||
private readonly expectedState: string;
|
||||
private codeVerifierValue?: string;
|
||||
|
||||
constructor(
|
||||
private readonly options: ClientOptions,
|
||||
private readonly tokenStore: TokenStore,
|
||||
private readonly discoveryStore: DiscoveryStore,
|
||||
private readonly onRedirect: (url: URL) => void | Promise<void>,
|
||||
state = randomBytes(32).toString("base64url"),
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
state(): string {
|
||||
return this.expectedState;
|
||||
}
|
||||
|
||||
validateState(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const expected = Buffer.from(this.expectedState);
|
||||
const actual = Buffer.from(value);
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformationMixed {
|
||||
return { client_id: this.options.clientId };
|
||||
}
|
||||
|
||||
tokens(): Promise<OAuthTokens | undefined> {
|
||||
return this.tokenStore.get(this.account);
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
return this.tokenStore.set(this.account, tokens);
|
||||
}
|
||||
|
||||
redirectToAuthorization(url: URL): void | Promise<void> {
|
||||
return this.onRedirect(url);
|
||||
}
|
||||
|
||||
saveCodeVerifier(codeVerifier: string): void {
|
||||
this.codeVerifierValue = codeVerifier;
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired");
|
||||
return this.codeVerifierValue;
|
||||
}
|
||||
|
||||
discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
||||
return this.discoveryStore.get(this.account);
|
||||
}
|
||||
|
||||
saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
|
||||
return this.discoveryStore.set(this.account, state);
|
||||
}
|
||||
|
||||
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
|
||||
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 = undefined;
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
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 type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import open from "open";
|
||||
import { OAuthCallbackServer } from "./callbackServer.js";
|
||||
import type { ClientOptions } from "./config.js";
|
||||
import { credentialAccount, DiscoveryStore, errorMessage } from "./config.js";
|
||||
import type { TokenStore } from "./credentials.js";
|
||||
import { GameCraftOAuthProvider } from "./oauthProvider.js";
|
||||
|
||||
export type RemoteConnection = {
|
||||
client: Client;
|
||||
transport: StreamableHTTPClientTransport;
|
||||
authentication: "oauth" | "service-token";
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function connectRemote(
|
||||
options: ClientOptions,
|
||||
tokenStore: TokenStore,
|
||||
discoveryStore: DiscoveryStore,
|
||||
serviceToken = process.env.CRAFTTABLE_MCP_TOKEN?.trim(),
|
||||
): Promise<RemoteConnection> {
|
||||
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");
|
||||
}
|
||||
|
||||
export async function loginRemote(
|
||||
options: ClientOptions,
|
||||
tokenStore: TokenStore,
|
||||
discoveryStore: DiscoveryStore,
|
||||
input: { browser: boolean; timeoutMs?: number; writeLine?: (value: string) => void } = { browser: true },
|
||||
): Promise<{ alreadyAuthenticated: boolean; toolCount: number }> {
|
||||
const writeLine = input.writeLine ?? ((value) => process.stderr.write(`${value}\n`));
|
||||
let authorizationUrl: URL | undefined;
|
||||
const provider = new GameCraftOAuthProvider(options, tokenStore, discoveryStore, async (url) => {
|
||||
authorizationUrl = url;
|
||||
if (!input.browser) {
|
||||
writeLine(`Open this URL to log in:\n${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:\n${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(() => undefined);
|
||||
await callback.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutRemote(
|
||||
options: ClientOptions,
|
||||
tokenStore: TokenStore,
|
||||
discoveryStore: DiscoveryStore,
|
||||
localOnly: boolean,
|
||||
fetchFn: typeof fetch = fetch,
|
||||
): Promise<{ hadCredential: boolean; revoked: boolean }> {
|
||||
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: ClientOptions,
|
||||
transport: StreamableHTTPClientTransport,
|
||||
authentication: RemoteConnection["authentication"],
|
||||
): Promise<RemoteConnection> {
|
||||
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.0" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
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: URL, fetchFn: typeof fetch): Promise<URL> {
|
||||
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() as { authorization_servers?: unknown };
|
||||
const issuer = Array.isArray(protectedMetadata.authorization_servers)
|
||||
? protectedMetadata.authorization_servers.find((value): value is string => typeof value === "string")
|
||||
: undefined;
|
||||
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() as { revocation_endpoint?: unknown };
|
||||
if (typeof metadata.revocation_endpoint === "string") return new URL(metadata.revocation_endpoint);
|
||||
} catch {
|
||||
// Try the next standards-compatible discovery location.
|
||||
}
|
||||
}
|
||||
throw new Error("OAuth authorization server does not advertise a revocation endpoint");
|
||||
}
|
||||
|
||||
async function revokeTokens(endpoint: URL, clientId: string, tokens: OAuthTokens, fetchFn: typeof fetch): Promise<void> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { parse } from "jsonc-parser";
|
||||
import { configureAgents, type CommandResult, type CommandRunner, launchCommand, unconfigureAgents } from "../src/agents.js";
|
||||
import { resolveClientOptions } from "../src/config.js";
|
||||
|
||||
class FakeRunner implements CommandRunner {
|
||||
readonly calls: Array<{ command: string; args: string[] }> = [];
|
||||
existing: Record<string, string | undefined> = {};
|
||||
|
||||
async run(command: string, args: string[]): Promise<CommandResult> {
|
||||
this.calls.push({ command, args });
|
||||
const agent = command === "codex" ? "codex" : "claude";
|
||||
if (args[1] === "get") {
|
||||
const value = this.existing[agent];
|
||||
return value === undefined
|
||||
? { code: 1, stdout: "", stderr: "MCP server not found" }
|
||||
: { code: 0, stdout: value, stderr: "" };
|
||||
}
|
||||
if (args[1] === "remove") {
|
||||
this.existing[agent] = undefined;
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (args[1] === "add") {
|
||||
const separator = args.indexOf("--");
|
||||
const commandParts = args.slice(separator + 1);
|
||||
this.existing[agent] = JSON.stringify({ transport: { command: commandParts[0], args: commandParts.slice(1) } });
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { code: 1, stdout: "", stderr: "unexpected command" };
|
||||
}
|
||||
}
|
||||
|
||||
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const cliEntry = path.resolve("dist", "cli.js");
|
||||
const nodePath = path.resolve("bin", "node.exe");
|
||||
|
||||
test("Codex and Claude adapters add, detect idempotency, replace conflicts, remove, and dry-run", async () => {
|
||||
const runner = new FakeRunner();
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "added" }]);
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "unchanged" }]);
|
||||
runner.existing.codex = JSON.stringify({ transport: { command: "other", args: [] } });
|
||||
await assert.rejects(configureAgents({ target: "codex", options, cliEntry, nodePath, runner, confirm: async () => false }), /not changed/);
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner, force: true }), [{ agent: "codex", action: "replaced" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "codex", options, cliEntry, nodePath, runner, dryRun: true }), [{ agent: "codex", action: "would-remove" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "removed" }]);
|
||||
|
||||
assert.deepEqual(await configureAgents({ target: "claude", options, cliEntry, nodePath, runner, dryRun: true }), [{ agent: "claude", action: "would-add" }]);
|
||||
assert.equal(runner.existing.claude, undefined);
|
||||
});
|
||||
|
||||
test("OpenCode adapter preserves JSONC, creates one backup, is idempotent, and unconfigures", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "crafttable-mcp-agent-test-"));
|
||||
const filePath = path.join(directory, "opencode.json");
|
||||
await import("node:fs/promises").then(({ writeFile }) => writeFile(filePath, "{\n // keep this comment\n \"theme\": \"dark\"\n}\n", "utf8"));
|
||||
try {
|
||||
assert.deepEqual(await configureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "added" }]);
|
||||
const configuredText = await readFile(filePath, "utf8");
|
||||
assert.match(configuredText, /keep this comment/);
|
||||
const configured = parse(configuredText) as { theme: string; mcp: Record<string, { type: string; command: string[] }> };
|
||||
assert.equal(configured.theme, "dark");
|
||||
assert.equal(configured.mcp.crafttable?.type, "local");
|
||||
assert.deepEqual(configured.mcp.crafttable?.command, launchCommand(options, cliEntry, nodePath));
|
||||
assert.match(await readFile(`${filePath}.crafttable-mcp.backup`, "utf8"), /keep this comment/);
|
||||
assert.deepEqual(await configureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "unchanged" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "removed" }]);
|
||||
const removed = parse(await readFile(filePath, "utf8")) as { mcp?: Record<string, unknown> };
|
||||
assert.equal(removed.mcp?.crafttable, undefined);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createProxyServer } from "../src/bridge.js";
|
||||
|
||||
test("stdio proxy forwards tools, resources, templates, reads, errors, and cursors", async () => {
|
||||
const calls: Array<{ method: string; params: unknown }> = [];
|
||||
const upstream = {
|
||||
listTools: async (params?: { cursor?: string }) => {
|
||||
calls.push({ method: "tools/list", params });
|
||||
return { tools: [{ name: "list_spaces", description: "List spaces", inputSchema: { type: "object" } }], nextCursor: "tools-next" };
|
||||
},
|
||||
callTool: async (params: { name: string; arguments?: Record<string, unknown> }) => {
|
||||
calls.push({ method: "tools/call", params });
|
||||
if (params.name === "explode") throw new Error("upstream failed");
|
||||
return { content: [{ type: "text", text: JSON.stringify(params.arguments) }] };
|
||||
},
|
||||
listResources: async (params?: { cursor?: string }) => {
|
||||
calls.push({ method: "resources/list", params });
|
||||
return { resources: [{ uri: "gamecraft://spaces", name: "Spaces" }], nextCursor: "resources-next" };
|
||||
},
|
||||
listResourceTemplates: async (params?: { cursor?: string }) => {
|
||||
calls.push({ method: "resources/templates/list", params });
|
||||
return { resourceTemplates: [{ uriTemplate: "gamecraft://space/{spaceId}", name: "Space" }] };
|
||||
},
|
||||
readResource: async (params: { uri: string }) => {
|
||||
calls.push({ method: "resources/read", params });
|
||||
return { contents: [{ uri: params.uri, text: "resource-value" }] };
|
||||
},
|
||||
};
|
||||
const server = createProxyServer(upstream);
|
||||
const client = new Client({ name: "bridge-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
assert.equal((await client.listTools({ cursor: "tool-cursor" })).nextCursor, "tools-next");
|
||||
assert.equal((await client.listResources({ cursor: "resource-cursor" })).nextCursor, "resources-next");
|
||||
assert.equal((await client.listResourceTemplates({ cursor: "template-cursor" })).resourceTemplates.length, 1);
|
||||
const resource = await client.readResource({ uri: "gamecraft://spaces" });
|
||||
assert.equal("text" in resource.contents[0]! ? resource.contents[0].text : undefined, "resource-value");
|
||||
const called = await client.callTool({ name: "list_spaces", arguments: { page: 2 } });
|
||||
const content = called.content as Array<{ type: string; text?: string }>;
|
||||
assert.match(String(content[0]?.type === "text" ? content[0].text : ""), /\"page\":2/);
|
||||
await assert.rejects(client.callTool({ name: "explode", arguments: {} }), /upstream failed/);
|
||||
assert.deepEqual(calls.slice(0, 3), [
|
||||
{ method: "tools/list", params: { cursor: "tool-cursor" } },
|
||||
{ method: "resources/list", params: { cursor: "resource-cursor" } },
|
||||
{ method: "resources/templates/list", params: { cursor: "template-cursor" } },
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:net";
|
||||
import test from "node:test";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { OAuthCallbackServer } from "../src/callbackServer.js";
|
||||
import { credentialAccount, resolveClientOptions, validateMcpUrl } from "../src/config.js";
|
||||
import type { TokenStore } from "../src/credentials.js";
|
||||
import { GameCraftOAuthProvider } from "../src/oauthProvider.js";
|
||||
import { logoutRemote } from "../src/remote.js";
|
||||
|
||||
class MemoryTokens implements TokenStore {
|
||||
readonly values = new Map<string, OAuthTokens>();
|
||||
async get(account: string): Promise<OAuthTokens | undefined> { return this.values.get(account); }
|
||||
async set(account: string, tokens: OAuthTokens): Promise<void> { this.values.set(account, tokens); }
|
||||
async delete(account: string): Promise<void> { this.values.delete(account); }
|
||||
}
|
||||
|
||||
class MemoryDiscovery {
|
||||
values = new Map<string, unknown>();
|
||||
async get(account: string): Promise<undefined> { return this.values.get(account) as undefined; }
|
||||
async set(account: string, state: unknown): Promise<void> { this.values.set(account, state); }
|
||||
async delete(account: string): Promise<void> { this.values.delete(account); }
|
||||
}
|
||||
|
||||
test("connection options enforce HTTPS and isolate credentials by URL and client ID", () => {
|
||||
assert.equal(validateMcpUrl("https://example.test/mcp/").toString(), "https://example.test/mcp");
|
||||
assert.equal(validateMcpUrl("http://127.0.0.1:8787/mcp").protocol, "http:");
|
||||
assert.throws(() => validateMcpUrl("http://example.test/mcp"), /HTTPS/);
|
||||
assert.throws(() => validateMcpUrl("https://example.test/mcp?token=secret"), /query/);
|
||||
const first = resolveClientOptions({ url: "https://one.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const second = resolveClientOptions({ url: "https://two.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const third = resolveClientOptions({ url: "https://one.test/mcp", clientId: "other", callbackPort: 48321, env: {} });
|
||||
assert.notEqual(credentialAccount(first), credentialAccount(second));
|
||||
assert.notEqual(credentialAccount(first), credentialAccount(third));
|
||||
});
|
||||
|
||||
test("OAuth provider validates state and replaces rotated refresh tokens", async () => {
|
||||
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const tokens = new MemoryTokens();
|
||||
const discovery = new MemoryDiscovery();
|
||||
const provider = new GameCraftOAuthProvider(options, tokens, discovery as never, () => undefined, "expected-state");
|
||||
assert.equal(provider.validateState("expected-state"), true);
|
||||
assert.equal(provider.validateState("wrong-state"), false);
|
||||
provider.saveCodeVerifier("verifier");
|
||||
assert.equal(provider.codeVerifier(), "verifier");
|
||||
await provider.saveTokens({ access_token: "access-one", refresh_token: "refresh-one", token_type: "Bearer" });
|
||||
await provider.saveTokens({ access_token: "access-two", refresh_token: "refresh-two", token_type: "Bearer" });
|
||||
assert.equal((await provider.tokens())?.refresh_token, "refresh-two");
|
||||
});
|
||||
|
||||
test("OAuth callback validates state without exposing the authorization code", async () => {
|
||||
const port = await availablePort();
|
||||
const callback = new OAuthCallbackServer(port, (state) => state === "expected");
|
||||
await callback.listen();
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/oauth/callback?code=sensitive-code&state=expected`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.doesNotMatch(await response.text(), /sensitive-code/);
|
||||
assert.deepEqual(await callback.wait(1000), { code: "sensitive-code" });
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("OAuth callback reports timeout and occupied ports", async () => {
|
||||
const timeoutPort = await availablePort();
|
||||
const timeoutServer = new OAuthCallbackServer(timeoutPort, () => true);
|
||||
await timeoutServer.listen();
|
||||
try {
|
||||
await assert.rejects(timeoutServer.wait(10), /timed out/);
|
||||
} finally {
|
||||
await timeoutServer.close();
|
||||
}
|
||||
|
||||
const occupiedPort = await availablePort();
|
||||
const blocker = createServer();
|
||||
await new Promise<void>((resolve) => blocker.listen(occupiedPort, "127.0.0.1", resolve));
|
||||
try {
|
||||
await assert.rejects(new OAuthCallbackServer(occupiedPort, () => true).listen(), /Could not listen/);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => blocker.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("logout revokes refresh and access tokens before deleting local credentials", async () => {
|
||||
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const account = credentialAccount(options);
|
||||
const tokens = new MemoryTokens();
|
||||
tokens.values.set(account, { access_token: "access", refresh_token: "refresh", token_type: "Bearer" });
|
||||
const discovery = new MemoryDiscovery();
|
||||
const requests: Array<{ url: string; body?: string }> = [];
|
||||
const fetchFn = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const url = String(input);
|
||||
requests.push({ url, body: init?.body?.toString() });
|
||||
if (url.includes("oauth-protected-resource")) return Response.json({ authorization_servers: ["https://id.example/oidc"] });
|
||||
if (url.includes("openid-configuration")) return Response.json({ revocation_endpoint: "https://id.example/revoke" });
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
const result = await logoutRemote(options, tokens, discovery as never, false, fetchFn as typeof fetch);
|
||||
assert.deepEqual(result, { hadCredential: true, revoked: true });
|
||||
assert.equal(tokens.values.has(account), false);
|
||||
assert.equal(requests.filter((request) => request.url.endsWith("/revoke")).length, 1);
|
||||
assert.match(requests.find((request) => request.body?.includes("refresh_token"))?.body ?? "", /token=refresh/);
|
||||
});
|
||||
|
||||
test("failed remote revocation retains local credentials", async () => {
|
||||
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
|
||||
const account = credentialAccount(options);
|
||||
const tokens = new MemoryTokens();
|
||||
tokens.values.set(account, { access_token: "access", token_type: "Bearer" });
|
||||
const fetchFn = async (input: string | URL | Request): Promise<Response> => {
|
||||
const url = String(input);
|
||||
if (url.includes("oauth-protected-resource")) return Response.json({ authorization_servers: ["https://id.example/oidc"] });
|
||||
if (url.includes("openid-configuration")) return Response.json({ revocation_endpoint: "https://id.example/revoke" });
|
||||
return new Response(null, { status: 500 });
|
||||
};
|
||||
await assert.rejects(logoutRemote(options, tokens, new MemoryDiscovery() as never, false, fetchFn as typeof fetch), /retained/);
|
||||
assert.equal(tokens.values.has(account), true);
|
||||
});
|
||||
|
||||
async function availablePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("could not allocate test port");
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
return address.port;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import test from "node:test";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { isInitializeRequest, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { credentialAccount, resolveClientOptions } from "../src/config.js";
|
||||
import type { TokenStore } from "../src/credentials.js";
|
||||
import { loginRemote } from "../src/remote.js";
|
||||
|
||||
class MemoryTokens implements TokenStore {
|
||||
readonly values = new Map<string, OAuthTokens>();
|
||||
async get(account: string): Promise<OAuthTokens | undefined> { return this.values.get(account); }
|
||||
async set(account: string, tokens: OAuthTokens): Promise<void> { this.values.set(account, tokens); }
|
||||
async delete(account: string): Promise<void> { this.values.delete(account); }
|
||||
}
|
||||
|
||||
class MemoryDiscovery {
|
||||
value: unknown;
|
||||
async get(): Promise<undefined> { return this.value as undefined; }
|
||||
async set(_account: string, value: unknown): Promise<void> { this.value = value; }
|
||||
async delete(): Promise<void> { this.value = undefined; }
|
||||
}
|
||||
|
||||
test("browser PKCE login exchanges, refreshes rotated tokens, and initializes MCP without leaking secrets", async () => {
|
||||
const callbackPort = await availablePort();
|
||||
const app = express();
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(express.json());
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
const resource = `${origin}/mcp`;
|
||||
const authorizationCodes = new Map<string, { challenge: string; resource: string }>();
|
||||
let authorizationRequest: URL | undefined;
|
||||
let refreshCount = 0;
|
||||
|
||||
let mcpServer: Server | undefined;
|
||||
let transport: StreamableHTTPServerTransport | undefined;
|
||||
|
||||
app.get("/.well-known/oauth-protected-resource/mcp", (_request, response) => response.json({
|
||||
resource,
|
||||
authorization_servers: [origin],
|
||||
scopes_supported: ["openid", "profile", "email", "offline_access"],
|
||||
bearer_methods_supported: ["header"],
|
||||
}));
|
||||
const authorizationMetadata = {
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/token`,
|
||||
revocation_endpoint: `${origin}/revoke`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
};
|
||||
app.get("/.well-known/oauth-authorization-server", (_request, response) => response.json(authorizationMetadata));
|
||||
app.get("/.well-known/openid-configuration", (_request, response) => response.json({
|
||||
...authorizationMetadata,
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
}));
|
||||
app.get("/authorize", (request, response) => {
|
||||
authorizationRequest = new URL(request.originalUrl, origin);
|
||||
const redirectUri = String(request.query.redirect_uri ?? "");
|
||||
const state = String(request.query.state ?? "");
|
||||
const challenge = String(request.query.code_challenge ?? "");
|
||||
const requestedResource = String(request.query.resource ?? "");
|
||||
const code = "authorization-code";
|
||||
authorizationCodes.set(code, { challenge, resource: requestedResource });
|
||||
const callback = new URL(redirectUri);
|
||||
callback.searchParams.set("code", code);
|
||||
callback.searchParams.set("state", state);
|
||||
response.redirect(callback.toString());
|
||||
});
|
||||
app.post("/token", (request, response) => {
|
||||
const grantType = String(request.body.grant_type ?? "");
|
||||
if (grantType === "authorization_code") {
|
||||
const code = String(request.body.code ?? "");
|
||||
const record = authorizationCodes.get(code);
|
||||
const verifier = String(request.body.code_verifier ?? "");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
if (!record || challenge !== record.challenge || record.resource !== resource || request.body.resource !== resource || request.body.client_id !== "test-client") {
|
||||
response.status(400).json({ error: "invalid_grant" });
|
||||
return;
|
||||
}
|
||||
response.json({ access_token: "access-one", refresh_token: "refresh-one", token_type: "Bearer", expires_in: 3600 });
|
||||
return;
|
||||
}
|
||||
if (grantType === "refresh_token" && request.body.refresh_token === "refresh-one" && request.body.resource === resource) {
|
||||
refreshCount += 1;
|
||||
response.json({ access_token: "access-two", refresh_token: "refresh-two", token_type: "Bearer", expires_in: 3600 });
|
||||
return;
|
||||
}
|
||||
response.status(400).json({ error: "invalid_grant" });
|
||||
});
|
||||
app.post("/mcp", async (request, response) => {
|
||||
if (request.headers.authorization !== "Bearer access-two") {
|
||||
response.setHeader("WWW-Authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`);
|
||||
response.status(401).json({ error: "authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!transport && isInitializeRequest(request.body)) {
|
||||
mcpServer = new Server({ name: "mock-gamecraft", version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [{ name: "list_spaces", description: "List spaces", inputSchema: { type: "object" } }],
|
||||
}));
|
||||
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true });
|
||||
await mcpServer.connect(transport);
|
||||
}
|
||||
if (!transport) {
|
||||
response.status(400).json({ error: "MCP session is missing" });
|
||||
return;
|
||||
}
|
||||
await transport.handleRequest(request, response, request.body);
|
||||
});
|
||||
|
||||
const options = resolveClientOptions({ url: resource, clientId: "test-client", callbackPort, env: {} });
|
||||
const tokens = new MemoryTokens();
|
||||
const discovery = new MemoryDiscovery();
|
||||
const output: string[] = [];
|
||||
let browserFailure: unknown;
|
||||
try {
|
||||
const result = await loginRemote(options, tokens, discovery as never, {
|
||||
browser: false,
|
||||
timeoutMs: 3000,
|
||||
writeLine: (line) => {
|
||||
output.push(line);
|
||||
const match = line.match(/https?:\/\/[^\s]+/);
|
||||
if (match) void fetch(match[0], { redirect: "follow" }).catch((error) => { browserFailure = error; });
|
||||
},
|
||||
});
|
||||
assert.equal(browserFailure, undefined);
|
||||
assert.deepEqual(result, { alreadyAuthenticated: false, toolCount: 1 });
|
||||
assert.equal(authorizationRequest?.searchParams.get("code_challenge_method"), "S256");
|
||||
assert.equal(authorizationRequest?.searchParams.get("resource"), resource);
|
||||
assert.match(authorizationRequest?.searchParams.get("scope") ?? "", /offline_access/);
|
||||
assert.equal(refreshCount, 1);
|
||||
assert.equal(tokens.values.get(credentialAccount(options))?.refresh_token, "refresh-two");
|
||||
const renderedOutput = output.join("\n");
|
||||
assert.doesNotMatch(renderedOutput, /authorization-code|access-one|access-two|refresh-one|refresh-two|Authorization:/);
|
||||
} finally {
|
||||
await mcpServer?.close();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
async function availablePort(): Promise<number> {
|
||||
const app = express();
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
return address.port;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { createProxyServer } from "../src/bridge.js";
|
||||
|
||||
const server = createProxyServer({
|
||||
listTools: async (params) => ({ tools: [{ name: "fixture_tool", inputSchema: { type: "object" } }], nextCursor: params?.cursor }),
|
||||
callTool: async () => ({ content: [{ type: "text", text: "ok" }] }),
|
||||
listResources: async () => ({ resources: [] }),
|
||||
listResourceTemplates: async () => ({ resourceTemplates: [] }),
|
||||
readResource: async ({ uri }) => ({ contents: [{ uri, text: "ok" }] }),
|
||||
});
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
test("stdio protocol mode writes only JSON-RPC messages to stdout", async () => {
|
||||
const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const child = spawn(process.execPath, ["--import", "tsx", "test/stdioFixture.ts"], {
|
||||
cwd: packageRoot,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ["pipe", "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); });
|
||||
const messages = [
|
||||
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "stdio-test", version: "1.0.0" } } },
|
||||
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
{ jsonrpc: "2.0", id: 2, method: "tools/list", params: { cursor: "next" } },
|
||||
];
|
||||
child.stdin.write(messages.map((message) => JSON.stringify(message)).join("\n") + "\n");
|
||||
await waitFor(() => stdout.split(/\r?\n/).filter(Boolean).some((line) => {
|
||||
try { return (JSON.parse(line) as { id?: number }).id === 2; } catch { return false; }
|
||||
}), 3000);
|
||||
child.stdin.end();
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => { child.kill(); resolve(); }, 1000);
|
||||
child.once("close", () => { clearTimeout(timeout); resolve(); });
|
||||
});
|
||||
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
||||
assert.ok(lines.length >= 2);
|
||||
const parsed = lines.map((line) => JSON.parse(line) as { jsonrpc: string; id?: number; result?: unknown });
|
||||
assert.ok(parsed.every((message) => message.jsonrpc === "2.0"));
|
||||
assert.ok(parsed.some((message) => message.id === 1));
|
||||
assert.ok(parsed.some((message) => message.id === 2));
|
||||
assert.equal(stderr, "");
|
||||
});
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error("timed out waiting for stdio response");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user