sync: update MCP client from GameCraftTable

This commit is contained in:
2026-09-17 00:40:50 +08:00
parent d8f8f394f9
commit 6182738980
18 changed files with 551 additions and 23 deletions
Vendored
+93 -4
View File
@@ -64,14 +64,25 @@ var DiscoveryStore = class {
constructor(configDir = platformConfigDir()) {
this.filePath = path.join(configDir, "discovery.json");
}
/**
* @param account - 稳定账户标识。
* @returns 账户的缓存发现状态;不存在时返回 `undefined`。
*/
async get(account) {
return (await this.read()).entries[account];
}
/**
* 使用原子文件替换持久化账户的发现状态。
*
* @param account - 稳定账户标识。
* @param state - MCP OAuth 客户端提供的发现状态。
*/
async set(account, state) {
const document = await this.read();
document.entries[account] = state;
await atomicWriteJson(this.filePath, document);
}
/** @param account - 要删除其状态的稳定账户标识。 */
async delete(account) {
const document = await this.read();
if (!(account in document.entries)) return;
@@ -392,6 +403,12 @@ function safeCommandError(result) {
return (result.stderr || result.stdout || `exit code ${result.code}`).trim();
}
var SpawnCommandRunner = class {
/**
* @param command - 可执行文件名称或路径。
* @param args - 不经 shell 解释而传递的原始参数列表。
* @returns 捕获的退出码、标准输出与标准错误。
* @throws 子进程无法启动时抛出。
*/
run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
@@ -443,6 +460,12 @@ var OAuthCallbackServer = class {
this.resolveResult = resolve;
this.rejectResult = reject;
});
/**
* 在配置的固定回调端口监听 `127.0.0.1`。
*
* @returns 端口绑定完成后解析的 Promise。
* @throws 服务已运行或端口无法绑定时抛出。
*/
async listen() {
if (this.server) throw new Error("OAuth callback server is already running");
this.server = createServer((request, response) => {
@@ -482,6 +505,13 @@ var OAuthCallbackServer = class {
const address = this.server.address();
if (!address || address.port !== this.port) throw new Error(`OAuth callback server did not bind port ${this.port}`);
}
/**
* 等待有效回调或达到配置的超时时间。
*
* @param timeoutMs - 最大等待时间(毫秒)。
* @returns 经过验证的回调中的授权码。
* @throws 授权失败、状态验证失败、缺少授权码或等待超时时抛出。
*/
async wait(timeoutMs = 10 * 60 * 1e3) {
let timeout;
try {
@@ -495,6 +525,11 @@ var OAuthCallbackServer = class {
if (timeout) clearTimeout(timeout);
}
}
/**
* 停止接收回调;对已停止的服务调用此方法不会产生任何操作。
*
* @returns HTTP 服务关闭后解析的 Promise。
*/
async close() {
if (!this.server) return;
const server = this.server;
@@ -506,6 +541,13 @@ var OAuthCallbackServer = class {
// src/oauthProvider.ts
import { randomBytes, timingSafeEqual } from "node:crypto";
var CraftTableOAuthProvider = class {
/**
* @param options - 远端端点与已注册公共客户端设置。
* @param tokenStore - OAuth 令牌安全持久化接口。
* @param discoveryStore - 授权服务器发现状态缓存。
* @param onRedirect - 打开或显示授权 URL 的处理器。
* @param state - 预期回调状态,可注入以实现确定性测试。
*/
constructor(options, tokenStore, discoveryStore, onRedirect, state = randomBytes(32).toString("base64url")) {
this.options = options;
this.tokenStore = tokenStore;
@@ -532,40 +574,63 @@ var CraftTableOAuthProvider = class {
account;
expectedState;
codeVerifierValue;
/** @returns 授权回调必须携带的 state 值。 */
state() {
return this.expectedState;
}
/**
* 在长度一致时以常量时间比较回调 state。
*
* @param value - 从 loopback 回调收到的 state。
* @returns 回调是否属于本次授权尝试。
*/
validateState(value) {
if (!value) return false;
const expected = Buffer.from(this.expectedState);
const actual = Buffer.from(value);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
/** @returns MCP SDK 使用的静态公共客户端注册信息。 */
clientInformation() {
return { client_id: this.options.clientId };
}
/** @returns 当前服务/客户端组合已保存的 OAuth 令牌。 */
tokens() {
return this.tokenStore.get(this.account);
}
/** 持久化刷新或新签发的 OAuth 令牌集。 */
saveTokens(tokens) {
return this.tokenStore.set(this.account, tokens);
}
/** 将授权 URL 交给 CLI 的浏览器或手动登录处理器。 */
redirectToAuthorization(url) {
return this.onRedirect(url);
}
/** 在本次登录尝试期间将 PKCE verifier 保存在内存中。 */
saveCodeVerifier(codeVerifier) {
this.codeVerifierValue = codeVerifier;
}
/**
* @returns 为当前授权尝试保存的 PKCE verifier。
* @throws 尚未生成 verifier 或其已失效时抛出。
*/
codeVerifier() {
if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired");
return this.codeVerifierValue;
}
/** @returns 当前服务/客户端组合的 OAuth 发现缓存。 */
discoveryState() {
return this.discoveryStore.get(this.account);
}
/** 在凭据存储之外持久化 OAuth 发现状态。 */
saveDiscoveryState(state) {
return this.discoveryStore.set(this.account, state);
}
/**
* 使 MCP OAuth 客户端指定的凭据材料失效。
*
* @param scope - 要清理的凭据类别。
*/
async invalidateCredentials(scope) {
if (scope === "all" || scope === "tokens") await this.tokenStore.delete(this.account);
if (scope === "all" || scope === "discovery") await this.discoveryStore.delete(this.account);
@@ -637,14 +702,19 @@ ${url.toString()}`);
}
async function logoutRemote(options, tokenStore, discoveryStore, localOnly, fetchFn = fetch) {
const account = credentialAccount(options);
if (localOnly) {
const hadCredential = await tokenStore.delete(account);
await discoveryStore.delete(account);
return { hadCredential, revoked: false };
}
const tokens = await tokenStore.get(account);
if (tokens && !localOnly) {
if (tokens) {
const endpoint = await discoverRevocationEndpoint(options.url, fetchFn);
await revokeTokens(endpoint, options.clientId, tokens, fetchFn);
}
await tokenStore.delete(account);
await discoveryStore.delete(account);
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens && !localOnly) };
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens) };
}
async function connectWithTransport(options, transport, authentication) {
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.3" });
@@ -760,6 +830,11 @@ var KeyringTokenStore = class {
this.entryFactory = entryFactory;
}
entryFactory;
/**
* @param account - 稳定凭据账户标识。
* @returns 解码后的令牌集;不存在凭据时返回 `undefined`。
* @throws 凭据存储不可用或已存数据无效时抛出。
*/
async get(account) {
let serialized;
try {
@@ -787,6 +862,13 @@ var KeyringTokenStore = class {
throw invalidCredentialError();
}
}
/**
* 原子发布新一代分块,并删除上一代分块。
*
* @param account - 稳定凭据账户标识。
* @param tokens - 要安全存储的 OAuth 令牌。
* @throws 任一凭据存储操作失败时抛出。
*/
async set(account, tokens) {
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
const chunks = splitCredential(encoded);
@@ -811,6 +893,12 @@ var KeyringTokenStore = class {
await Promise.allSettled(Array.from({ length: previousManifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, previousManifest.generation, index))));
}
}
/**
* 删除账户清单及其引用的全部分块。
*
* @param account - 稳定凭据账户标识。
* @throws 无法访问凭据存储时抛出。
*/
async delete(account) {
try {
const serialized = await this.readPassword(account);
@@ -819,13 +907,14 @@ var KeyringTokenStore = class {
if (manifest) {
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, manifest.generation, index))));
}
return serialized !== void 0;
} catch (error) {
throw keyringError(error);
}
}
async readPassword(account) {
try {
return await (await this.entryFactory(account)).getPassword();
return await (await this.entryFactory(account)).getPassword() ?? void 0;
} catch (error) {
if (isMissingCredential(error)) return void 0;
throw error;
@@ -924,7 +1013,7 @@ withConnection(program.command("serve").description("Run the local stdio bridge"
for (const operation of ["configure", "unconfigure"]) {
withConnection(program.command(`${operation} <agent>`).description(`${operation === "configure" ? "Add" : "Remove"} the stdio bridge and CraftTable Skill in Codex, Claude Code, or OpenCode`)).option("--dry-run", "show the planned changes without writing").option("--force", "replace a conflicting entry without prompting").action(async (agent, flags) => {
const cliEntry = fileURLToPath(import.meta.url);
if (!cliEntry.endsWith(".js")) throw new Error("Agent configuration requires the built CLI; run `npm --prefix apps/mcp-client run build` first");
if (!cliEntry.endsWith(".js")) throw new Error("Agent configuration requires the built CLI; run `npm --prefix mcp-client run build` first");
const input = {
target: agent,
options: connectionOptions(flags),
+2 -2
View File
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -5,15 +5,17 @@ description: Use the configured CraftTable MCP when a task involves project spac
# CraftTable
Use the MCP server named `crafttable`; do not substitute direct HTTP calls or expose OAuth credentials.
Use the MCP server named `crafttable` when it is mounted in the current session. If it is not mounted, use the installed `crafttable-mcp-client` CLI (or the compatible `crafttable-mcp` command alias) instead. Do not substitute direct HTTP calls, override the configured server URL, or expose OAuth credentials.
For CLI access, use `status` to check connectivity, `tools` or `resources` to discover the remote surface, `call <tool> '<json>'` to invoke a tool, and `read <uri>` for an MCP resource. Run `login` only when `status` reports that the user is not logged in.
## Workflow
1. Call `list_spaces` when the target space ID is unknown. Match by name and ask only when multiple spaces remain plausible.
1. Confirm the connection through the mounted MCP server or `crafttable-mcp-client status`, then call `list_spaces` when the target space ID is unknown. Match by name and ask only when multiple spaces remain plausible.
2. Read the smallest useful surface before answering or changing data. Prefer overview and list tools, then fetch a specific work item or document.
3. Treat CraftTable as the source of truth for workspace state. Do not infer current members, tasks, document text, or project knowledge from stale conversation context when MCP reads are available.
4. Perform writes only when the user's request authorizes that state change. Keep changes within the named space and object.
5. Before updating Markdown, call `get_document` and pass its `currentRevisionId` as `baseRevisionId` to `update_markdown_document`.
6. Pass `confirm: true` to deletion tools only after the user has explicitly confirmed the specific deletion.
Respect membership roles, enabled-plugin checks, and MCP errors. Never try to bypass a denied operation. If the MCP server is unavailable because the user is not logged in, ask them to run `crafttable-mcp login`; never open a login page from an Agent stdio session.
Respect membership roles, enabled-plugin checks, and MCP errors. Never try to bypass a denied operation. Do not run `configure` or `unconfigure` unless the user explicitly asks to change an agent's MCP registration. If the user is not logged in, ask them to run `crafttable-mcp-client login` (or `crafttable-mcp login`); never open a login page from an Agent stdio session.
+62 -1
View File
@@ -1,3 +1,9 @@
/**
* 管理 CraftTable MCP 在 Codex、Claude Code 与 OpenCode 中的注册和 Skill 安装。
*
* @packageDocumentation
*/
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
@@ -9,16 +15,24 @@ import spawn from "cross-spawn";
import type { ClientOptions } from "./config.js";
import { errorMessage } from "./config.js";
/** CraftTable MCP 服务及随附 Skill 使用的注册名称。 */
export const SERVER_NAME = "crafttable";
/** 支持自动配置的 Agent CLI。 */
export type AgentName = "codex" | "claude" | "opencode";
/** 单个受支持 Agent 或全部受支持 Agent。 */
export type AgentTarget = AgentName | "all";
/** 安装或移除托管 CraftTable Skill 的结果。 */
export type SkillAction = "installed" | "replaced" | "removed" | "unchanged" | "absent" | "preserved" | "would-install" | "would-replace" | "would-remove" | "would-preserve";
/** 外部 Agent CLI 调用的捕获结果。 */
export type CommandResult = { code: number; stdout: string; stderr: string };
/** Agent 配置使用的可注入命令执行边界。 */
export interface CommandRunner {
/** 不经过 shell 执行命令并捕获其输出。 */
run(command: string, args: string[]): Promise<CommandResult>;
}
/** 控制 MCP 注册和随附 Skill 安装的输入。 */
export type ConfigureInput = {
target: AgentTarget;
options: ClientOptions;
@@ -34,12 +48,22 @@ export type ConfigureInput = {
skillPaths?: Partial<Record<AgentName, string>>;
};
/** 配置或取消配置操作中每个 Agent 的结果。 */
export type ConfigureResult = {
agent: AgentName;
action: "added" | "replaced" | "removed" | "unchanged" | "absent" | "would-add" | "would-replace" | "would-remove";
skillAction: SkillAction;
};
/**
* 为选定 Agent 注册 stdio 桥接并安装随附 Skill。
*
* 除非现有配置与托管内容一致、用户确认替换或启用 `force`,否则保留现有配置。
*
* @param input - 目标 Agent、启动设置与变更策略。
* @returns 每个选定 Agent 的 MCP 与 Skill 操作结果。
* @throws 无法检查配置、无法取得确认或写入/CLI 命令失败时抛出。
*/
export async function configureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
const agents = expandTarget(input.target);
const command = launchCommand(input.options, input.cliEntry, input.nodePath ?? process.execPath);
@@ -55,6 +79,15 @@ export async function configureAgents(input: ConfigureInput): Promise<ConfigureR
return results;
}
/**
* 从选定 Agent 中移除托管的 MCP 注册与随附 Skill。
*
* 除非启用 `force`,否则保留用户修改过的 Skill。
*
* @param input - 目标 Agent、路径与变更策略。
* @returns 每个选定 Agent 的 MCP 与 Skill 操作结果。
* @throws 无法检查配置或写入/CLI 命令失败时抛出。
*/
export async function unconfigureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
const agents = expandTarget(input.target);
const runner = input.runner ?? new SpawnCommandRunner();
@@ -88,6 +121,14 @@ async function unconfigureCliAgent(
return { agent, action: "removed" };
}
/**
* 构造注册到 Agent CLI 的完整可执行程序与参数。
*
* @param options - 远端 MCP 与 OAuth 回调设置。
* @param cliEntry - CraftTable MCP CLI 入口模块路径。
* @param nodePath - 用于启动入口模块的 Node.js 可执行文件。
* @returns 可用于 Agent 配置的 argv 风格命令数组。
*/
export function launchCommand(options: ClientOptions, cliEntry: string, nodePath: string): string[] {
return [
path.resolve(nodePath),
@@ -147,7 +188,7 @@ function outputMatchesCommand(output: string, command: string[]): boolean {
const document = JSON.parse(output) as unknown;
if (findCommand(document, command)) return true;
} catch {
// Claude currently returns a human-readable record.
// Claude 当前返回便于阅读的文本记录。
}
return command.every((part) => output.includes(part));
}
@@ -201,6 +242,10 @@ async function unconfigureOpenCode(input: ConfigureInput): Promise<Omit<Configur
return { agent: "opencode", action: "removed" };
}
/**
* @param env - 用于解析用户配置根目录的环境变量。
* @returns 默认 OpenCode JSONC 配置路径。
*/
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")
@@ -208,10 +253,19 @@ export function defaultOpenCodePath(env: NodeJS.ProcessEnv = process.env): strin
return path.join(base, "opencode", "opencode.json");
}
/**
* @param cliEntry - 已安装 CLI 入口模块的路径。
* @returns 与入口相邻的随附 CraftTable Skill 目录。
*/
export function bundledSkillPath(cliEntry: string): string {
return path.resolve(path.dirname(cliEntry), "..", "skills", SERVER_NAME);
}
/**
* @param agent - 需要解析用户级 Skill 目录的 Agent。
* @param env - 用于解析用户配置根目录的环境变量。
* @returns CraftTable Skill 的默认目标目录。
*/
export function defaultSkillPath(agent: AgentName, env: NodeJS.ProcessEnv = process.env): string {
const home = env.USERPROFILE || env.HOME || os.homedir();
if (agent === "codex") return path.join(env.CODEX_HOME || path.join(home, ".codex"), "skills", SERVER_NAME);
@@ -377,7 +431,14 @@ function safeCommandError(result: CommandResult): string {
return (result.stderr || result.stdout || `exit code ${result.code}`).trim();
}
/** 以隐藏且不经过 shell 的子进程运行 Agent CLI 配置命令。 */
export class SpawnCommandRunner implements CommandRunner {
/**
* @param command - 可执行文件名称或路径。
* @param args - 不经 shell 解释而传递的原始参数列表。
* @returns 捕获的退出码、标准输出与标准错误。
* @throws 子进程无法启动时抛出。
*/
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"] });
+25
View File
@@ -1,3 +1,9 @@
/**
* 将远端 CraftTable MCP 服务桥接为本地 stdio MCP 服务。
*
* @packageDocumentation
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
@@ -12,6 +18,19 @@ import { DiscoveryStore } from "./config.js";
import type { TokenStore } from "./credentials.js";
import { connectRemote } from "./remote.js";
/**
* 连接远端 CraftTable MCP 端点,并通过 stdio 提供服务。
*
* 桥接会一直运行到 stdio 传输关闭或进程收到 SIGINT/SIGTERM
* 关闭时会同时释放本地和远端连接。
*
* @param options - 远端 MCP 与 OAuth 客户端设置。
* @param tokenStore - OAuth 令牌的安全存储。
* @param discoveryStore - 持久化的 OAuth 发现缓存。
* @param serviceToken - OAuth 令牌不存在时可使用的旧版 Bearer 令牌。
* @returns 桥接停止后完成的 Promise。
* @throws 无法完成鉴权或建立任一 MCP 传输时抛出。
*/
export async function serveBridge(
options: ClientOptions,
tokenStore: TokenStore,
@@ -38,6 +57,12 @@ export async function serveBridge(
}
}
/**
* 创建一个将支持的请求转发到远端的本地 MCP 服务。
*
* @param upstream - 代理所需的最小远端 MCP 客户端接口。
* @returns 尚未连接、可绑定服务传输的 MCP 服务实例。
*/
export function createProxyServer(upstream: {
listTools: (params?: { cursor?: string }) => Promise<unknown>;
callTool: (params: { name: string; arguments?: Record<string, unknown> }) => Promise<unknown>;
+33 -2
View File
@@ -1,8 +1,21 @@
/**
* 在 loopback 地址接收 OAuth 授权码回调。
*
* @packageDocumentation
*/
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
/** loopback 回调收到的成功授权响应。 */
export type OAuthCallback = { code: string };
/**
* 在 loopback 接口接收一次 OAuth 授权码回调。
*
* 服务会拒绝包含 OAuth 错误、状态无效或缺少授权码的回调。
* 登录尝试完成后应调用 {@link close}。
*/
export class OAuthCallbackServer {
private server?: Server;
private resolveResult?: (value: OAuthCallback) => void;
@@ -16,11 +29,17 @@ export class OAuthCallbackServer {
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().
// 浏览器回调可能早于 loginRemote 开始等待;预先处理拒绝,
// 同时为 wait() 保留原始 Promise。
void this.result.catch(() => undefined);
}
/**
* 在配置的固定回调端口监听 `127.0.0.1`。
*
* @returns 端口绑定完成后解析的 Promise。
* @throws 服务已运行或端口无法绑定时抛出。
*/
async listen(): Promise<void> {
if (this.server) throw new Error("OAuth callback server is already running");
this.server = createServer((request, response) => {
@@ -61,6 +80,13 @@ export class OAuthCallbackServer {
if (!address || address.port !== this.port) throw new Error(`OAuth callback server did not bind port ${this.port}`);
}
/**
* 等待有效回调或达到配置的超时时间。
*
* @param timeoutMs - 最大等待时间(毫秒)。
* @returns 经过验证的回调中的授权码。
* @throws 授权失败、状态验证失败、缺少授权码或等待超时时抛出。
*/
async wait(timeoutMs = 10 * 60 * 1000): Promise<OAuthCallback> {
let timeout: NodeJS.Timeout | undefined;
try {
@@ -75,6 +101,11 @@ export class OAuthCallbackServer {
}
}
/**
* 停止接收回调;对已停止的服务调用此方法不会产生任何操作。
*
* @returns HTTP 服务关闭后解析的 Promise。
*/
async close(): Promise<void> {
if (!this.server) return;
const server = this.server;
+37 -1
View File
@@ -1,5 +1,11 @@
#!/usr/bin/env node
/**
* 定义 CraftTable MCP 客户端的命令行入口与子命令。
*
* @packageDocumentation
*/
import { fileURLToPath } from "node:url";
import { Command } from "commander";
import { configureAgents, type AgentTarget, unconfigureAgents } from "./agents.js";
@@ -10,6 +16,7 @@ import { connectRemote, loginRemote, logoutRemote } from "./remote.js";
type ConnectionFlags = { url?: string; clientId?: string; callbackPort?: string };
/** 交互式 OAuth、检查与 stdio 桥接流程共用的根命令。 */
const program = new Command()
.name("crafttable-mcp")
.description("OAuth client and stdio bridge for CraftTable MCP")
@@ -80,7 +87,7 @@ for (const operation of ["configure", "unconfigure"] as const) {
.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");
if (!cliEntry.endsWith(".js")) throw new Error("Agent configuration requires the built CLI; run `npm --prefix mcp-client run build` first");
const input = {
target: agent,
options: connectionOptions(flags),
@@ -93,6 +100,12 @@ for (const operation of ["configure", "unconfigure"] as const) {
});
}
/**
* 为命令添加通用的远端端点与 OAuth 客户端选项。
*
* @param command - 要扩展的 Commander 命令。
* @returns 已注册连接选项的同一命令实例。
*/
function withConnection(command: Command): Command {
return command
.option("--url <url>", "MCP Streamable HTTP URL")
@@ -100,10 +113,17 @@ function withConnection(command: Command): Command {
.option("--callback-port <port>", "fixed localhost OAuth callback port");
}
/** 解析并验证一次命令调用的连接选项。 */
function connectionOptions(flags: ConnectionFlags): ClientOptions {
return resolveClientOptions(flags);
}
/**
* 使用已连接的远端客户端执行操作,并确保连接始终关闭。
*
* @param options - 已验证的远端连接设置。
* @param action - 使用已连接 MCP 客户端执行的操作。
*/
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 {
@@ -113,6 +133,13 @@ async function withClient(options: ClientOptions, action: (client: Awaited<Retur
}
}
/**
* 解析 CLI 工具参数,并要求 JSON 值必须为对象。
*
* @param raw - 命令行提供的 JSON。
* @returns 解析后的工具参数。
* @throws JSON 无效或值不是对象时抛出。
*/
function parseObject(raw: string): Record<string, unknown> {
let value: unknown;
try {
@@ -124,12 +151,21 @@ function parseObject(raw: string): Record<string, unknown> {
return value as Record<string, unknown>;
}
/**
* 解析正整数命令选项。
*
* @param value - 原始 CLI 选项值。
* @param name - 错误消息中使用的可读选项名称。
* @returns 经过验证的整数。
* @throws 值不是正整数时抛出。
*/
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;
}
/** 将稳定且便于阅读的 JSON 结果写入标准输出。 */
function printJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
+61
View File
@@ -1,20 +1,38 @@
/**
* 解析 MCP 连接配置并管理 OAuth 发现缓存。
*
* @packageDocumentation
*/
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";
/** CLI 使用的默认公开 Streamable HTTP 端点。 */
export const DEFAULT_MCP_URL = "https://crafttable.crash.work/mcp";
/** 为 CLI 注册的 OAuth 公共客户端标识。 */
export const DEFAULT_CLIENT_ID = "crafttable-mcp-cli";
/** 注册为 OAuth 重定向 URI 的固定 loopback 端口。 */
export const DEFAULT_CALLBACK_PORT = 48321;
/** 交互式登录时请求的 OAuth 作用域。 */
export const OAUTH_SCOPES = "openid profile email offline_access";
/** MCP 操作使用的已解析连接与 OAuth 回调设置。 */
export type ClientOptions = {
url: URL;
clientId: string;
callbackPort: number;
};
/**
* 将 CLI 覆盖项、环境变量与默认值解析为经过验证的设置。
*
* @param input - 可选命令标志与环境变量来源。
* @returns 规范化的客户端设置。
* @throws URL、客户端 ID 或回调端口无效时抛出。
*/
export function resolveClientOptions(input: {
url?: string;
clientId?: string;
@@ -32,6 +50,16 @@ export function resolveClientOptions(input: {
return { url, clientId, callbackPort };
}
/**
* 验证并规范化远端 MCP URL。
*
* 除 loopback 开发端点外必须使用 HTTPS;为避免泄露认证数据,
* URL 中不允许包含凭据、查询字符串或片段。
*
* @param value - 用户或环境变量提供的绝对 URL。
* @returns 去除路径末尾斜杠后的规范化 URL。
* @throws URL 格式错误或违反传输策略时抛出。
*/
export function validateMcpUrl(value: string): URL {
let url: URL;
try {
@@ -50,10 +78,23 @@ export function validateMcpUrl(value: string): URL {
return url;
}
/**
* 派生稳定且不包含秘密的凭据存储账户键。
*
* @param options - 定义账户的服务 URL 与 OAuth 客户端标识。
* @returns SHA-256 账户标识。
*/
export function credentialAccount(options: Pick<ClientOptions, "url" | "clientId">): string {
return createHash("sha256").update(`${options.url.toString()}\0${options.clientId}`).digest("hex");
}
/**
* 解析保存非敏感客户端状态的平台目录。
*
* @param env - 用于覆盖平台目录的环境变量。
* @param platform - Node 平台标识,可注入以便测试。
* @returns 绝对配置目录路径。
*/
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");
@@ -65,6 +106,7 @@ type DiscoveryFile = {
entries: Record<string, OAuthDiscoveryState>;
};
/** 在用户非敏感配置目录中存储 OAuth 发现状态。 */
export class DiscoveryStore {
readonly filePath: string;
@@ -72,16 +114,27 @@ export class DiscoveryStore {
this.filePath = path.join(configDir, "discovery.json");
}
/**
* @param account - 稳定账户标识。
* @returns 账户的缓存发现状态;不存在时返回 `undefined`。
*/
async get(account: string): Promise<OAuthDiscoveryState | undefined> {
return (await this.read()).entries[account];
}
/**
* 使用原子文件替换持久化账户的发现状态。
*
* @param account - 稳定账户标识。
* @param state - MCP OAuth 客户端提供的发现状态。
*/
async set(account: string, state: OAuthDiscoveryState): Promise<void> {
const document = await this.read();
document.entries[account] = state;
await atomicWriteJson(this.filePath, document);
}
/** @param account - 要删除其状态的稳定账户标识。 */
async delete(account: string): Promise<void> {
const document = await this.read();
if (!(account in document.entries)) return;
@@ -100,6 +153,13 @@ export class DiscoveryStore {
}
}
/**
* 先写入私有临时文件,再通过原子重命名替换 JSON 文档。
*
* @param filePath - 目标文件路径。
* @param value - 可序列化为 JSON 的值。
* @returns 替换完成后解析的 Promise。
*/
export async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.${process.pid}.tmp`;
@@ -107,6 +167,7 @@ export async function atomicWriteJson(filePath: string, value: unknown): Promise
await rename(temporary, filePath);
}
/** 将未知的抛出值转换为可显示的错误消息。 */
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+40 -4
View File
@@ -1,11 +1,18 @@
/**
* 使用操作系统凭据管理器安全持久化 OAuth 令牌。
*
* @packageDocumentation
*/
import { randomUUID } from "node:crypto";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
/** 操作系统凭据存储中使用的服务名称。 */
export const KEYRING_SERVICE = "CraftTable MCP";
const KEYRING_CHUNK_SIZE = 1000;
type CredentialEntry = {
getPassword(): Promise<string | undefined>;
getPassword(): Promise<string | null | undefined>;
setPassword(password: string): Promise<void>;
deleteCredential(): Promise<boolean>;
};
@@ -18,15 +25,30 @@ type ChunkManifest = {
chunks: number;
};
/** 以账户哈希为键安全持久化 OAuth 令牌集的契约。 */
export interface TokenStore {
/** 返回账户的令牌集;不存在时返回 `undefined`。 */
get(account: string): Promise<OAuthTokens | undefined>;
/** 替换账户的令牌集。 */
set(account: string, tokens: OAuthTokens): Promise<void>;
delete(account: string): Promise<void>;
/** 删除账户的全部已存储令牌数据,返回是否存在凭据。 */
delete(account: string): Promise<boolean>;
}
/**
* 在操作系统凭据管理器中存储 OAuth 令牌。
*
* 部分凭据管理器的单条记录上限较小,因此令牌负载会被分块。
* 此实现有意不支持明文后备存储。
*/
export class KeyringTokenStore implements TokenStore {
constructor(private readonly entryFactory: CredentialEntryFactory = keyringEntry) {}
/**
* @param account - 稳定凭据账户标识。
* @returns 解码后的令牌集;不存在凭据时返回 `undefined`。
* @throws 凭据存储不可用或已存数据无效时抛出。
*/
async get(account: string): Promise<OAuthTokens | undefined> {
let serialized: string | undefined;
try {
@@ -58,6 +80,13 @@ export class KeyringTokenStore implements TokenStore {
}
}
/**
* 原子发布新一代分块,并删除上一代分块。
*
* @param account - 稳定凭据账户标识。
* @param tokens - 要安全存储的 OAuth 令牌。
* @throws 任一凭据存储操作失败时抛出。
*/
async set(account: string, tokens: OAuthTokens): Promise<void> {
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
const chunks = splitCredential(encoded);
@@ -86,7 +115,13 @@ export class KeyringTokenStore implements TokenStore {
}
}
async delete(account: string): Promise<void> {
/**
* 删除账户清单及其引用的全部分块。
*
* @param account - 稳定凭据账户标识。
* @throws 无法访问凭据存储时抛出。
*/
async delete(account: string): Promise<boolean> {
try {
const serialized = await this.readPassword(account);
const manifest = serialized ? parseChunkManifest(serialized) : undefined;
@@ -95,6 +130,7 @@ export class KeyringTokenStore implements TokenStore {
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) =>
this.deletePassword(chunkAccount(account, manifest.generation, index))));
}
return serialized !== undefined;
} catch (error) {
throw keyringError(error);
}
@@ -102,7 +138,7 @@ export class KeyringTokenStore implements TokenStore {
private async readPassword(account: string): Promise<string | undefined> {
try {
return await (await this.entryFactory(account)).getPassword();
return (await (await this.entryFactory(account)).getPassword()) ?? undefined;
} catch (error) {
if (isMissingCredential(error)) return undefined;
throw error;
+42
View File
@@ -1,3 +1,9 @@
/**
* 实现 MCP SDK 所需的 CraftTable OAuth 提供者。
*
* @packageDocumentation
*/
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";
@@ -5,6 +11,12 @@ import type { ClientOptions } from "./config.js";
import { credentialAccount, DiscoveryStore, OAUTH_SCOPES } from "./config.js";
import type { TokenStore } from "./credentials.js";
/**
* 由安全令牌存储和发现缓存支持的 MCP SDK OAuth 提供者。
*
* 每个实例拥有独立的随机 OAuth state 和内存 PKCE verifier
* 防止这些值在不同登录尝试间共享。
*/
export class CraftTableOAuthProvider implements OAuthClientProvider {
readonly redirectUrl: URL;
readonly clientMetadata: OAuthClientMetadata;
@@ -12,6 +24,13 @@ export class CraftTableOAuthProvider implements OAuthClientProvider {
private readonly expectedState: string;
private codeVerifierValue?: string;
/**
* @param options - 远端端点与已注册公共客户端设置。
* @param tokenStore - OAuth 令牌安全持久化接口。
* @param discoveryStore - 授权服务器发现状态缓存。
* @param onRedirect - 打开或显示授权 URL 的处理器。
* @param state - 预期回调状态,可注入以实现确定性测试。
*/
constructor(
private readonly options: ClientOptions,
private readonly tokenStore: TokenStore,
@@ -32,10 +51,17 @@ export class CraftTableOAuthProvider implements OAuthClientProvider {
this.expectedState = state;
}
/** @returns 授权回调必须携带的 state 值。 */
state(): string {
return this.expectedState;
}
/**
* 在长度一致时以常量时间比较回调 state。
*
* @param value - 从 loopback 回调收到的 state。
* @returns 回调是否属于本次授权尝试。
*/
validateState(value: string | null): boolean {
if (!value) return false;
const expected = Buffer.from(this.expectedState);
@@ -43,39 +69,55 @@ export class CraftTableOAuthProvider implements OAuthClientProvider {
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
/** @returns MCP SDK 使用的静态公共客户端注册信息。 */
clientInformation(): OAuthClientInformationMixed {
return { client_id: this.options.clientId };
}
/** @returns 当前服务/客户端组合已保存的 OAuth 令牌。 */
tokens(): Promise<OAuthTokens | undefined> {
return this.tokenStore.get(this.account);
}
/** 持久化刷新或新签发的 OAuth 令牌集。 */
saveTokens(tokens: OAuthTokens): Promise<void> {
return this.tokenStore.set(this.account, tokens);
}
/** 将授权 URL 交给 CLI 的浏览器或手动登录处理器。 */
redirectToAuthorization(url: URL): void | Promise<void> {
return this.onRedirect(url);
}
/** 在本次登录尝试期间将 PKCE verifier 保存在内存中。 */
saveCodeVerifier(codeVerifier: string): void {
this.codeVerifierValue = codeVerifier;
}
/**
* @returns 为当前授权尝试保存的 PKCE verifier。
* @throws 尚未生成 verifier 或其已失效时抛出。
*/
codeVerifier(): string {
if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired");
return this.codeVerifierValue;
}
/** @returns 当前服务/客户端组合的 OAuth 发现缓存。 */
discoveryState(): Promise<OAuthDiscoveryState | undefined> {
return this.discoveryStore.get(this.account);
}
/** 在凭据存储之外持久化 OAuth 发现状态。 */
saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
return this.discoveryStore.set(this.account, state);
}
/**
* 使 MCP OAuth 客户端指定的凭据材料失效。
*
* @param scope - 要清理的凭据类别。
*/
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);
+58 -3
View File
@@ -1,3 +1,9 @@
/**
* 建立远端 MCP 连接,并编排 OAuth 登录、注销与元数据发现。
*
* @packageDocumentation
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -9,6 +15,7 @@ import { credentialAccount, DiscoveryStore, errorMessage } from "./config.js";
import type { TokenStore } from "./credentials.js";
import { CraftTableOAuthProvider } from "./oauthProvider.js";
/** 已连接的远端 MCP 客户端及其所选鉴权模式。 */
export type RemoteConnection = {
client: Client;
transport: StreamableHTTPClientTransport;
@@ -16,6 +23,18 @@ export type RemoteConnection = {
close: () => Promise<void>;
};
/**
* 使用已保存 OAuth 令牌或旧版令牌连接远端 MCP 服务。
*
* 已保存的 OAuth 凭据优先于服务令牌。
*
* @param options - 远端 MCP 与 OAuth 客户端设置。
* @param tokenStore - OAuth 令牌安全持久化接口。
* @param discoveryStore - OAuth 发现缓存。
* @param serviceToken - 可选的旧版 Bearer 令牌后备项。
* @returns 已连接客户端与可重复调用的关闭边界。
* @throws 没有可用鉴权方式或远端连接失败时抛出。
*/
export async function connectRemote(
options: ClientOptions,
tokenStore: TokenStore,
@@ -37,6 +56,16 @@ export async function connectRemote(
throw new Error("Not logged in; run `crafttable-mcp login` or set CRAFTTABLE_MCP_TOKEN for the legacy service-account path");
}
/**
* 完成 OAuth 授权码流程并验证 MCP 连通性。
*
* @param options - 远端 MCP 与 loopback 回调设置。
* @param tokenStore - OAuth 令牌的安全目标存储。
* @param discoveryStore - 持久化 OAuth 发现缓存。
* @param input - 浏览器行为、回调超时与状态输出钩子。
* @returns 凭据此前是否有效,以及当前可见工具数量。
* @throws OAuth 元数据、浏览器回调、令牌交换或 MCP 连接失败时抛出。
*/
export async function loginRemote(
options: ClientOptions,
tokenStore: TokenStore,
@@ -87,6 +116,19 @@ export async function loginRemote(
}
}
/**
* 按需撤销已保存的 OAuth 凭据,然后清理本地 OAuth 状态。
*
* 远端撤销失败时会保留本地凭据,以便重试。
*
* @param options - 远端 MCP 与 OAuth 客户端设置。
* @param tokenStore - OAuth 令牌安全持久化接口。
* @param discoveryStore - OAuth 发现缓存。
* @param localOnly - 为 `true` 时跳过远端撤销。
* @param fetchFn - HTTP 实现,可注入以便测试。
* @returns 凭据是否存在,以及是否成功执行撤销。
* @throws 发现流程或远端令牌撤销失败时抛出。
*/
export async function logoutRemote(
options: ClientOptions,
tokenStore: TokenStore,
@@ -95,14 +137,19 @@ export async function logoutRemote(
fetchFn: typeof fetch = fetch,
): Promise<{ hadCredential: boolean; revoked: boolean }> {
const account = credentialAccount(options);
if (localOnly) {
const hadCredential = await tokenStore.delete(account);
await discoveryStore.delete(account);
return { hadCredential, revoked: false };
}
const tokens = await tokenStore.get(account);
if (tokens && !localOnly) {
if (tokens) {
const endpoint = await discoverRevocationEndpoint(options.url, fetchFn);
await revokeTokens(endpoint, options.clientId, tokens, fetchFn);
}
await tokenStore.delete(account);
await discoveryStore.delete(account);
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens && !localOnly) };
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens) };
}
async function connectWithTransport(
@@ -144,12 +191,20 @@ async function discoverRevocationEndpoint(resource: URL, fetchFn: typeof fetch):
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");
}
/**
* 加载并验证 MCP 端点的 RFC 9728 受保护资源元数据。
*
* @param resource - 要发现其元数据的 MCP 资源 URL。
* @param fetchFn - HTTP 实现,可注入以便测试。
* @returns 包含已公布授权服务器的解析后元数据。
* @throws 元数据不可达、响应失败、不是 JSON 或格式错误时抛出。
*/
export async function requireOAuthProtectedResourceMetadata(
resource: URL,
fetchFn: typeof fetch = fetch,
+7 -1
View File
@@ -1,3 +1,9 @@
/**
* 覆盖 Agent 配置适配器、Skill 目录解析、幂等配置、冲突替换与备份行为。
*
* @packageDocumentation
*/
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
@@ -35,7 +41,7 @@ class FakeRunner implements CommandRunner {
}
const options = resolveClientOptions({ url: "https://example.test/mcp", clientId: "client", callbackPort: 48321, env: {} });
const cliEntry = path.resolve("dist", "cli.js");
const cliEntry = path.resolve("..", "build", "mcp-client", "cli.js");
const nodePath = path.resolve("bin", "node.exe");
test("Agent Skill paths use each client's user-level discovery directory", () => {
+6
View File
@@ -1,3 +1,9 @@
/**
* 覆盖 stdio 代理对 MCP 工具、资源、模板、游标与上游错误的转发行为。
*
* @packageDocumentation
*/
import assert from "node:assert/strict";
import test from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+7 -1
View File
@@ -1,3 +1,9 @@
/**
* 覆盖连接配置校验、OAuth state/回调、元数据检查与令牌撤销边界。
*
* @packageDocumentation
*/
import assert from "node:assert/strict";
import { createServer } from "node:net";
import test from "node:test";
@@ -12,7 +18,7 @@ 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); }
async delete(account: string): Promise<boolean> { return this.values.delete(account); }
}
class MemoryDiscovery {
+54
View File
@@ -1,7 +1,15 @@
/**
* 覆盖系统凭据存储适配器的令牌分块、轮换清理与旧格式兼容行为。
*
* @packageDocumentation
*/
import assert from "node:assert/strict";
import test from "node:test";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { KeyringTokenStore } from "../src/credentials.js";
import { credentialAccount, resolveClientOptions } from "../src/config.js";
import { logoutRemote } from "../src/remote.js";
class MemoryEntry {
constructor(private readonly values: Map<string, string>, private readonly account: string) {}
@@ -64,3 +72,49 @@ test("keyring token store still reads and removes legacy single-entry credential
await store.delete("account");
assert.equal(values.size, 0);
});
for (const damage of ["invalid-json", "missing-token-fields", "missing-chunk"] as const) {
test(`local-only logout removes ${damage} credentials without remote requests`, async () => {
const options = resolveClientOptions({ url: "https://example.test/mcp", env: {} });
const account = credentialAccount(options);
const values = new Map<string, string>();
const store = memoryStore(values);
if (damage === "missing-chunk") {
await store.set(account, { access_token: "a".repeat(3000), token_type: "Bearer" });
values.delete([...values.keys()].find((key) => key !== account)!);
} else {
values.set(account, damage === "invalid-json" ? "{broken" : "{}");
}
values.set("other-account", "untouched");
await assert.rejects(store.get(account), /credential is invalid/);
const discovery = new Map([[account, "cached"], ["other-account", "untouched"]]);
const result = await logoutRemote(options, store, {
async delete(key: string) { discovery.delete(key); },
} as never, true, async () => { throw new Error("unexpected network request"); });
assert.deepEqual(result, { hadCredential: true, revoked: false });
assert.deepEqual([...values], [["other-account", "untouched"]]);
assert.deepEqual([...discovery], [["other-account", "untouched"]]);
assert.equal(await store.get(account), undefined);
assert.deepEqual(await logoutRemote(options, store, {
async delete(key: string) { discovery.delete(key); },
} as never, true), { hadCredential: false, revoked: false });
});
}
test("local-only logout reports credential store failures without claiming success", async () => {
const store = new KeyringTokenStore(async () => { throw new Error("access denied"); });
await assert.rejects(logoutRemote(resolveClientOptions({ env: {} }), store, {
async delete() { assert.fail("discovery must be retained when credential deletion fails"); },
} as never, true), /credential store is unavailable.*access denied/);
});
test("keyring null for an absent Windows credential is treated as missing", async () => {
const store = new KeyringTokenStore(async () => ({
async getPassword() { return null; },
async setPassword() { assert.fail("unexpected credential write"); },
async deleteCredential() { return false; },
}));
assert.equal(await store.get("account"), undefined);
assert.equal(await store.delete("account"), false);
});
+7 -1
View File
@@ -1,3 +1,9 @@
/**
* 通过本地 OAuth 与 MCP 模拟服务验证完整 PKCE 登录、令牌刷新和秘密隔离流程。
*
* @packageDocumentation
*/
import assert from "node:assert/strict";
import { createHash, randomUUID } from "node:crypto";
import express from "express";
@@ -15,7 +21,7 @@ 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); }
async delete(account: string): Promise<boolean> { return this.values.delete(account); }
}
class MemoryDiscovery {
+6
View File
@@ -1,3 +1,9 @@
/**
* 为 stdio 协议集成测试提供最小 MCP 服务进程。
*
* @packageDocumentation
*/
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createProxyServer } from "../src/bridge.js";
+6
View File
@@ -1,3 +1,9 @@
/**
* 验证 stdio 模式的标准输出只包含合法 JSON-RPC 消息,不混入诊断文本。
*
* @packageDocumentation
*/
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import path from "node:path";