sync: update MCP client from GameCraftTable
This commit is contained in:
Vendored
+93
-4
@@ -64,14 +64,25 @@ var DiscoveryStore = class {
|
|||||||
constructor(configDir = platformConfigDir()) {
|
constructor(configDir = platformConfigDir()) {
|
||||||
this.filePath = path.join(configDir, "discovery.json");
|
this.filePath = path.join(configDir, "discovery.json");
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param account - 稳定账户标识。
|
||||||
|
* @returns 账户的缓存发现状态;不存在时返回 `undefined`。
|
||||||
|
*/
|
||||||
async get(account) {
|
async get(account) {
|
||||||
return (await this.read()).entries[account];
|
return (await this.read()).entries[account];
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 使用原子文件替换持久化账户的发现状态。
|
||||||
|
*
|
||||||
|
* @param account - 稳定账户标识。
|
||||||
|
* @param state - MCP OAuth 客户端提供的发现状态。
|
||||||
|
*/
|
||||||
async set(account, state) {
|
async set(account, state) {
|
||||||
const document = await this.read();
|
const document = await this.read();
|
||||||
document.entries[account] = state;
|
document.entries[account] = state;
|
||||||
await atomicWriteJson(this.filePath, document);
|
await atomicWriteJson(this.filePath, document);
|
||||||
}
|
}
|
||||||
|
/** @param account - 要删除其状态的稳定账户标识。 */
|
||||||
async delete(account) {
|
async delete(account) {
|
||||||
const document = await this.read();
|
const document = await this.read();
|
||||||
if (!(account in document.entries)) return;
|
if (!(account in document.entries)) return;
|
||||||
@@ -392,6 +403,12 @@ function safeCommandError(result) {
|
|||||||
return (result.stderr || result.stdout || `exit code ${result.code}`).trim();
|
return (result.stderr || result.stdout || `exit code ${result.code}`).trim();
|
||||||
}
|
}
|
||||||
var SpawnCommandRunner = class {
|
var SpawnCommandRunner = class {
|
||||||
|
/**
|
||||||
|
* @param command - 可执行文件名称或路径。
|
||||||
|
* @param args - 不经 shell 解释而传递的原始参数列表。
|
||||||
|
* @returns 捕获的退出码、标准输出与标准错误。
|
||||||
|
* @throws 子进程无法启动时抛出。
|
||||||
|
*/
|
||||||
run(command, args) {
|
run(command, args) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
@@ -443,6 +460,12 @@ var OAuthCallbackServer = class {
|
|||||||
this.resolveResult = resolve;
|
this.resolveResult = resolve;
|
||||||
this.rejectResult = reject;
|
this.rejectResult = reject;
|
||||||
});
|
});
|
||||||
|
/**
|
||||||
|
* 在配置的固定回调端口监听 `127.0.0.1`。
|
||||||
|
*
|
||||||
|
* @returns 端口绑定完成后解析的 Promise。
|
||||||
|
* @throws 服务已运行或端口无法绑定时抛出。
|
||||||
|
*/
|
||||||
async listen() {
|
async listen() {
|
||||||
if (this.server) throw new Error("OAuth callback server is already running");
|
if (this.server) throw new Error("OAuth callback server is already running");
|
||||||
this.server = createServer((request, response) => {
|
this.server = createServer((request, response) => {
|
||||||
@@ -482,6 +505,13 @@ var OAuthCallbackServer = class {
|
|||||||
const address = this.server.address();
|
const address = this.server.address();
|
||||||
if (!address || address.port !== this.port) throw new Error(`OAuth callback server did not bind port ${this.port}`);
|
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) {
|
async wait(timeoutMs = 10 * 60 * 1e3) {
|
||||||
let timeout;
|
let timeout;
|
||||||
try {
|
try {
|
||||||
@@ -495,6 +525,11 @@ var OAuthCallbackServer = class {
|
|||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 停止接收回调;对已停止的服务调用此方法不会产生任何操作。
|
||||||
|
*
|
||||||
|
* @returns HTTP 服务关闭后解析的 Promise。
|
||||||
|
*/
|
||||||
async close() {
|
async close() {
|
||||||
if (!this.server) return;
|
if (!this.server) return;
|
||||||
const server = this.server;
|
const server = this.server;
|
||||||
@@ -506,6 +541,13 @@ var OAuthCallbackServer = class {
|
|||||||
// src/oauthProvider.ts
|
// src/oauthProvider.ts
|
||||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
var CraftTableOAuthProvider = class {
|
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")) {
|
constructor(options, tokenStore, discoveryStore, onRedirect, state = randomBytes(32).toString("base64url")) {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.tokenStore = tokenStore;
|
this.tokenStore = tokenStore;
|
||||||
@@ -532,40 +574,63 @@ var CraftTableOAuthProvider = class {
|
|||||||
account;
|
account;
|
||||||
expectedState;
|
expectedState;
|
||||||
codeVerifierValue;
|
codeVerifierValue;
|
||||||
|
/** @returns 授权回调必须携带的 state 值。 */
|
||||||
state() {
|
state() {
|
||||||
return this.expectedState;
|
return this.expectedState;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 在长度一致时以常量时间比较回调 state。
|
||||||
|
*
|
||||||
|
* @param value - 从 loopback 回调收到的 state。
|
||||||
|
* @returns 回调是否属于本次授权尝试。
|
||||||
|
*/
|
||||||
validateState(value) {
|
validateState(value) {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
const expected = Buffer.from(this.expectedState);
|
const expected = Buffer.from(this.expectedState);
|
||||||
const actual = Buffer.from(value);
|
const actual = Buffer.from(value);
|
||||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||||
}
|
}
|
||||||
|
/** @returns MCP SDK 使用的静态公共客户端注册信息。 */
|
||||||
clientInformation() {
|
clientInformation() {
|
||||||
return { client_id: this.options.clientId };
|
return { client_id: this.options.clientId };
|
||||||
}
|
}
|
||||||
|
/** @returns 当前服务/客户端组合已保存的 OAuth 令牌。 */
|
||||||
tokens() {
|
tokens() {
|
||||||
return this.tokenStore.get(this.account);
|
return this.tokenStore.get(this.account);
|
||||||
}
|
}
|
||||||
|
/** 持久化刷新或新签发的 OAuth 令牌集。 */
|
||||||
saveTokens(tokens) {
|
saveTokens(tokens) {
|
||||||
return this.tokenStore.set(this.account, tokens);
|
return this.tokenStore.set(this.account, tokens);
|
||||||
}
|
}
|
||||||
|
/** 将授权 URL 交给 CLI 的浏览器或手动登录处理器。 */
|
||||||
redirectToAuthorization(url) {
|
redirectToAuthorization(url) {
|
||||||
return this.onRedirect(url);
|
return this.onRedirect(url);
|
||||||
}
|
}
|
||||||
|
/** 在本次登录尝试期间将 PKCE verifier 保存在内存中。 */
|
||||||
saveCodeVerifier(codeVerifier) {
|
saveCodeVerifier(codeVerifier) {
|
||||||
this.codeVerifierValue = codeVerifier;
|
this.codeVerifierValue = codeVerifier;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @returns 为当前授权尝试保存的 PKCE verifier。
|
||||||
|
* @throws 尚未生成 verifier 或其已失效时抛出。
|
||||||
|
*/
|
||||||
codeVerifier() {
|
codeVerifier() {
|
||||||
if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired");
|
if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired");
|
||||||
return this.codeVerifierValue;
|
return this.codeVerifierValue;
|
||||||
}
|
}
|
||||||
|
/** @returns 当前服务/客户端组合的 OAuth 发现缓存。 */
|
||||||
discoveryState() {
|
discoveryState() {
|
||||||
return this.discoveryStore.get(this.account);
|
return this.discoveryStore.get(this.account);
|
||||||
}
|
}
|
||||||
|
/** 在凭据存储之外持久化 OAuth 发现状态。 */
|
||||||
saveDiscoveryState(state) {
|
saveDiscoveryState(state) {
|
||||||
return this.discoveryStore.set(this.account, state);
|
return this.discoveryStore.set(this.account, state);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 使 MCP OAuth 客户端指定的凭据材料失效。
|
||||||
|
*
|
||||||
|
* @param scope - 要清理的凭据类别。
|
||||||
|
*/
|
||||||
async invalidateCredentials(scope) {
|
async invalidateCredentials(scope) {
|
||||||
if (scope === "all" || scope === "tokens") await this.tokenStore.delete(this.account);
|
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 === "discovery") await this.discoveryStore.delete(this.account);
|
||||||
@@ -637,14 +702,19 @@ ${url.toString()}`);
|
|||||||
}
|
}
|
||||||
async function logoutRemote(options, tokenStore, discoveryStore, localOnly, fetchFn = fetch) {
|
async function logoutRemote(options, tokenStore, discoveryStore, localOnly, fetchFn = fetch) {
|
||||||
const account = credentialAccount(options);
|
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);
|
const tokens = await tokenStore.get(account);
|
||||||
if (tokens && !localOnly) {
|
if (tokens) {
|
||||||
const endpoint = await discoverRevocationEndpoint(options.url, fetchFn);
|
const endpoint = await discoverRevocationEndpoint(options.url, fetchFn);
|
||||||
await revokeTokens(endpoint, options.clientId, tokens, fetchFn);
|
await revokeTokens(endpoint, options.clientId, tokens, fetchFn);
|
||||||
}
|
}
|
||||||
await tokenStore.delete(account);
|
await tokenStore.delete(account);
|
||||||
await discoveryStore.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) {
|
async function connectWithTransport(options, transport, authentication) {
|
||||||
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.3" });
|
const client = new Client({ name: "crafttable-mcp-cli", version: "0.1.3" });
|
||||||
@@ -760,6 +830,11 @@ var KeyringTokenStore = class {
|
|||||||
this.entryFactory = entryFactory;
|
this.entryFactory = entryFactory;
|
||||||
}
|
}
|
||||||
entryFactory;
|
entryFactory;
|
||||||
|
/**
|
||||||
|
* @param account - 稳定凭据账户标识。
|
||||||
|
* @returns 解码后的令牌集;不存在凭据时返回 `undefined`。
|
||||||
|
* @throws 凭据存储不可用或已存数据无效时抛出。
|
||||||
|
*/
|
||||||
async get(account) {
|
async get(account) {
|
||||||
let serialized;
|
let serialized;
|
||||||
try {
|
try {
|
||||||
@@ -787,6 +862,13 @@ var KeyringTokenStore = class {
|
|||||||
throw invalidCredentialError();
|
throw invalidCredentialError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 原子发布新一代分块,并删除上一代分块。
|
||||||
|
*
|
||||||
|
* @param account - 稳定凭据账户标识。
|
||||||
|
* @param tokens - 要安全存储的 OAuth 令牌。
|
||||||
|
* @throws 任一凭据存储操作失败时抛出。
|
||||||
|
*/
|
||||||
async set(account, tokens) {
|
async set(account, tokens) {
|
||||||
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
|
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
|
||||||
const chunks = splitCredential(encoded);
|
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))));
|
await Promise.allSettled(Array.from({ length: previousManifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, previousManifest.generation, index))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 删除账户清单及其引用的全部分块。
|
||||||
|
*
|
||||||
|
* @param account - 稳定凭据账户标识。
|
||||||
|
* @throws 无法访问凭据存储时抛出。
|
||||||
|
*/
|
||||||
async delete(account) {
|
async delete(account) {
|
||||||
try {
|
try {
|
||||||
const serialized = await this.readPassword(account);
|
const serialized = await this.readPassword(account);
|
||||||
@@ -819,13 +907,14 @@ var KeyringTokenStore = class {
|
|||||||
if (manifest) {
|
if (manifest) {
|
||||||
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, manifest.generation, index))));
|
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, manifest.generation, index))));
|
||||||
}
|
}
|
||||||
|
return serialized !== void 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw keyringError(error);
|
throw keyringError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async readPassword(account) {
|
async readPassword(account) {
|
||||||
try {
|
try {
|
||||||
return await (await this.entryFactory(account)).getPassword();
|
return await (await this.entryFactory(account)).getPassword() ?? void 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingCredential(error)) return void 0;
|
if (isMissingCredential(error)) return void 0;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -924,7 +1013,7 @@ withConnection(program.command("serve").description("Run the local stdio bridge"
|
|||||||
for (const operation of ["configure", "unconfigure"]) {
|
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) => {
|
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);
|
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 = {
|
const input = {
|
||||||
target: agent,
|
target: agent,
|
||||||
options: connectionOptions(flags),
|
options: connectionOptions(flags),
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -5,15 +5,17 @@ description: Use the configured CraftTable MCP when a task involves project spac
|
|||||||
|
|
||||||
# CraftTable
|
# 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
|
## 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.
|
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.
|
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.
|
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`.
|
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.
|
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
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 管理 CraftTable MCP 在 Codex、Claude Code 与 OpenCode 中的注册和 Skill 安装。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
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 type { ClientOptions } from "./config.js";
|
||||||
import { errorMessage } from "./config.js";
|
import { errorMessage } from "./config.js";
|
||||||
|
|
||||||
|
/** CraftTable MCP 服务及随附 Skill 使用的注册名称。 */
|
||||||
export const SERVER_NAME = "crafttable";
|
export const SERVER_NAME = "crafttable";
|
||||||
|
/** 支持自动配置的 Agent CLI。 */
|
||||||
export type AgentName = "codex" | "claude" | "opencode";
|
export type AgentName = "codex" | "claude" | "opencode";
|
||||||
|
/** 单个受支持 Agent 或全部受支持 Agent。 */
|
||||||
export type AgentTarget = AgentName | "all";
|
export type AgentTarget = AgentName | "all";
|
||||||
|
/** 安装或移除托管 CraftTable Skill 的结果。 */
|
||||||
export type SkillAction = "installed" | "replaced" | "removed" | "unchanged" | "absent" | "preserved" | "would-install" | "would-replace" | "would-remove" | "would-preserve";
|
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 };
|
export type CommandResult = { code: number; stdout: string; stderr: string };
|
||||||
|
/** Agent 配置使用的可注入命令执行边界。 */
|
||||||
export interface CommandRunner {
|
export interface CommandRunner {
|
||||||
|
/** 不经过 shell 执行命令并捕获其输出。 */
|
||||||
run(command: string, args: string[]): Promise<CommandResult>;
|
run(command: string, args: string[]): Promise<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 控制 MCP 注册和随附 Skill 安装的输入。 */
|
||||||
export type ConfigureInput = {
|
export type ConfigureInput = {
|
||||||
target: AgentTarget;
|
target: AgentTarget;
|
||||||
options: ClientOptions;
|
options: ClientOptions;
|
||||||
@@ -34,12 +48,22 @@ export type ConfigureInput = {
|
|||||||
skillPaths?: Partial<Record<AgentName, string>>;
|
skillPaths?: Partial<Record<AgentName, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 配置或取消配置操作中每个 Agent 的结果。 */
|
||||||
export type ConfigureResult = {
|
export type ConfigureResult = {
|
||||||
agent: AgentName;
|
agent: AgentName;
|
||||||
action: "added" | "replaced" | "removed" | "unchanged" | "absent" | "would-add" | "would-replace" | "would-remove";
|
action: "added" | "replaced" | "removed" | "unchanged" | "absent" | "would-add" | "would-replace" | "would-remove";
|
||||||
skillAction: SkillAction;
|
skillAction: SkillAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为选定 Agent 注册 stdio 桥接并安装随附 Skill。
|
||||||
|
*
|
||||||
|
* 除非现有配置与托管内容一致、用户确认替换或启用 `force`,否则保留现有配置。
|
||||||
|
*
|
||||||
|
* @param input - 目标 Agent、启动设置与变更策略。
|
||||||
|
* @returns 每个选定 Agent 的 MCP 与 Skill 操作结果。
|
||||||
|
* @throws 无法检查配置、无法取得确认或写入/CLI 命令失败时抛出。
|
||||||
|
*/
|
||||||
export async function configureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
|
export async function configureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
|
||||||
const agents = expandTarget(input.target);
|
const agents = expandTarget(input.target);
|
||||||
const command = launchCommand(input.options, input.cliEntry, input.nodePath ?? process.execPath);
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从选定 Agent 中移除托管的 MCP 注册与随附 Skill。
|
||||||
|
*
|
||||||
|
* 除非启用 `force`,否则保留用户修改过的 Skill。
|
||||||
|
*
|
||||||
|
* @param input - 目标 Agent、路径与变更策略。
|
||||||
|
* @returns 每个选定 Agent 的 MCP 与 Skill 操作结果。
|
||||||
|
* @throws 无法检查配置或写入/CLI 命令失败时抛出。
|
||||||
|
*/
|
||||||
export async function unconfigureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
|
export async function unconfigureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
|
||||||
const agents = expandTarget(input.target);
|
const agents = expandTarget(input.target);
|
||||||
const runner = input.runner ?? new SpawnCommandRunner();
|
const runner = input.runner ?? new SpawnCommandRunner();
|
||||||
@@ -88,6 +121,14 @@ async function unconfigureCliAgent(
|
|||||||
return { agent, action: "removed" };
|
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[] {
|
export function launchCommand(options: ClientOptions, cliEntry: string, nodePath: string): string[] {
|
||||||
return [
|
return [
|
||||||
path.resolve(nodePath),
|
path.resolve(nodePath),
|
||||||
@@ -147,7 +188,7 @@ function outputMatchesCommand(output: string, command: string[]): boolean {
|
|||||||
const document = JSON.parse(output) as unknown;
|
const document = JSON.parse(output) as unknown;
|
||||||
if (findCommand(document, command)) return true;
|
if (findCommand(document, command)) return true;
|
||||||
} catch {
|
} catch {
|
||||||
// Claude currently returns a human-readable record.
|
// Claude 当前返回便于阅读的文本记录。
|
||||||
}
|
}
|
||||||
return command.every((part) => output.includes(part));
|
return command.every((part) => output.includes(part));
|
||||||
}
|
}
|
||||||
@@ -201,6 +242,10 @@ async function unconfigureOpenCode(input: ConfigureInput): Promise<Omit<Configur
|
|||||||
return { agent: "opencode", action: "removed" };
|
return { agent: "opencode", action: "removed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param env - 用于解析用户配置根目录的环境变量。
|
||||||
|
* @returns 默认 OpenCode JSONC 配置路径。
|
||||||
|
*/
|
||||||
export function defaultOpenCodePath(env: NodeJS.ProcessEnv = process.env): string {
|
export function defaultOpenCodePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
const base = env.XDG_CONFIG_HOME || (process.platform === "win32"
|
const base = env.XDG_CONFIG_HOME || (process.platform === "win32"
|
||||||
? path.join(env.USERPROFILE || os.homedir(), ".config")
|
? 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");
|
return path.join(base, "opencode", "opencode.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param cliEntry - 已安装 CLI 入口模块的路径。
|
||||||
|
* @returns 与入口相邻的随附 CraftTable Skill 目录。
|
||||||
|
*/
|
||||||
export function bundledSkillPath(cliEntry: string): string {
|
export function bundledSkillPath(cliEntry: string): string {
|
||||||
return path.resolve(path.dirname(cliEntry), "..", "skills", SERVER_NAME);
|
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 {
|
export function defaultSkillPath(agent: AgentName, env: NodeJS.ProcessEnv = process.env): string {
|
||||||
const home = env.USERPROFILE || env.HOME || os.homedir();
|
const home = env.USERPROFILE || env.HOME || os.homedir();
|
||||||
if (agent === "codex") return path.join(env.CODEX_HOME || path.join(home, ".codex"), "skills", SERVER_NAME);
|
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();
|
return (result.stderr || result.stdout || `exit code ${result.code}`).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 以隐藏且不经过 shell 的子进程运行 Agent CLI 配置命令。 */
|
||||||
export class SpawnCommandRunner implements CommandRunner {
|
export class SpawnCommandRunner implements CommandRunner {
|
||||||
|
/**
|
||||||
|
* @param command - 可执行文件名称或路径。
|
||||||
|
* @param args - 不经 shell 解释而传递的原始参数列表。
|
||||||
|
* @returns 捕获的退出码、标准输出与标准错误。
|
||||||
|
* @throws 子进程无法启动时抛出。
|
||||||
|
*/
|
||||||
run(command: string, args: string[]): Promise<CommandResult> {
|
run(command: string, args: string[]): Promise<CommandResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 将远端 CraftTable MCP 服务桥接为本地 stdio MCP 服务。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +18,19 @@ import { DiscoveryStore } from "./config.js";
|
|||||||
import type { TokenStore } from "./credentials.js";
|
import type { TokenStore } from "./credentials.js";
|
||||||
import { connectRemote } from "./remote.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(
|
export async function serveBridge(
|
||||||
options: ClientOptions,
|
options: ClientOptions,
|
||||||
tokenStore: TokenStore,
|
tokenStore: TokenStore,
|
||||||
@@ -38,6 +57,12 @@ export async function serveBridge(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个将支持的请求转发到远端的本地 MCP 服务。
|
||||||
|
*
|
||||||
|
* @param upstream - 代理所需的最小远端 MCP 客户端接口。
|
||||||
|
* @returns 尚未连接、可绑定服务传输的 MCP 服务实例。
|
||||||
|
*/
|
||||||
export function createProxyServer(upstream: {
|
export function createProxyServer(upstream: {
|
||||||
listTools: (params?: { cursor?: string }) => Promise<unknown>;
|
listTools: (params?: { cursor?: string }) => Promise<unknown>;
|
||||||
callTool: (params: { name: string; arguments?: Record<string, unknown> }) => Promise<unknown>;
|
callTool: (params: { name: string; arguments?: Record<string, unknown> }) => Promise<unknown>;
|
||||||
|
|||||||
+33
-2
@@ -1,8 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 在 loopback 地址接收 OAuth 授权码回调。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { createServer, type Server } from "node:http";
|
import { createServer, type Server } from "node:http";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
|
|
||||||
|
/** loopback 回调收到的成功授权响应。 */
|
||||||
export type OAuthCallback = { code: string };
|
export type OAuthCallback = { code: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 loopback 接口接收一次 OAuth 授权码回调。
|
||||||
|
*
|
||||||
|
* 服务会拒绝包含 OAuth 错误、状态无效或缺少授权码的回调。
|
||||||
|
* 登录尝试完成后应调用 {@link close}。
|
||||||
|
*/
|
||||||
export class OAuthCallbackServer {
|
export class OAuthCallbackServer {
|
||||||
private server?: Server;
|
private server?: Server;
|
||||||
private resolveResult?: (value: OAuthCallback) => void;
|
private resolveResult?: (value: OAuthCallback) => void;
|
||||||
@@ -16,11 +29,17 @@ export class OAuthCallbackServer {
|
|||||||
private readonly port: number,
|
private readonly port: number,
|
||||||
private readonly validateState: (state: string | null) => boolean,
|
private readonly validateState: (state: string | null) => boolean,
|
||||||
) {
|
) {
|
||||||
// A very fast browser callback can arrive before loginRemote starts awaiting it.
|
// 浏览器回调可能早于 loginRemote 开始等待;预先处理拒绝,
|
||||||
// Keep rejection handled while preserving the original promise for wait().
|
// 同时为 wait() 保留原始 Promise。
|
||||||
void this.result.catch(() => undefined);
|
void this.result.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在配置的固定回调端口监听 `127.0.0.1`。
|
||||||
|
*
|
||||||
|
* @returns 端口绑定完成后解析的 Promise。
|
||||||
|
* @throws 服务已运行或端口无法绑定时抛出。
|
||||||
|
*/
|
||||||
async listen(): Promise<void> {
|
async listen(): Promise<void> {
|
||||||
if (this.server) throw new Error("OAuth callback server is already running");
|
if (this.server) throw new Error("OAuth callback server is already running");
|
||||||
this.server = createServer((request, response) => {
|
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}`);
|
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> {
|
async wait(timeoutMs = 10 * 60 * 1000): Promise<OAuthCallback> {
|
||||||
let timeout: NodeJS.Timeout | undefined;
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -75,6 +101,11 @@ export class OAuthCallbackServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止接收回调;对已停止的服务调用此方法不会产生任何操作。
|
||||||
|
*
|
||||||
|
* @returns HTTP 服务关闭后解析的 Promise。
|
||||||
|
*/
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
if (!this.server) return;
|
if (!this.server) return;
|
||||||
const server = this.server;
|
const server = this.server;
|
||||||
|
|||||||
+37
-1
@@ -1,5 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义 CraftTable MCP 客户端的命令行入口与子命令。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { configureAgents, type AgentTarget, unconfigureAgents } from "./agents.js";
|
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 };
|
type ConnectionFlags = { url?: string; clientId?: string; callbackPort?: string };
|
||||||
|
|
||||||
|
/** 交互式 OAuth、检查与 stdio 桥接流程共用的根命令。 */
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
.name("crafttable-mcp")
|
.name("crafttable-mcp")
|
||||||
.description("OAuth client and stdio bridge for 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")
|
.option("--force", "replace a conflicting entry without prompting")
|
||||||
.action(async (agent: AgentTarget, flags: ConnectionFlags & { dryRun?: boolean; force?: boolean }) => {
|
.action(async (agent: AgentTarget, flags: ConnectionFlags & { dryRun?: boolean; force?: boolean }) => {
|
||||||
const cliEntry = fileURLToPath(import.meta.url);
|
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 = {
|
const input = {
|
||||||
target: agent,
|
target: agent,
|
||||||
options: connectionOptions(flags),
|
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 {
|
function withConnection(command: Command): Command {
|
||||||
return command
|
return command
|
||||||
.option("--url <url>", "MCP Streamable HTTP URL")
|
.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");
|
.option("--callback-port <port>", "fixed localhost OAuth callback port");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析并验证一次命令调用的连接选项。 */
|
||||||
function connectionOptions(flags: ConnectionFlags): ClientOptions {
|
function connectionOptions(flags: ConnectionFlags): ClientOptions {
|
||||||
return resolveClientOptions(flags);
|
return resolveClientOptions(flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用已连接的远端客户端执行操作,并确保连接始终关闭。
|
||||||
|
*
|
||||||
|
* @param options - 已验证的远端连接设置。
|
||||||
|
* @param action - 使用已连接 MCP 客户端执行的操作。
|
||||||
|
*/
|
||||||
async function withClient(options: ClientOptions, action: (client: Awaited<ReturnType<typeof connectRemote>>["client"]) => Promise<void>): Promise<void> {
|
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());
|
const connection = await connectRemote(options, new KeyringTokenStore(), new DiscoveryStore());
|
||||||
try {
|
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> {
|
function parseObject(raw: string): Record<string, unknown> {
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -124,12 +151,21 @@ function parseObject(raw: string): Record<string, unknown> {
|
|||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析正整数命令选项。
|
||||||
|
*
|
||||||
|
* @param value - 原始 CLI 选项值。
|
||||||
|
* @param name - 错误消息中使用的可读选项名称。
|
||||||
|
* @returns 经过验证的整数。
|
||||||
|
* @throws 值不是正整数时抛出。
|
||||||
|
*/
|
||||||
function positiveInteger(value: string, name: string): number {
|
function positiveInteger(value: string, name: string): number {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将稳定且便于阅读的 JSON 结果写入标准输出。 */
|
||||||
function printJson(value: unknown): void {
|
function printJson(value: unknown): void {
|
||||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 解析 MCP 连接配置并管理 OAuth 发现缓存。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
import type { OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
|
||||||
|
/** CLI 使用的默认公开 Streamable HTTP 端点。 */
|
||||||
export const DEFAULT_MCP_URL = "https://crafttable.crash.work/mcp";
|
export const DEFAULT_MCP_URL = "https://crafttable.crash.work/mcp";
|
||||||
|
/** 为 CLI 注册的 OAuth 公共客户端标识。 */
|
||||||
export const DEFAULT_CLIENT_ID = "crafttable-mcp-cli";
|
export const DEFAULT_CLIENT_ID = "crafttable-mcp-cli";
|
||||||
|
/** 注册为 OAuth 重定向 URI 的固定 loopback 端口。 */
|
||||||
export const DEFAULT_CALLBACK_PORT = 48321;
|
export const DEFAULT_CALLBACK_PORT = 48321;
|
||||||
|
/** 交互式登录时请求的 OAuth 作用域。 */
|
||||||
export const OAUTH_SCOPES = "openid profile email offline_access";
|
export const OAUTH_SCOPES = "openid profile email offline_access";
|
||||||
|
|
||||||
|
/** MCP 操作使用的已解析连接与 OAuth 回调设置。 */
|
||||||
export type ClientOptions = {
|
export type ClientOptions = {
|
||||||
url: URL;
|
url: URL;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
callbackPort: number;
|
callbackPort: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 CLI 覆盖项、环境变量与默认值解析为经过验证的设置。
|
||||||
|
*
|
||||||
|
* @param input - 可选命令标志与环境变量来源。
|
||||||
|
* @returns 规范化的客户端设置。
|
||||||
|
* @throws URL、客户端 ID 或回调端口无效时抛出。
|
||||||
|
*/
|
||||||
export function resolveClientOptions(input: {
|
export function resolveClientOptions(input: {
|
||||||
url?: string;
|
url?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
@@ -32,6 +50,16 @@ export function resolveClientOptions(input: {
|
|||||||
return { url, clientId, callbackPort };
|
return { url, clientId, callbackPort };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证并规范化远端 MCP URL。
|
||||||
|
*
|
||||||
|
* 除 loopback 开发端点外必须使用 HTTPS;为避免泄露认证数据,
|
||||||
|
* URL 中不允许包含凭据、查询字符串或片段。
|
||||||
|
*
|
||||||
|
* @param value - 用户或环境变量提供的绝对 URL。
|
||||||
|
* @returns 去除路径末尾斜杠后的规范化 URL。
|
||||||
|
* @throws URL 格式错误或违反传输策略时抛出。
|
||||||
|
*/
|
||||||
export function validateMcpUrl(value: string): URL {
|
export function validateMcpUrl(value: string): URL {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
@@ -50,10 +78,23 @@ export function validateMcpUrl(value: string): URL {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派生稳定且不包含秘密的凭据存储账户键。
|
||||||
|
*
|
||||||
|
* @param options - 定义账户的服务 URL 与 OAuth 客户端标识。
|
||||||
|
* @returns SHA-256 账户标识。
|
||||||
|
*/
|
||||||
export function credentialAccount(options: Pick<ClientOptions, "url" | "clientId">): string {
|
export function credentialAccount(options: Pick<ClientOptions, "url" | "clientId">): string {
|
||||||
return createHash("sha256").update(`${options.url.toString()}\0${options.clientId}`).digest("hex");
|
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 {
|
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 === "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");
|
if (platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "GameCraftTable", "mcp-client");
|
||||||
@@ -65,6 +106,7 @@ type DiscoveryFile = {
|
|||||||
entries: Record<string, OAuthDiscoveryState>;
|
entries: Record<string, OAuthDiscoveryState>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 在用户非敏感配置目录中存储 OAuth 发现状态。 */
|
||||||
export class DiscoveryStore {
|
export class DiscoveryStore {
|
||||||
readonly filePath: string;
|
readonly filePath: string;
|
||||||
|
|
||||||
@@ -72,16 +114,27 @@ export class DiscoveryStore {
|
|||||||
this.filePath = path.join(configDir, "discovery.json");
|
this.filePath = path.join(configDir, "discovery.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param account - 稳定账户标识。
|
||||||
|
* @returns 账户的缓存发现状态;不存在时返回 `undefined`。
|
||||||
|
*/
|
||||||
async get(account: string): Promise<OAuthDiscoveryState | undefined> {
|
async get(account: string): Promise<OAuthDiscoveryState | undefined> {
|
||||||
return (await this.read()).entries[account];
|
return (await this.read()).entries[account];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用原子文件替换持久化账户的发现状态。
|
||||||
|
*
|
||||||
|
* @param account - 稳定账户标识。
|
||||||
|
* @param state - MCP OAuth 客户端提供的发现状态。
|
||||||
|
*/
|
||||||
async set(account: string, state: OAuthDiscoveryState): Promise<void> {
|
async set(account: string, state: OAuthDiscoveryState): Promise<void> {
|
||||||
const document = await this.read();
|
const document = await this.read();
|
||||||
document.entries[account] = state;
|
document.entries[account] = state;
|
||||||
await atomicWriteJson(this.filePath, document);
|
await atomicWriteJson(this.filePath, document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param account - 要删除其状态的稳定账户标识。 */
|
||||||
async delete(account: string): Promise<void> {
|
async delete(account: string): Promise<void> {
|
||||||
const document = await this.read();
|
const document = await this.read();
|
||||||
if (!(account in document.entries)) return;
|
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> {
|
export async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
|
||||||
await mkdir(path.dirname(filePath), { recursive: true });
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
const temporary = `${filePath}.${process.pid}.tmp`;
|
const temporary = `${filePath}.${process.pid}.tmp`;
|
||||||
@@ -107,6 +167,7 @@ export async function atomicWriteJson(filePath: string, value: unknown): Promise
|
|||||||
await rename(temporary, filePath);
|
await rename(temporary, filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将未知的抛出值转换为可显示的错误消息。 */
|
||||||
export function errorMessage(error: unknown): string {
|
export function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-4
@@ -1,11 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 使用操作系统凭据管理器安全持久化 OAuth 令牌。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
|
||||||
|
/** 操作系统凭据存储中使用的服务名称。 */
|
||||||
export const KEYRING_SERVICE = "CraftTable MCP";
|
export const KEYRING_SERVICE = "CraftTable MCP";
|
||||||
const KEYRING_CHUNK_SIZE = 1000;
|
const KEYRING_CHUNK_SIZE = 1000;
|
||||||
|
|
||||||
type CredentialEntry = {
|
type CredentialEntry = {
|
||||||
getPassword(): Promise<string | undefined>;
|
getPassword(): Promise<string | null | undefined>;
|
||||||
setPassword(password: string): Promise<void>;
|
setPassword(password: string): Promise<void>;
|
||||||
deleteCredential(): Promise<boolean>;
|
deleteCredential(): Promise<boolean>;
|
||||||
};
|
};
|
||||||
@@ -18,15 +25,30 @@ type ChunkManifest = {
|
|||||||
chunks: number;
|
chunks: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 以账户哈希为键安全持久化 OAuth 令牌集的契约。 */
|
||||||
export interface TokenStore {
|
export interface TokenStore {
|
||||||
|
/** 返回账户的令牌集;不存在时返回 `undefined`。 */
|
||||||
get(account: string): Promise<OAuthTokens | undefined>;
|
get(account: string): Promise<OAuthTokens | undefined>;
|
||||||
|
/** 替换账户的令牌集。 */
|
||||||
set(account: string, tokens: OAuthTokens): Promise<void>;
|
set(account: string, tokens: OAuthTokens): Promise<void>;
|
||||||
delete(account: string): Promise<void>;
|
/** 删除账户的全部已存储令牌数据,返回是否存在凭据。 */
|
||||||
|
delete(account: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在操作系统凭据管理器中存储 OAuth 令牌。
|
||||||
|
*
|
||||||
|
* 部分凭据管理器的单条记录上限较小,因此令牌负载会被分块。
|
||||||
|
* 此实现有意不支持明文后备存储。
|
||||||
|
*/
|
||||||
export class KeyringTokenStore implements TokenStore {
|
export class KeyringTokenStore implements TokenStore {
|
||||||
constructor(private readonly entryFactory: CredentialEntryFactory = keyringEntry) {}
|
constructor(private readonly entryFactory: CredentialEntryFactory = keyringEntry) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param account - 稳定凭据账户标识。
|
||||||
|
* @returns 解码后的令牌集;不存在凭据时返回 `undefined`。
|
||||||
|
* @throws 凭据存储不可用或已存数据无效时抛出。
|
||||||
|
*/
|
||||||
async get(account: string): Promise<OAuthTokens | undefined> {
|
async get(account: string): Promise<OAuthTokens | undefined> {
|
||||||
let serialized: string | undefined;
|
let serialized: string | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +80,13 @@ export class KeyringTokenStore implements TokenStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子发布新一代分块,并删除上一代分块。
|
||||||
|
*
|
||||||
|
* @param account - 稳定凭据账户标识。
|
||||||
|
* @param tokens - 要安全存储的 OAuth 令牌。
|
||||||
|
* @throws 任一凭据存储操作失败时抛出。
|
||||||
|
*/
|
||||||
async set(account: string, tokens: OAuthTokens): Promise<void> {
|
async set(account: string, tokens: OAuthTokens): Promise<void> {
|
||||||
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
|
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
|
||||||
const chunks = splitCredential(encoded);
|
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 {
|
try {
|
||||||
const serialized = await this.readPassword(account);
|
const serialized = await this.readPassword(account);
|
||||||
const manifest = serialized ? parseChunkManifest(serialized) : undefined;
|
const manifest = serialized ? parseChunkManifest(serialized) : undefined;
|
||||||
@@ -95,6 +130,7 @@ export class KeyringTokenStore implements TokenStore {
|
|||||||
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) =>
|
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) =>
|
||||||
this.deletePassword(chunkAccount(account, manifest.generation, index))));
|
this.deletePassword(chunkAccount(account, manifest.generation, index))));
|
||||||
}
|
}
|
||||||
|
return serialized !== undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw keyringError(error);
|
throw keyringError(error);
|
||||||
}
|
}
|
||||||
@@ -102,7 +138,7 @@ export class KeyringTokenStore implements TokenStore {
|
|||||||
|
|
||||||
private async readPassword(account: string): Promise<string | undefined> {
|
private async readPassword(account: string): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
return await (await this.entryFactory(account)).getPassword();
|
return (await (await this.entryFactory(account)).getPassword()) ?? undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingCredential(error)) return undefined;
|
if (isMissingCredential(error)) return undefined;
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 实现 MCP SDK 所需的 CraftTable OAuth 提供者。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/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 { credentialAccount, DiscoveryStore, OAUTH_SCOPES } from "./config.js";
|
||||||
import type { TokenStore } from "./credentials.js";
|
import type { TokenStore } from "./credentials.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由安全令牌存储和发现缓存支持的 MCP SDK OAuth 提供者。
|
||||||
|
*
|
||||||
|
* 每个实例拥有独立的随机 OAuth state 和内存 PKCE verifier,
|
||||||
|
* 防止这些值在不同登录尝试间共享。
|
||||||
|
*/
|
||||||
export class CraftTableOAuthProvider implements OAuthClientProvider {
|
export class CraftTableOAuthProvider implements OAuthClientProvider {
|
||||||
readonly redirectUrl: URL;
|
readonly redirectUrl: URL;
|
||||||
readonly clientMetadata: OAuthClientMetadata;
|
readonly clientMetadata: OAuthClientMetadata;
|
||||||
@@ -12,6 +24,13 @@ export class CraftTableOAuthProvider implements OAuthClientProvider {
|
|||||||
private readonly expectedState: string;
|
private readonly expectedState: string;
|
||||||
private codeVerifierValue?: string;
|
private codeVerifierValue?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param options - 远端端点与已注册公共客户端设置。
|
||||||
|
* @param tokenStore - OAuth 令牌安全持久化接口。
|
||||||
|
* @param discoveryStore - 授权服务器发现状态缓存。
|
||||||
|
* @param onRedirect - 打开或显示授权 URL 的处理器。
|
||||||
|
* @param state - 预期回调状态,可注入以实现确定性测试。
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly options: ClientOptions,
|
private readonly options: ClientOptions,
|
||||||
private readonly tokenStore: TokenStore,
|
private readonly tokenStore: TokenStore,
|
||||||
@@ -32,10 +51,17 @@ export class CraftTableOAuthProvider implements OAuthClientProvider {
|
|||||||
this.expectedState = state;
|
this.expectedState = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns 授权回调必须携带的 state 值。 */
|
||||||
state(): string {
|
state(): string {
|
||||||
return this.expectedState;
|
return this.expectedState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在长度一致时以常量时间比较回调 state。
|
||||||
|
*
|
||||||
|
* @param value - 从 loopback 回调收到的 state。
|
||||||
|
* @returns 回调是否属于本次授权尝试。
|
||||||
|
*/
|
||||||
validateState(value: string | null): boolean {
|
validateState(value: string | null): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
const expected = Buffer.from(this.expectedState);
|
const expected = Buffer.from(this.expectedState);
|
||||||
@@ -43,39 +69,55 @@ export class CraftTableOAuthProvider implements OAuthClientProvider {
|
|||||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns MCP SDK 使用的静态公共客户端注册信息。 */
|
||||||
clientInformation(): OAuthClientInformationMixed {
|
clientInformation(): OAuthClientInformationMixed {
|
||||||
return { client_id: this.options.clientId };
|
return { client_id: this.options.clientId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns 当前服务/客户端组合已保存的 OAuth 令牌。 */
|
||||||
tokens(): Promise<OAuthTokens | undefined> {
|
tokens(): Promise<OAuthTokens | undefined> {
|
||||||
return this.tokenStore.get(this.account);
|
return this.tokenStore.get(this.account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 持久化刷新或新签发的 OAuth 令牌集。 */
|
||||||
saveTokens(tokens: OAuthTokens): Promise<void> {
|
saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
return this.tokenStore.set(this.account, tokens);
|
return this.tokenStore.set(this.account, tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将授权 URL 交给 CLI 的浏览器或手动登录处理器。 */
|
||||||
redirectToAuthorization(url: URL): void | Promise<void> {
|
redirectToAuthorization(url: URL): void | Promise<void> {
|
||||||
return this.onRedirect(url);
|
return this.onRedirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在本次登录尝试期间将 PKCE verifier 保存在内存中。 */
|
||||||
saveCodeVerifier(codeVerifier: string): void {
|
saveCodeVerifier(codeVerifier: string): void {
|
||||||
this.codeVerifierValue = codeVerifier;
|
this.codeVerifierValue = codeVerifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns 为当前授权尝试保存的 PKCE verifier。
|
||||||
|
* @throws 尚未生成 verifier 或其已失效时抛出。
|
||||||
|
*/
|
||||||
codeVerifier(): string {
|
codeVerifier(): string {
|
||||||
if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired");
|
if (!this.codeVerifierValue) throw new Error("OAuth PKCE verifier is missing or expired");
|
||||||
return this.codeVerifierValue;
|
return this.codeVerifierValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns 当前服务/客户端组合的 OAuth 发现缓存。 */
|
||||||
discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
||||||
return this.discoveryStore.get(this.account);
|
return this.discoveryStore.get(this.account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在凭据存储之外持久化 OAuth 发现状态。 */
|
||||||
saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
|
saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
|
||||||
return this.discoveryStore.set(this.account, state);
|
return this.discoveryStore.set(this.account, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使 MCP OAuth 客户端指定的凭据材料失效。
|
||||||
|
*
|
||||||
|
* @param scope - 要清理的凭据类别。
|
||||||
|
*/
|
||||||
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
|
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 === "tokens") await this.tokenStore.delete(this.account);
|
||||||
if (scope === "all" || scope === "discovery") await this.discoveryStore.delete(this.account);
|
if (scope === "all" || scope === "discovery") await this.discoveryStore.delete(this.account);
|
||||||
|
|||||||
+58
-3
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 建立远端 MCP 连接,并编排 OAuth 登录、注销与元数据发现。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.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 type { TokenStore } from "./credentials.js";
|
||||||
import { CraftTableOAuthProvider } from "./oauthProvider.js";
|
import { CraftTableOAuthProvider } from "./oauthProvider.js";
|
||||||
|
|
||||||
|
/** 已连接的远端 MCP 客户端及其所选鉴权模式。 */
|
||||||
export type RemoteConnection = {
|
export type RemoteConnection = {
|
||||||
client: Client;
|
client: Client;
|
||||||
transport: StreamableHTTPClientTransport;
|
transport: StreamableHTTPClientTransport;
|
||||||
@@ -16,6 +23,18 @@ export type RemoteConnection = {
|
|||||||
close: () => Promise<void>;
|
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(
|
export async function connectRemote(
|
||||||
options: ClientOptions,
|
options: ClientOptions,
|
||||||
tokenStore: TokenStore,
|
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");
|
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(
|
export async function loginRemote(
|
||||||
options: ClientOptions,
|
options: ClientOptions,
|
||||||
tokenStore: TokenStore,
|
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(
|
export async function logoutRemote(
|
||||||
options: ClientOptions,
|
options: ClientOptions,
|
||||||
tokenStore: TokenStore,
|
tokenStore: TokenStore,
|
||||||
@@ -95,14 +137,19 @@ export async function logoutRemote(
|
|||||||
fetchFn: typeof fetch = fetch,
|
fetchFn: typeof fetch = fetch,
|
||||||
): Promise<{ hadCredential: boolean; revoked: boolean }> {
|
): Promise<{ hadCredential: boolean; revoked: boolean }> {
|
||||||
const account = credentialAccount(options);
|
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);
|
const tokens = await tokenStore.get(account);
|
||||||
if (tokens && !localOnly) {
|
if (tokens) {
|
||||||
const endpoint = await discoverRevocationEndpoint(options.url, fetchFn);
|
const endpoint = await discoverRevocationEndpoint(options.url, fetchFn);
|
||||||
await revokeTokens(endpoint, options.clientId, tokens, fetchFn);
|
await revokeTokens(endpoint, options.clientId, tokens, fetchFn);
|
||||||
}
|
}
|
||||||
await tokenStore.delete(account);
|
await tokenStore.delete(account);
|
||||||
await discoveryStore.delete(account);
|
await discoveryStore.delete(account);
|
||||||
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens && !localOnly) };
|
return { hadCredential: Boolean(tokens), revoked: Boolean(tokens) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectWithTransport(
|
async function connectWithTransport(
|
||||||
@@ -144,12 +191,20 @@ async function discoverRevocationEndpoint(resource: URL, fetchFn: typeof fetch):
|
|||||||
const metadata = await response.json() as { revocation_endpoint?: unknown };
|
const metadata = await response.json() as { revocation_endpoint?: unknown };
|
||||||
if (typeof metadata.revocation_endpoint === "string") return new URL(metadata.revocation_endpoint);
|
if (typeof metadata.revocation_endpoint === "string") return new URL(metadata.revocation_endpoint);
|
||||||
} catch {
|
} catch {
|
||||||
// Try the next standards-compatible discovery location.
|
// 尝试下一个符合标准的发现位置。
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("OAuth authorization server does not advertise a revocation endpoint");
|
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(
|
export async function requireOAuthProtectedResourceMetadata(
|
||||||
resource: URL,
|
resource: URL,
|
||||||
fetchFn: typeof fetch = fetch,
|
fetchFn: typeof fetch = fetch,
|
||||||
|
|||||||
+7
-1
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 覆盖 Agent 配置适配器、Skill 目录解析、幂等配置、冲突替换与备份行为。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
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 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");
|
const nodePath = path.resolve("bin", "node.exe");
|
||||||
|
|
||||||
test("Agent Skill paths use each client's user-level discovery directory", () => {
|
test("Agent Skill paths use each client's user-level discovery directory", () => {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 覆盖 stdio 代理对 MCP 工具、资源、模板、游标与上游错误的转发行为。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 覆盖连接配置校验、OAuth state/回调、元数据检查与令牌撤销边界。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
@@ -12,7 +18,7 @@ class MemoryTokens implements TokenStore {
|
|||||||
readonly values = new Map<string, OAuthTokens>();
|
readonly values = new Map<string, OAuthTokens>();
|
||||||
async get(account: string): Promise<OAuthTokens | undefined> { return this.values.get(account); }
|
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 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 {
|
class MemoryDiscovery {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 覆盖系统凭据存储适配器的令牌分块、轮换清理与旧格式兼容行为。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { KeyringTokenStore } from "../src/credentials.js";
|
import { KeyringTokenStore } from "../src/credentials.js";
|
||||||
|
import { credentialAccount, resolveClientOptions } from "../src/config.js";
|
||||||
|
import { logoutRemote } from "../src/remote.js";
|
||||||
|
|
||||||
class MemoryEntry {
|
class MemoryEntry {
|
||||||
constructor(private readonly values: Map<string, string>, private readonly account: string) {}
|
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");
|
await store.delete("account");
|
||||||
assert.equal(values.size, 0);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 通过本地 OAuth 与 MCP 模拟服务验证完整 PKCE 登录、令牌刷新和秘密隔离流程。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
@@ -15,7 +21,7 @@ class MemoryTokens implements TokenStore {
|
|||||||
readonly values = new Map<string, OAuthTokens>();
|
readonly values = new Map<string, OAuthTokens>();
|
||||||
async get(account: string): Promise<OAuthTokens | undefined> { return this.values.get(account); }
|
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 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 {
|
class MemoryDiscovery {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 为 stdio 协议集成测试提供最小 MCP 服务进程。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { createProxyServer } from "../src/bridge.js";
|
import { createProxyServer } from "../src/bridge.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 验证 stdio 模式的标准输出只包含合法 JSON-RPC 消息,不混入诊断文本。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|||||||
Reference in New Issue
Block a user