@crush-protocol/mcp-client 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTRUCTIONS.md +21 -1
- package/README.md +278 -369
- package/dist/cli.js +140 -12
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/mcp/authPreflight.d.ts +9 -0
- package/dist/mcp/authPreflight.js +26 -0
- package/dist/mcp/oauthProvider.d.ts +2 -0
- package/dist/mcp/oauthProvider.js +21 -11
- package/dist/mcp/oauthRemoteClient.d.ts +3 -0
- package/dist/mcp/oauthRemoteClient.js +31 -18
- package/dist/mcp/oauthStorage.d.ts +29 -0
- package/dist/mcp/oauthStorage.js +81 -0
- package/dist/mcp/proxy.js +30 -137
- package/dist/onboarding/cliOutput.d.ts +14 -0
- package/dist/onboarding/cliOutput.js +80 -0
- package/dist/setup/setupClients.d.ts +8 -1
- package/dist/setup/setupClients.js +23 -20
- package/package.json +24 -3
- package/dist/__tests__/e2e.test.d.ts +0 -1
- package/dist/__tests__/e2e.test.js +0 -50
- package/dist/__tests__/oauthProvider.test.d.ts +0 -1
- package/dist/__tests__/oauthProvider.test.js +0 -45
package/dist/mcp/proxy.js
CHANGED
|
@@ -3,181 +3,74 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 原理:
|
|
5
5
|
* AI 工具(Cursor/Claude/Antigravity)通过 stdio 连接本 proxy,
|
|
6
|
-
* proxy
|
|
7
|
-
* 将请求转发到远程 MCP Server(Streamable HTTP)。
|
|
6
|
+
* proxy 将请求转发到远程 MCP Server(Streamable HTTP)。
|
|
8
7
|
*
|
|
9
8
|
* 使用:
|
|
10
9
|
* npx @crush-protocol/mcp-client proxy [SERVER_URL]
|
|
11
10
|
*
|
|
12
11
|
* 用户只需执行一次 `login` 获取 token,所有 AI 工具共享同一份凭证。
|
|
13
12
|
*/
|
|
14
|
-
import { createHash } from "node:crypto";
|
|
15
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
16
|
-
import os from "node:os";
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
13
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
14
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
15
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, PingRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
16
|
+
import { getPreflightStatusMessage } from "./authPreflight.js";
|
|
17
|
+
import { OAuthRemoteMcpClient } from "./oauthRemoteClient.js";
|
|
18
|
+
import { getCachedAuthStatus } from "./oauthStorage.js";
|
|
23
19
|
import { CLIENT_NAME, CLIENT_VERSION } from "./version.js";
|
|
24
|
-
const STORAGE_DIR = path.join(os.homedir(), ".crush-mcp");
|
|
25
|
-
const hashServerUrl = (serverUrl) => createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
|
|
26
|
-
const loadTokens = async (serverUrl) => {
|
|
27
|
-
// 尝试多个可能的 URL 变体(login 用 base URL,proxy 用 /mcp URL)
|
|
28
|
-
const candidates = [serverUrl];
|
|
29
|
-
if (serverUrl.endsWith("/mcp")) {
|
|
30
|
-
candidates.push(serverUrl.replace(/\/mcp$/, ""));
|
|
31
|
-
}
|
|
32
|
-
else {
|
|
33
|
-
candidates.push(`${serverUrl}/mcp`);
|
|
34
|
-
}
|
|
35
|
-
for (const url of candidates) {
|
|
36
|
-
const storageFile = path.join(STORAGE_DIR, `oauth-${hashServerUrl(url)}.json`);
|
|
37
|
-
try {
|
|
38
|
-
const raw = await readFile(storageFile, "utf8");
|
|
39
|
-
const state = JSON.parse(raw);
|
|
40
|
-
if (state.tokens?.access_token) {
|
|
41
|
-
return state.tokens;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
// 继续尝试下一个
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return undefined;
|
|
49
|
-
};
|
|
50
|
-
const saveTokens = async (serverUrl, tokens) => {
|
|
51
|
-
const storageFile = path.join(STORAGE_DIR, `oauth-${hashServerUrl(serverUrl)}.json`);
|
|
52
|
-
let state = {};
|
|
53
|
-
try {
|
|
54
|
-
const raw = await readFile(storageFile, "utf8");
|
|
55
|
-
state = JSON.parse(raw);
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
// ignore
|
|
59
|
-
}
|
|
60
|
-
state.tokens = tokens;
|
|
61
|
-
await mkdir(path.dirname(storageFile), { recursive: true });
|
|
62
|
-
await writeFile(storageFile, JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
63
|
-
};
|
|
64
|
-
/**
|
|
65
|
-
* 尝试用 refresh_token 刷新 access_token
|
|
66
|
-
*/
|
|
67
|
-
const refreshAccessToken = async (serverUrl, tokens) => {
|
|
68
|
-
if (!tokens.refresh_token)
|
|
69
|
-
return null;
|
|
70
|
-
try {
|
|
71
|
-
// 先获取 token_endpoint
|
|
72
|
-
const metadataUrl = new URL("/.well-known/oauth-authorization-server", serverUrl);
|
|
73
|
-
const metaRes = await fetch(metadataUrl.toString());
|
|
74
|
-
if (!metaRes.ok)
|
|
75
|
-
return null;
|
|
76
|
-
const meta = (await metaRes.json());
|
|
77
|
-
// 获取 client_id
|
|
78
|
-
const storageFile = path.join(STORAGE_DIR, `oauth-${hashServerUrl(serverUrl)}.json`);
|
|
79
|
-
const raw = await readFile(storageFile, "utf8");
|
|
80
|
-
const state = JSON.parse(raw);
|
|
81
|
-
const clientInfo = state.clientInformation;
|
|
82
|
-
if (!clientInfo?.client_id)
|
|
83
|
-
return null;
|
|
84
|
-
const body = new URLSearchParams({
|
|
85
|
-
grant_type: "refresh_token",
|
|
86
|
-
refresh_token: tokens.refresh_token,
|
|
87
|
-
client_id: clientInfo.client_id,
|
|
88
|
-
});
|
|
89
|
-
const res = await fetch(meta.token_endpoint, {
|
|
90
|
-
method: "POST",
|
|
91
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
92
|
-
body: body.toString(),
|
|
93
|
-
});
|
|
94
|
-
if (!res.ok)
|
|
95
|
-
return null;
|
|
96
|
-
const newTokens = (await res.json());
|
|
97
|
-
await saveTokens(serverUrl, newTokens);
|
|
98
|
-
return newTokens;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
20
|
export async function runProxy(serverUrl) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
21
|
+
const authStatus = await getCachedAuthStatus(serverUrl);
|
|
22
|
+
if (authStatus.status === "not_authenticated") {
|
|
23
|
+
const message = getPreflightStatusMessage({
|
|
24
|
+
authStatus,
|
|
25
|
+
requiresManualLogin: true,
|
|
26
|
+
canAutoRefreshLogin: false,
|
|
27
|
+
});
|
|
28
|
+
process.stderr.write(`${message ?? "Crush needs sign-in on this machine."}\n`);
|
|
110
29
|
process.exit(1);
|
|
111
30
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
Authorization: `Bearer ${token}`,
|
|
117
|
-
},
|
|
118
|
-
},
|
|
31
|
+
const preflightMessage = getPreflightStatusMessage({
|
|
32
|
+
authStatus,
|
|
33
|
+
requiresManualLogin: false,
|
|
34
|
+
canAutoRefreshLogin: true,
|
|
119
35
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
let transport = createTransport(tokens.access_token);
|
|
123
|
-
try {
|
|
124
|
-
await remoteClient.connect(transport);
|
|
36
|
+
if (preflightMessage) {
|
|
37
|
+
process.stderr.write(`${preflightMessage}\n\n`);
|
|
125
38
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
` npx @crush-protocol/mcp-client login ${serverUrl}\n`);
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
136
|
-
tokens = refreshed;
|
|
137
|
-
transport = createTransport(tokens.access_token);
|
|
138
|
-
await remoteClient.connect(transport);
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
throw error;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
// 3. 获取远程 server 的能力
|
|
145
|
-
const serverInfo = remoteClient.getServerVersion();
|
|
39
|
+
const remoteClient = new OAuthRemoteMcpClient({
|
|
40
|
+
serverUrl,
|
|
41
|
+
oauth: {
|
|
42
|
+
authorizationOutput: "stderr",
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
await remoteClient.connect();
|
|
146
46
|
const remoteTools = await remoteClient.listTools();
|
|
147
|
-
// 4. 创建本地 stdio server,代理所有请求
|
|
148
47
|
const localServer = new Server({
|
|
149
|
-
name:
|
|
150
|
-
version:
|
|
48
|
+
name: `${CLIENT_NAME}-proxy`,
|
|
49
|
+
version: CLIENT_VERSION,
|
|
151
50
|
}, {
|
|
152
51
|
capabilities: {
|
|
153
52
|
tools: { listChanged: false },
|
|
154
53
|
},
|
|
155
54
|
});
|
|
156
|
-
// 代理 tools/list
|
|
157
55
|
localServer.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
158
56
|
try {
|
|
159
|
-
|
|
160
|
-
return result;
|
|
57
|
+
return await remoteClient.listTools();
|
|
161
58
|
}
|
|
162
59
|
catch {
|
|
163
60
|
return { tools: remoteTools.tools };
|
|
164
61
|
}
|
|
165
62
|
});
|
|
166
|
-
// 代理 tools/call
|
|
167
63
|
localServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
168
64
|
const { name, arguments: args } = request.params;
|
|
169
|
-
return remoteClient.callTool(
|
|
65
|
+
return remoteClient.callTool(name, (args ?? {}));
|
|
170
66
|
});
|
|
171
|
-
// 代理 ping
|
|
172
67
|
localServer.setRequestHandler(PingRequestSchema, async () => {
|
|
173
68
|
await remoteClient.ping();
|
|
174
69
|
return {};
|
|
175
70
|
});
|
|
176
|
-
// 5. 启动 stdio transport
|
|
177
71
|
const stdioTransport = new StdioServerTransport();
|
|
178
72
|
await localServer.connect(stdioTransport);
|
|
179
73
|
process.stderr.write(`[crush-mcp-proxy] Connected to ${serverUrl} | stdio proxy ready\n`);
|
|
180
|
-
// 优雅关闭
|
|
181
74
|
const cleanup = async () => {
|
|
182
75
|
await localServer.close();
|
|
183
76
|
await remoteClient.close();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CachedAuthStatus } from "../mcp/oauthStorage.js";
|
|
2
|
+
import type { SetupInstallResult, SetupScope, SetupTarget } from "../setup/setupClients.js";
|
|
3
|
+
export type DoctorCheck = {
|
|
4
|
+
status: "ok" | "warn" | "error";
|
|
5
|
+
label: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const getTargetLabel: (target: SetupTarget) => string;
|
|
9
|
+
export declare const getLoginCommand: (serverUrl: string) => string;
|
|
10
|
+
export declare const formatLoginRequiredMessage: (serverUrl: string) => string;
|
|
11
|
+
export declare const formatAutomaticRefreshMessage: (serverUrl: string) => string;
|
|
12
|
+
export declare const formatSetupSummary: (results: SetupInstallResult[], scope: SetupScope) => string;
|
|
13
|
+
export declare const formatAuthStatus: (status: CachedAuthStatus) => string;
|
|
14
|
+
export declare const formatDoctorReport: (serverUrl: string, checks: DoctorCheck[]) => string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DEFAULT_MCP_SERVER_URL, PACKAGE_NAME } from "../config.js";
|
|
2
|
+
const TARGET_LABELS = {
|
|
3
|
+
cursor: "Cursor",
|
|
4
|
+
claude: "Claude Code",
|
|
5
|
+
codex: "Codex",
|
|
6
|
+
gemini: "Gemini CLI",
|
|
7
|
+
opencode: "OpenCode",
|
|
8
|
+
vscode: "VS Code",
|
|
9
|
+
windsurf: "Windsurf",
|
|
10
|
+
"claude-desktop": "Claude Desktop",
|
|
11
|
+
warp: "Warp",
|
|
12
|
+
};
|
|
13
|
+
export const getTargetLabel = (target) => TARGET_LABELS[target];
|
|
14
|
+
export const getLoginCommand = (serverUrl) => serverUrl === DEFAULT_MCP_SERVER_URL
|
|
15
|
+
? `npx -y ${PACKAGE_NAME} login`
|
|
16
|
+
: `CRUSH_MCP_SERVER_URL=\"${serverUrl}\" npx -y ${PACKAGE_NAME} login`;
|
|
17
|
+
export const formatLoginRequiredMessage = (serverUrl) => [
|
|
18
|
+
"Crush needs sign-in on this machine.",
|
|
19
|
+
"",
|
|
20
|
+
"Run this once in your terminal:",
|
|
21
|
+
getLoginCommand(serverUrl),
|
|
22
|
+
"",
|
|
23
|
+
"Then retry the same request.",
|
|
24
|
+
].join("\n");
|
|
25
|
+
export const formatAutomaticRefreshMessage = (serverUrl) => [
|
|
26
|
+
"Crush found previous local authorization data.",
|
|
27
|
+
"Refreshing the sign-in flow automatically...",
|
|
28
|
+
"",
|
|
29
|
+
"If the browser does not open, run:",
|
|
30
|
+
getLoginCommand(serverUrl),
|
|
31
|
+
].join("\n");
|
|
32
|
+
export const formatSetupSummary = (results, scope) => {
|
|
33
|
+
const targets = results.map((result) => getTargetLabel(result.target)).join(", ");
|
|
34
|
+
const lines = [
|
|
35
|
+
`Crush MCP configured for ${targets}.`,
|
|
36
|
+
"",
|
|
37
|
+
"Configuration:",
|
|
38
|
+
...results.map((result) => `- ${getTargetLabel(result.target)}: ${result.status} (${result.location})`),
|
|
39
|
+
"",
|
|
40
|
+
"Next steps:",
|
|
41
|
+
`1. Restart ${results.length === 1 ? getTargetLabel(results[0].target) : "your AI host"} to load the new MCP server.`,
|
|
42
|
+
`2. Authenticate once: ${getLoginCommand(DEFAULT_MCP_SERVER_URL)}`,
|
|
43
|
+
"3. Return to your AI host and ask it to use Crush tools.",
|
|
44
|
+
"",
|
|
45
|
+
"Tip:",
|
|
46
|
+
`This ${scope}-scope setup uses the official hosted Crush MCP for market data, indicators, backtests, and live strategies.`,
|
|
47
|
+
];
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
};
|
|
50
|
+
export const formatAuthStatus = (status) => {
|
|
51
|
+
const lines = [
|
|
52
|
+
"Crush authentication status",
|
|
53
|
+
"",
|
|
54
|
+
`Server: ${status.serverUrl}`,
|
|
55
|
+
`Status: ${status.status === "authenticated" ? "Authenticated" : status.status === "registered" ? "Registered but not authorized" : "Not authenticated"}`,
|
|
56
|
+
`Client registration: ${status.hasClientInformation ? "present" : "missing"}`,
|
|
57
|
+
`Access token: ${status.hasAccessToken ? "present" : "missing"}`,
|
|
58
|
+
`Refresh token: ${status.hasRefreshToken ? "present" : "missing"}`,
|
|
59
|
+
];
|
|
60
|
+
if (status.scope) {
|
|
61
|
+
lines.push(`Scope: ${status.scope}`);
|
|
62
|
+
}
|
|
63
|
+
if (status.storageFile) {
|
|
64
|
+
lines.push(`Cache file: ${status.storageFile}`);
|
|
65
|
+
}
|
|
66
|
+
if (status.matchedServerUrl && status.matchedServerUrl !== status.serverUrl) {
|
|
67
|
+
lines.push(`Matched cache URL: ${status.matchedServerUrl}`);
|
|
68
|
+
}
|
|
69
|
+
if (status.status !== "authenticated") {
|
|
70
|
+
lines.push("", "Run:", getLoginCommand(status.serverUrl));
|
|
71
|
+
}
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
};
|
|
74
|
+
export const formatDoctorReport = (serverUrl, checks) => {
|
|
75
|
+
const lines = ["Crush MCP doctor", "", `Server: ${serverUrl}`, ""];
|
|
76
|
+
for (const check of checks) {
|
|
77
|
+
lines.push(`[${check.status}] ${check.label} - ${check.detail}`);
|
|
78
|
+
}
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
};
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export type SetupTarget = "cursor" | "claude" | "codex" | "gemini" | "opencode" | "vscode" | "windsurf" | "claude-desktop" | "warp";
|
|
2
2
|
export type SetupScope = "user" | "project";
|
|
3
|
+
export type SetupInstallStatus = "created" | "updated" | "unchanged" | "configured";
|
|
4
|
+
export type SetupInstallResult = {
|
|
5
|
+
target: SetupTarget;
|
|
6
|
+
scope: SetupScope;
|
|
7
|
+
status: SetupInstallStatus;
|
|
8
|
+
location: string;
|
|
9
|
+
};
|
|
3
10
|
export declare const ALL_TARGETS: readonly SetupTarget[];
|
|
4
|
-
export declare const installClientConfig: (target: SetupTarget, scope: SetupScope) =>
|
|
11
|
+
export declare const installClientConfig: (target: SetupTarget, scope: SetupScope) => SetupInstallResult;
|
|
@@ -2,9 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
const PACKAGE_NAME = "@crush-protocol/mcp-client";
|
|
7
|
-
const REMOTE_URL = "https://crush-mcp-ats.dev.xexlab.com/mcp";
|
|
5
|
+
import { PACKAGE_NAME, SERVER_NAME } from "../config.js";
|
|
8
6
|
export const ALL_TARGETS = [
|
|
9
7
|
"cursor",
|
|
10
8
|
"claude",
|
|
@@ -30,8 +28,17 @@ const readJson = (filePath) => {
|
|
|
30
28
|
}
|
|
31
29
|
};
|
|
32
30
|
const writeJson = (filePath, value) => {
|
|
31
|
+
const existed = fs.existsSync(filePath);
|
|
32
|
+
const next = `${JSON.stringify(value, null, 2)}\n`;
|
|
33
|
+
if (existed) {
|
|
34
|
+
const previous = fs.readFileSync(filePath, "utf8");
|
|
35
|
+
if (previous === next) {
|
|
36
|
+
return "unchanged";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
33
39
|
ensureDir(filePath);
|
|
34
|
-
fs.writeFileSync(filePath,
|
|
40
|
+
fs.writeFileSync(filePath, next, "utf8");
|
|
41
|
+
return existed ? "updated" : "created";
|
|
35
42
|
};
|
|
36
43
|
const createNpxConfig = () => ({
|
|
37
44
|
command: "npx",
|
|
@@ -47,8 +54,7 @@ const installCursor = (scope) => {
|
|
|
47
54
|
const mcpServers = (config.mcpServers ?? {});
|
|
48
55
|
mcpServers[SERVER_NAME] = createNpxConfig();
|
|
49
56
|
config.mcpServers = mcpServers;
|
|
50
|
-
writeJson(filePath, config);
|
|
51
|
-
return filePath;
|
|
57
|
+
return { target: "cursor", scope, status: writeJson(filePath, config), location: filePath };
|
|
52
58
|
};
|
|
53
59
|
// ─── Gemini CLI ─────────────────────────────────────────
|
|
54
60
|
const getGeminiConfigPath = () => path.join(os.homedir(), ".gemini", "settings.json");
|
|
@@ -58,8 +64,7 @@ const installGemini = () => {
|
|
|
58
64
|
const mcpServers = (config.mcpServers ?? {});
|
|
59
65
|
mcpServers[SERVER_NAME] = createNpxConfig();
|
|
60
66
|
config.mcpServers = mcpServers;
|
|
61
|
-
writeJson(filePath, config);
|
|
62
|
-
return filePath;
|
|
67
|
+
return { target: "gemini", scope: "user", status: writeJson(filePath, config), location: filePath };
|
|
63
68
|
};
|
|
64
69
|
// ─── OpenCode ───────────────────────────────────────────
|
|
65
70
|
const getOpenCodeConfigPath = (scope) => scope === "project"
|
|
@@ -78,8 +83,7 @@ const installOpenCode = (scope) => {
|
|
|
78
83
|
enabled: true,
|
|
79
84
|
};
|
|
80
85
|
config.mcp = mcp;
|
|
81
|
-
writeJson(filePath, config);
|
|
82
|
-
return filePath;
|
|
86
|
+
return { target: "opencode", scope, status: writeJson(filePath, config), location: filePath };
|
|
83
87
|
};
|
|
84
88
|
// ─── OpenAI Codex ───────────────────────────────────────
|
|
85
89
|
const getCodexConfigPath = () => path.join(os.homedir(), ".codex", "config.toml");
|
|
@@ -91,12 +95,15 @@ args = ["-y", "${PACKAGE_NAME}"]
|
|
|
91
95
|
startup_timeout_ms = 20000
|
|
92
96
|
`;
|
|
93
97
|
ensureDir(filePath);
|
|
98
|
+
const existed = fs.existsSync(filePath);
|
|
94
99
|
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
100
|
+
let status = "unchanged";
|
|
95
101
|
if (!existing.includes(`[mcp_servers.${SERVER_NAME}]`)) {
|
|
96
102
|
const next = existing.trim().length > 0 ? `${existing.trimEnd()}\n\n${section}` : section;
|
|
97
103
|
fs.writeFileSync(filePath, next, "utf8");
|
|
104
|
+
status = existed ? "updated" : "created";
|
|
98
105
|
}
|
|
99
|
-
return filePath;
|
|
106
|
+
return { target: "codex", scope: "user", status, location: filePath };
|
|
100
107
|
};
|
|
101
108
|
// ─── Claude Code CLI ────────────────────────────────────
|
|
102
109
|
const installClaude = (scope) => {
|
|
@@ -116,7 +123,7 @@ const installClaude = (scope) => {
|
|
|
116
123
|
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
117
124
|
throw new Error(output || "Claude CLI returned a non-zero exit code.");
|
|
118
125
|
}
|
|
119
|
-
return "claude-managed-config";
|
|
126
|
+
return { target: "claude", scope, status: "configured", location: "claude-managed-config" };
|
|
120
127
|
};
|
|
121
128
|
// ─── VS Code ────────────────────────────────────────────
|
|
122
129
|
const getVSCodeConfigPath = (scope) => scope === "project"
|
|
@@ -132,8 +139,7 @@ const installVSCode = (scope) => {
|
|
|
132
139
|
args: ["-y", PACKAGE_NAME],
|
|
133
140
|
};
|
|
134
141
|
config.servers = servers;
|
|
135
|
-
writeJson(filePath, config);
|
|
136
|
-
return filePath;
|
|
142
|
+
return { target: "vscode", scope, status: writeJson(filePath, config), location: filePath };
|
|
137
143
|
};
|
|
138
144
|
// ─── Windsurf ───────────────────────────────────────────
|
|
139
145
|
const getWindsurfConfigPath = () => path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
@@ -143,8 +149,7 @@ const installWindsurf = () => {
|
|
|
143
149
|
const mcpServers = (config.mcpServers ?? {});
|
|
144
150
|
mcpServers[SERVER_NAME] = createNpxConfig();
|
|
145
151
|
config.mcpServers = mcpServers;
|
|
146
|
-
writeJson(filePath, config);
|
|
147
|
-
return filePath;
|
|
152
|
+
return { target: "windsurf", scope: "user", status: writeJson(filePath, config), location: filePath };
|
|
148
153
|
};
|
|
149
154
|
// ─── Claude Desktop ─────────────────────────────────────
|
|
150
155
|
const getClaudeDesktopConfigPath = () => {
|
|
@@ -164,8 +169,7 @@ const installClaudeDesktop = () => {
|
|
|
164
169
|
const mcpServers = (config.mcpServers ?? {});
|
|
165
170
|
mcpServers[SERVER_NAME] = createNpxConfig();
|
|
166
171
|
config.mcpServers = mcpServers;
|
|
167
|
-
writeJson(filePath, config);
|
|
168
|
-
return filePath;
|
|
172
|
+
return { target: "claude-desktop", scope: "user", status: writeJson(filePath, config), location: filePath };
|
|
169
173
|
};
|
|
170
174
|
// ─── Warp ───────────────────────────────────────────────
|
|
171
175
|
const getWarpConfigPath = () => path.join(os.homedir(), ".warp", "mcp_config.json");
|
|
@@ -179,8 +183,7 @@ const installWarp = () => {
|
|
|
179
183
|
working_directory: null,
|
|
180
184
|
start_on_launch: true,
|
|
181
185
|
};
|
|
182
|
-
writeJson(filePath, config);
|
|
183
|
-
return filePath;
|
|
186
|
+
return { target: "warp", scope: "user", status: writeJson(filePath, config), location: filePath };
|
|
184
187
|
};
|
|
185
188
|
// ─── Router ─────────────────────────────────────────────
|
|
186
189
|
export const installClientConfig = (target, scope) => {
|
package/package.json
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crush-protocol/mcp-client",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Crush MCP
|
|
3
|
+
"version": "0.4.4",
|
|
4
|
+
"description": "Official Crush MCP client for hosted market data, backtests, and trading workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/crush-protocol/crush-mcp-server/tree/main/packages/crush-mcp-client#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/crush-protocol/crush-mcp-server.git",
|
|
11
|
+
"directory": "packages/crush-mcp-client"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/crush-protocol/crush-mcp-server/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"crush",
|
|
18
|
+
"crush-protocol",
|
|
19
|
+
"mcp",
|
|
20
|
+
"model-context-protocol",
|
|
21
|
+
"trading",
|
|
22
|
+
"backtest",
|
|
23
|
+
"quant",
|
|
24
|
+
"cursor",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"codex"
|
|
27
|
+
],
|
|
7
28
|
"main": "dist/index.js",
|
|
8
29
|
"types": "dist/index.d.ts",
|
|
9
30
|
"bin": {
|
|
@@ -39,7 +60,7 @@
|
|
|
39
60
|
"node": ">=20"
|
|
40
61
|
},
|
|
41
62
|
"scripts": {
|
|
42
|
-
"build": "tsc -p tsconfig.json",
|
|
63
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
43
64
|
"dev": "tsx src/cli.ts",
|
|
44
65
|
"test": "vitest run",
|
|
45
66
|
"test:e2e": "dotenv -e .env.e2e vitest run src/__tests__/e2e.test.ts"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
-
import { BacktestClient } from "../backtest/backtestClient.js";
|
|
3
|
-
import { OAuthRemoteMcpClient } from "../mcp/oauthRemoteClient.js";
|
|
4
|
-
/**
|
|
5
|
-
* E2E 测试:验证 MCP client 能成功连接服务端并调用工具。
|
|
6
|
-
*
|
|
7
|
-
* 前置条件(通过环境变量或 .env.e2e 配置):
|
|
8
|
-
* CRUSH_MCP_SERVER_URL — 指向运行中的 MCP server(默认 https://crush-mcp-ats.dev.xexlab.com/mcp)
|
|
9
|
-
* CRUSH_OAUTH_ACCESS_TOKEN — 有效的 OAuth access token
|
|
10
|
-
*/
|
|
11
|
-
const serverUrl = process.env.CRUSH_MCP_SERVER_URL ?? "https://crush-mcp-ats.dev.xexlab.com/mcp";
|
|
12
|
-
const token = process.env.CRUSH_OAUTH_ACCESS_TOKEN ?? "";
|
|
13
|
-
// 没有 token 时跳过所有 e2e 测试
|
|
14
|
-
const describeE2E = token ? describe : describe.skip;
|
|
15
|
-
describeE2E("MCP Client E2E", () => {
|
|
16
|
-
let mcp;
|
|
17
|
-
let backtest;
|
|
18
|
-
beforeAll(async () => {
|
|
19
|
-
mcp = new OAuthRemoteMcpClient({ serverUrl, token, oauth: { openBrowser: false } });
|
|
20
|
-
await mcp.connect();
|
|
21
|
-
backtest = new BacktestClient(mcp);
|
|
22
|
-
});
|
|
23
|
-
afterAll(async () => {
|
|
24
|
-
await mcp.close();
|
|
25
|
-
});
|
|
26
|
-
it("ping — 服务端可达", async () => {
|
|
27
|
-
const result = await mcp.ping();
|
|
28
|
-
expect(result).toBeDefined();
|
|
29
|
-
});
|
|
30
|
-
it("listTools — 返回至少一个工具", async () => {
|
|
31
|
-
const { tools } = await mcp.listTools();
|
|
32
|
-
expect(tools.length).toBeGreaterThan(0);
|
|
33
|
-
});
|
|
34
|
-
it("backtest:schema — 返回支持的配置 schema", async () => {
|
|
35
|
-
const schema = await backtest.getConfigSchema();
|
|
36
|
-
expect(schema).toHaveProperty("platforms");
|
|
37
|
-
expect(schema).toHaveProperty("timeframes");
|
|
38
|
-
expect(Array.isArray(schema.platforms)).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
it("backtest:tokens — 返回可用交易对列表", async () => {
|
|
41
|
-
const result = await backtest.getAvailableTokens();
|
|
42
|
-
expect(result).toHaveProperty("tokens");
|
|
43
|
-
expect(Array.isArray(result.tokens)).toBe(true);
|
|
44
|
-
});
|
|
45
|
-
it("backtest:list — 返回当前用户的回测列表", async () => {
|
|
46
|
-
const result = await backtest.list({ limit: 5 });
|
|
47
|
-
expect(result).toHaveProperty("backtests");
|
|
48
|
-
expect(typeof result.total).toBe("number");
|
|
49
|
-
});
|
|
50
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { InteractiveOAuthProvider } from "../mcp/oauthProvider.js";
|
|
6
|
-
const tempDirs = [];
|
|
7
|
-
const createProvider = async () => {
|
|
8
|
-
const storageDir = await mkdtemp(path.join(os.tmpdir(), "crush-mcp-oauth-"));
|
|
9
|
-
tempDirs.push(storageDir);
|
|
10
|
-
const provider = new InteractiveOAuthProvider({
|
|
11
|
-
serverUrl: "https://example.com/mcp",
|
|
12
|
-
storageDir,
|
|
13
|
-
openBrowser: false,
|
|
14
|
-
});
|
|
15
|
-
return { provider, storageDir };
|
|
16
|
-
};
|
|
17
|
-
describe("InteractiveOAuthProvider", () => {
|
|
18
|
-
afterEach(async () => {
|
|
19
|
-
await Promise.all(tempDirs
|
|
20
|
-
.splice(0)
|
|
21
|
-
.map((dir) => import("node:fs/promises").then((fs) => fs.rm(dir, { recursive: true, force: true }))));
|
|
22
|
-
});
|
|
23
|
-
it("persists client info, tokens, and code verifier", async () => {
|
|
24
|
-
const { provider, storageDir } = await createProvider();
|
|
25
|
-
await provider.saveClientInformation({ client_id: "client_123" });
|
|
26
|
-
await provider.saveTokens({ access_token: "atk_123", token_type: "Bearer", refresh_token: "rt_123" });
|
|
27
|
-
await provider.saveCodeVerifier("verifier-123");
|
|
28
|
-
expect(await provider.clientInformation()).toEqual({ client_id: "client_123" });
|
|
29
|
-
expect(await provider.tokens()).toEqual({ access_token: "atk_123", token_type: "Bearer", refresh_token: "rt_123" });
|
|
30
|
-
expect(await provider.codeVerifier()).toBe("verifier-123");
|
|
31
|
-
const files = await import("node:fs/promises").then((fs) => fs.readdir(storageDir));
|
|
32
|
-
expect(files.length).toBe(1);
|
|
33
|
-
const raw = await readFile(path.join(storageDir, files[0]), "utf8");
|
|
34
|
-
expect(raw).toContain("client_123");
|
|
35
|
-
expect(raw).toContain("atk_123");
|
|
36
|
-
});
|
|
37
|
-
it("invalidates token state without removing client registration", async () => {
|
|
38
|
-
const { provider } = await createProvider();
|
|
39
|
-
await provider.saveClientInformation({ client_id: "client_123" });
|
|
40
|
-
await provider.saveTokens({ access_token: "atk_123", token_type: "Bearer", refresh_token: "rt_123" });
|
|
41
|
-
await provider.invalidateCredentials?.("tokens");
|
|
42
|
-
expect(await provider.clientInformation()).toEqual({ client_id: "client_123" });
|
|
43
|
-
expect(await provider.tokens()).toBeUndefined();
|
|
44
|
-
});
|
|
45
|
-
});
|