@crush-protocol/mcp-client 0.4.2 → 0.4.3
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 +5 -1
- package/README.md +75 -32
- package/dist/__tests__/cliOutput.test.d.ts +1 -0
- package/dist/__tests__/cliOutput.test.js +34 -0
- package/dist/__tests__/oauthStorage.test.d.ts +1 -0
- package/dist/__tests__/oauthStorage.test.js +52 -0
- package/dist/cli.js +134 -12
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/mcp/oauthProvider.js +13 -10
- package/dist/mcp/oauthStorage.d.ts +29 -0
- package/dist/mcp/oauthStorage.js +81 -0
- package/dist/mcp/proxy.js +17 -39
- package/dist/onboarding/cliOutput.d.ts +12 -0
- package/dist/onboarding/cliOutput.js +65 -0
- package/dist/setup/setupClients.d.ts +8 -1
- package/dist/setup/setupClients.js +23 -20
- package/package.json +1 -1
package/INSTRUCTIONS.md
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
You have access to **Crush Protocol MCP**, an AI-native quantitative trading platform. Use these tools to help users research markets, build strategies, run backtests, and manage live trading.
|
|
4
4
|
|
|
5
|
-
Authentication note: remote MCP requests use OAuth Bearer access tokens issued by the Crush OAuth server. In
|
|
5
|
+
Authentication note: remote MCP requests use OAuth Bearer access tokens issued by the Crush OAuth server. In supported MCP hosts, users typically authenticate once with:
|
|
6
|
+
|
|
7
|
+
`npx -y @crush-protocol/mcp-client login`
|
|
8
|
+
|
|
9
|
+
After login, credentials are cached locally and reused across supported hosts.
|
|
6
10
|
|
|
7
11
|
## Tool Categories
|
|
8
12
|
|
package/README.md
CHANGED
|
@@ -7,60 +7,69 @@
|
|
|
7
7
|
|
|
8
8
|
Crush Protocol is an AI-native quantitative trading product. It lets an MCP host connect to Crush and use trading-focused tools for strategy research, backtest creation, live strategy management, and market data discovery.
|
|
9
9
|
|
|
10
|
-
## Quick
|
|
10
|
+
## Quick Start
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
1. Write MCP config for your host.
|
|
15
|
-
2. Complete OAuth login once in the browser.
|
|
16
|
-
3. Reuse the same cached token across all supported MCP hosts.
|
|
12
|
+
Use this flow for the first successful Crush session:
|
|
17
13
|
|
|
18
14
|
```sh
|
|
19
|
-
npx -y @crush-protocol/mcp-client setup --
|
|
15
|
+
npx -y @crush-protocol/mcp-client setup --cursor
|
|
20
16
|
npx -y @crush-protocol/mcp-client login
|
|
21
17
|
npx -y @crush-protocol/mcp-client ping
|
|
22
18
|
```
|
|
23
19
|
|
|
24
|
-
|
|
20
|
+
What these commands do:
|
|
25
21
|
|
|
26
|
-
|
|
22
|
+
1. `setup` writes MCP config for your host.
|
|
23
|
+
2. `login` opens the browser once and stores OAuth credentials locally.
|
|
24
|
+
3. `ping` confirms that your local credentials can reach the hosted Crush MCP.
|
|
27
25
|
|
|
28
|
-
The
|
|
26
|
+
The official hosted MCP URL is built in. Users do not need to add it manually to host configs.
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
To configure every supported host at once, run `npx -y @crush-protocol/mcp-client setup --all`.
|
|
31
29
|
|
|
32
|
-
##
|
|
30
|
+
## What You Can Do
|
|
33
31
|
|
|
34
|
-
|
|
32
|
+
Crush tools are grouped around four common workflows:
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
(cached OAuth tokens)
|
|
41
|
-
```
|
|
34
|
+
- Market data: `list_tokens`, `list_indicators`, `list_timeframes`, `fetch_ohlcv`, `fetch_indicator`
|
|
35
|
+
- Backtests: `get_backtest_config_schema`, `get_available_tokens`, `validate_expression`, `create_backtest`, `list_backtests`
|
|
36
|
+
- Live strategies: `create_strategy`, `list_strategies`, `get_strategy`, `update_strategy`, `toggle_strategy`, `get_strategy_logs`
|
|
37
|
+
- Market intelligence: `search_tokens`, `get_token_info`, `get_trending_tokens`, `get_alpha_feed`, `get_token_feed`, `fetch_news`
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
Detailed tool guidance is in [INSTRUCTIONS.md](./INSTRUCTIONS.md).
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
npx -y @crush-protocol/mcp-client login
|
|
47
|
-
```
|
|
41
|
+
## First Things To Ask
|
|
48
42
|
|
|
49
|
-
|
|
43
|
+
These prompts work well for both users and MCP hosts:
|
|
50
44
|
|
|
51
|
-
|
|
45
|
+
- `List the tokens available in Crush.`
|
|
46
|
+
- `Show me the indicators and timeframes available in Crush.`
|
|
47
|
+
- `Fetch OHLCV data for BTC.`
|
|
48
|
+
- `Create a simple moving-average crossover backtest.`
|
|
49
|
+
- `List my recent backtests.`
|
|
50
|
+
- `Show my current live strategies.`
|
|
52
51
|
|
|
53
|
-
|
|
52
|
+
## Authentication
|
|
54
53
|
|
|
55
|
-
|
|
54
|
+
Crush stores OAuth credentials in `~/.crush-mcp/` and reuses them across supported MCP hosts.
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
If tools appear in your AI host but return an auth error, run:
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
```sh
|
|
59
|
+
npx -y @crush-protocol/mcp-client login
|
|
60
|
+
```
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
After login, retry the same request in your AI host. Token refresh is automatic when possible.
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
## How It Works
|
|
65
|
+
|
|
66
|
+
The CLI acts as a stdio-to-HTTP bridge:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
AI host -> @crush-protocol/mcp-client -> Crush MCP server
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Your AI host launches `@crush-protocol/mcp-client` over stdio. The client loads cached OAuth credentials from `~/.crush-mcp/`, connects to the hosted Crush MCP, and forwards all tool requests over authenticated HTTP.
|
|
64
73
|
|
|
65
74
|
## Client Configuration
|
|
66
75
|
|
|
@@ -647,7 +656,9 @@ Use `cmd` as the command wrapper:
|
|
|
647
656
|
|
|
648
657
|
```sh
|
|
649
658
|
# Auth
|
|
650
|
-
npx -y @crush-protocol/mcp-client login
|
|
659
|
+
npx -y @crush-protocol/mcp-client login # OAuth login (browser)
|
|
660
|
+
npx -y @crush-protocol/mcp-client auth:status # Show local Crush auth state
|
|
661
|
+
npx -y @crush-protocol/mcp-client doctor # Check auth state and connectivity
|
|
651
662
|
npx -y @crush-protocol/mcp-client setup --all # Auto-write config for all supported hosts
|
|
652
663
|
|
|
653
664
|
# Tools
|
|
@@ -659,6 +670,38 @@ npx -y @crush-protocol/mcp-client backtest:schema # Backtest config schema
|
|
|
659
670
|
npx -y @crush-protocol/mcp-client backtest:list --limit 10
|
|
660
671
|
```
|
|
661
672
|
|
|
673
|
+
## Troubleshooting
|
|
674
|
+
|
|
675
|
+
### Tools appear, but calls fail with auth errors
|
|
676
|
+
|
|
677
|
+
Run:
|
|
678
|
+
|
|
679
|
+
```sh
|
|
680
|
+
npx -y @crush-protocol/mcp-client login
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
Then retry the same request in your AI host.
|
|
684
|
+
|
|
685
|
+
### I installed Crush but do not see tools yet
|
|
686
|
+
|
|
687
|
+
Restart your AI host session after `setup`.
|
|
688
|
+
|
|
689
|
+
### I want to confirm whether this machine is logged in
|
|
690
|
+
|
|
691
|
+
Run:
|
|
692
|
+
|
|
693
|
+
```sh
|
|
694
|
+
npx -y @crush-protocol/mcp-client auth:status
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
### I want a quick connectivity check
|
|
698
|
+
|
|
699
|
+
Run:
|
|
700
|
+
|
|
701
|
+
```sh
|
|
702
|
+
npx -y @crush-protocol/mcp-client doctor
|
|
703
|
+
```
|
|
704
|
+
|
|
662
705
|
### Environment Variables
|
|
663
706
|
|
|
664
707
|
| Variable | Description | Default |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatAuthStatus, formatSetupSummary, getLoginCommand } from "../onboarding/cliOutput.js";
|
|
3
|
+
describe("cliOutput", () => {
|
|
4
|
+
it("renders setup summary with next steps", () => {
|
|
5
|
+
const results = [
|
|
6
|
+
{
|
|
7
|
+
target: "cursor",
|
|
8
|
+
scope: "user",
|
|
9
|
+
status: "created",
|
|
10
|
+
location: "/tmp/.cursor/mcp.json",
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
const output = formatSetupSummary(results, "user");
|
|
14
|
+
expect(output).toContain("Crush MCP configured for Cursor.");
|
|
15
|
+
expect(output).toContain("Restart Cursor");
|
|
16
|
+
expect(output).toContain("npx -y @crush-protocol/mcp-client login");
|
|
17
|
+
});
|
|
18
|
+
it("renders auth status with login guidance when missing", () => {
|
|
19
|
+
const output = formatAuthStatus({
|
|
20
|
+
serverUrl: "https://crush-mcp-ats.dev.xexlab.com/mcp",
|
|
21
|
+
status: "not_authenticated",
|
|
22
|
+
hasClientInformation: false,
|
|
23
|
+
hasAccessToken: false,
|
|
24
|
+
hasRefreshToken: false,
|
|
25
|
+
hasCodeVerifier: false,
|
|
26
|
+
});
|
|
27
|
+
expect(output).toContain("Status: Not authenticated");
|
|
28
|
+
expect(output).toContain("Run:");
|
|
29
|
+
expect(output).toContain("npx -y @crush-protocol/mcp-client login");
|
|
30
|
+
});
|
|
31
|
+
it("renders custom login command for non-default server URLs", () => {
|
|
32
|
+
expect(getLoginCommand("https://example.com/mcp")).toBe('CRUSH_MCP_SERVER_URL="https://example.com/mcp" npx -y @crush-protocol/mcp-client login');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } 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 { defaultStorageDir, getCachedAuthStatus, getStorageFileForServer, loadStoredTokens, } from "../mcp/oauthStorage.js";
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
describe("oauthStorage", () => {
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
await Promise.all(tempDirs
|
|
10
|
+
.splice(0)
|
|
11
|
+
.map((dir) => import("node:fs/promises").then((fs) => fs.rm(dir, { recursive: true, force: true }))));
|
|
12
|
+
});
|
|
13
|
+
it("reports authenticated state when tokens are present", async () => {
|
|
14
|
+
const storageDir = await mkdtemp(path.join(os.tmpdir(), "crush-mcp-storage-"));
|
|
15
|
+
tempDirs.push(storageDir);
|
|
16
|
+
const storageFile = getStorageFileForServer("https://example.com/mcp", storageDir);
|
|
17
|
+
await writeFile(storageFile, JSON.stringify({
|
|
18
|
+
clientInformation: { client_id: "client_123" },
|
|
19
|
+
tokens: { access_token: "atk_123", refresh_token: "rt_123", token_type: "Bearer", scope: "mcp:tools" },
|
|
20
|
+
}), "utf8");
|
|
21
|
+
const status = await getCachedAuthStatus("https://example.com/mcp", storageDir);
|
|
22
|
+
expect(status.status).toBe("authenticated");
|
|
23
|
+
expect(status.hasRefreshToken).toBe(true);
|
|
24
|
+
expect(status.storageFile).toBe(storageFile);
|
|
25
|
+
});
|
|
26
|
+
it("falls back between /mcp and base URL variants", async () => {
|
|
27
|
+
const storageDir = await mkdtemp(path.join(os.tmpdir(), "crush-mcp-storage-"));
|
|
28
|
+
tempDirs.push(storageDir);
|
|
29
|
+
const storageFile = getStorageFileForServer("https://example.com", storageDir);
|
|
30
|
+
await writeFile(storageFile, JSON.stringify({
|
|
31
|
+
clientInformation: { client_id: "client_123" },
|
|
32
|
+
tokens: { access_token: "atk_123", token_type: "Bearer" },
|
|
33
|
+
}), "utf8");
|
|
34
|
+
const tokens = await loadStoredTokens("https://example.com/mcp", storageDir);
|
|
35
|
+
expect(tokens?.access_token).toBe("atk_123");
|
|
36
|
+
});
|
|
37
|
+
it("reports registered state when only client metadata exists", async () => {
|
|
38
|
+
const storageDir = await mkdtemp(path.join(os.tmpdir(), "crush-mcp-storage-"));
|
|
39
|
+
tempDirs.push(storageDir);
|
|
40
|
+
const storageFile = getStorageFileForServer("https://example.com/mcp", storageDir);
|
|
41
|
+
await writeFile(storageFile, JSON.stringify({
|
|
42
|
+
clientInformation: { client_id: "client_123" },
|
|
43
|
+
codeVerifier: "verifier_123",
|
|
44
|
+
}), "utf8");
|
|
45
|
+
const status = await getCachedAuthStatus("https://example.com/mcp", storageDir);
|
|
46
|
+
expect(status.status).toBe("registered");
|
|
47
|
+
expect(status.hasCodeVerifier).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it("uses the default storage directory helper", () => {
|
|
50
|
+
expect(defaultStorageDir()).toContain(".crush-mcp");
|
|
51
|
+
});
|
|
52
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
import "dotenv/config";
|
|
3
3
|
import { BacktestClient } from "./backtest/backtestClient.js";
|
|
4
4
|
import { ClickHouseDirectClient } from "./clickhouse/directClient.js";
|
|
5
|
+
import { DEFAULT_MCP_SERVER_URL } from "./config.js";
|
|
5
6
|
import { OAuthRemoteMcpClient } from "./mcp/oauthRemoteClient.js";
|
|
7
|
+
import { getCachedAuthStatus, loadStoredTokens } from "./mcp/oauthStorage.js";
|
|
6
8
|
import { runProxy } from "./mcp/proxy.js";
|
|
7
9
|
import { RemoteMcpClient } from "./mcp/remoteClient.js";
|
|
10
|
+
import { CLIENT_VERSION } from "./mcp/version.js";
|
|
11
|
+
import { formatAuthStatus, formatDoctorReport, formatSetupSummary, getLoginCommand, } from "./onboarding/cliOutput.js";
|
|
8
12
|
import { ALL_TARGETS, installClientConfig } from "./setup/setupClients.js";
|
|
9
|
-
const MCP_SERVER_URL = process.env.CRUSH_MCP_SERVER_URL ||
|
|
13
|
+
const MCP_SERVER_URL = process.env.CRUSH_MCP_SERVER_URL || DEFAULT_MCP_SERVER_URL;
|
|
10
14
|
const printUsage = () => {
|
|
11
|
-
console.log(`\ncrush-mcp-client\n\
|
|
15
|
+
console.log(`\ncrush-mcp-client v${CLIENT_VERSION}\n\nQuick start:\n setup [--cursor] [--claude] [--codex] [--gemini] [--opencode] [--all] [--scope user|project]\n login\n ping\n\nGeneral:\n auth:status Show whether Crush is authenticated on this machine\n doctor Check auth state and connectivity\n proxy [SERVER_URL] stdio proxy mode for MCP hosts\n tools:list [--token TOKEN]\n tool:call --name TOOL_NAME [--args JSON] [--token TOKEN]\n ping [--token TOKEN]\n --version\n\nBacktest:\n backtest:schema [--token TOKEN]\n backtest:tokens [--platform PLATFORM] [--token TOKEN]\n backtest:validate --expression JSON [--data-source kline|factors] [--token TOKEN]\n backtest:create --config JSON [--backtest-id ID] [--token TOKEN]\n backtest:list [--status STATUS] [--limit N] [--offset N] [--token TOKEN]\n\nClickHouse:\n clickhouse:list-tables [--ch-host HOST --ch-port PORT --ch-user USER --ch-password PASS --ch-database DB]\n clickhouse:query --sql SQL [--ch-host HOST --ch-port PORT --ch-user USER --ch-password PASS --ch-database DB --ch-row-cap N]\n\nAuthentication:\n Without --token, Crush uses locally cached OAuth credentials.\n If not authenticated yet, run:\n ${getLoginCommand(MCP_SERVER_URL)}\n\nEnv:\n CRUSH_MCP_SERVER_URL\n CRUSH_OAUTH_ACCESS_TOKEN\n CH_HOST, CH_PORT, CH_USER, CH_PASSWORD, CH_DATABASE, CH_ROW_CAP\n`);
|
|
12
16
|
};
|
|
13
17
|
const parseFlags = (args) => {
|
|
14
18
|
const flags = {};
|
|
@@ -34,6 +38,7 @@ const requireString = (value, message) => {
|
|
|
34
38
|
return value;
|
|
35
39
|
};
|
|
36
40
|
const getServerUrl = (override) => override || MCP_SERVER_URL;
|
|
41
|
+
const getExplicitToken = (flags) => typeof flags.token === "string" ? flags.token : process.env.CRUSH_OAUTH_ACCESS_TOKEN || "";
|
|
37
42
|
const getSetupTargets = (flags) => {
|
|
38
43
|
if (flags.all === true) {
|
|
39
44
|
return [...ALL_TARGETS];
|
|
@@ -54,7 +59,7 @@ const getSetupTargets = (flags) => {
|
|
|
54
59
|
const createSmartClient = (flags) => {
|
|
55
60
|
const serverUrl = getServerUrl(typeof flags.server === "string" ? flags.server : undefined);
|
|
56
61
|
// 显式传了 token → 用简单客户端
|
|
57
|
-
const explicitToken =
|
|
62
|
+
const explicitToken = getExplicitToken(flags);
|
|
58
63
|
if (explicitToken) {
|
|
59
64
|
return {
|
|
60
65
|
client: new RemoteMcpClient({ serverUrl, token: explicitToken }),
|
|
@@ -77,6 +82,78 @@ const connectClient = async (flags) => {
|
|
|
77
82
|
}
|
|
78
83
|
return client;
|
|
79
84
|
};
|
|
85
|
+
const buildDoctorChecks = async (flags) => {
|
|
86
|
+
const serverUrl = getServerUrl(typeof flags.server === "string" ? flags.server : undefined);
|
|
87
|
+
const checks = [];
|
|
88
|
+
const explicitToken = getExplicitToken(flags);
|
|
89
|
+
const authStatus = await getCachedAuthStatus(serverUrl);
|
|
90
|
+
checks.push({
|
|
91
|
+
status: serverUrl === DEFAULT_MCP_SERVER_URL ? "ok" : "warn",
|
|
92
|
+
label: "MCP server URL",
|
|
93
|
+
detail: serverUrl === DEFAULT_MCP_SERVER_URL
|
|
94
|
+
? "Using the official hosted Crush MCP URL."
|
|
95
|
+
: `Using a custom MCP URL: ${serverUrl}`,
|
|
96
|
+
});
|
|
97
|
+
if (explicitToken) {
|
|
98
|
+
checks.push({
|
|
99
|
+
status: "ok",
|
|
100
|
+
label: "Access token",
|
|
101
|
+
detail: "Using an explicit OAuth access token from --token or CRUSH_OAUTH_ACCESS_TOKEN.",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else if (authStatus.status === "authenticated") {
|
|
105
|
+
checks.push({
|
|
106
|
+
status: "ok",
|
|
107
|
+
label: "Cached credentials",
|
|
108
|
+
detail: `OAuth credentials found${authStatus.storageFile ? ` in ${authStatus.storageFile}` : ""}.`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else if (authStatus.status === "registered") {
|
|
112
|
+
checks.push({
|
|
113
|
+
status: "warn",
|
|
114
|
+
label: "Cached credentials",
|
|
115
|
+
detail: `Crush client registration exists, but no usable access token was found. Run: ${getLoginCommand(serverUrl)}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
checks.push({
|
|
120
|
+
status: "warn",
|
|
121
|
+
label: "Cached credentials",
|
|
122
|
+
detail: `No local Crush credentials found. Run: ${getLoginCommand(serverUrl)}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const storedTokens = explicitToken ? undefined : await loadStoredTokens(serverUrl);
|
|
126
|
+
const accessToken = explicitToken || storedTokens?.access_token || "";
|
|
127
|
+
if (!accessToken) {
|
|
128
|
+
checks.push({
|
|
129
|
+
status: "warn",
|
|
130
|
+
label: "Connectivity",
|
|
131
|
+
detail: "Skipped MCP connectivity check because no access token is available yet.",
|
|
132
|
+
});
|
|
133
|
+
return { serverUrl, checks };
|
|
134
|
+
}
|
|
135
|
+
const client = new RemoteMcpClient({ serverUrl, token: accessToken });
|
|
136
|
+
try {
|
|
137
|
+
await client.connect();
|
|
138
|
+
await client.ping();
|
|
139
|
+
checks.push({
|
|
140
|
+
status: "ok",
|
|
141
|
+
label: "Connectivity",
|
|
142
|
+
detail: "Connected to Crush MCP and ping succeeded.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
checks.push({
|
|
147
|
+
status: "error",
|
|
148
|
+
label: "Connectivity",
|
|
149
|
+
detail: `Failed to connect to Crush MCP: ${error.message}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await client.close().catch(() => undefined);
|
|
154
|
+
}
|
|
155
|
+
return { serverUrl, checks };
|
|
156
|
+
};
|
|
80
157
|
const createClickHouseClient = (flags) => {
|
|
81
158
|
const host = typeof flags["ch-host"] === "string" ? flags["ch-host"] : (process.env.CH_HOST ?? "localhost");
|
|
82
159
|
const portRaw = typeof flags["ch-port"] === "string" ? flags["ch-port"] : (process.env.CH_PORT ?? "8123");
|
|
@@ -111,10 +188,27 @@ const run = async () => {
|
|
|
111
188
|
// 如果没有已知子命令,自动进入 proxy 模式
|
|
112
189
|
const isStdioPipe = !process.stdin.isTTY;
|
|
113
190
|
const knownCommands = new Set([
|
|
114
|
-
"proxy",
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
191
|
+
"proxy",
|
|
192
|
+
"login",
|
|
193
|
+
"setup",
|
|
194
|
+
"auth:status",
|
|
195
|
+
"doctor",
|
|
196
|
+
"tools:list",
|
|
197
|
+
"tool:call",
|
|
198
|
+
"ping",
|
|
199
|
+
"backtest:schema",
|
|
200
|
+
"backtest:tokens",
|
|
201
|
+
"backtest:validate",
|
|
202
|
+
"backtest:create",
|
|
203
|
+
"backtest:list",
|
|
204
|
+
"clickhouse:list-tables",
|
|
205
|
+
"clickhouse:query",
|
|
206
|
+
"help",
|
|
207
|
+
"--help",
|
|
208
|
+
"-h",
|
|
209
|
+
"version",
|
|
210
|
+
"--version",
|
|
211
|
+
"-v",
|
|
118
212
|
]);
|
|
119
213
|
if (isStdioPipe && (!command || !knownCommands.has(command))) {
|
|
120
214
|
// 无命令或第一个参数是 URL → 自动进入 proxy 模式
|
|
@@ -126,6 +220,10 @@ const run = async () => {
|
|
|
126
220
|
printUsage();
|
|
127
221
|
return;
|
|
128
222
|
}
|
|
223
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
224
|
+
console.log(CLIENT_VERSION);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
129
227
|
const flags = parseFlags(rest);
|
|
130
228
|
switch (command) {
|
|
131
229
|
case "proxy": {
|
|
@@ -139,11 +237,23 @@ const run = async () => {
|
|
|
139
237
|
// login [SERVER_URL] — 第一个非 flag 参数作为 server URL
|
|
140
238
|
const loginUrl = rest.find((a) => !a.startsWith("--"));
|
|
141
239
|
const serverUrl = getServerUrl(loginUrl);
|
|
142
|
-
|
|
240
|
+
const authStatus = await getCachedAuthStatus(serverUrl);
|
|
241
|
+
console.log("Connecting to Crush...");
|
|
242
|
+
console.log(`Server: ${serverUrl}`);
|
|
243
|
+
if (authStatus.status === "authenticated") {
|
|
244
|
+
console.log("Existing credentials found. Verifying they are still valid...");
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
console.log("Opening browser for authorization...");
|
|
248
|
+
console.log("If the browser does not open, use the authorization URL printed below.");
|
|
249
|
+
console.log("Waiting for authorization callback on:");
|
|
250
|
+
console.log("http://127.0.0.1:8787/oauth/callback");
|
|
251
|
+
}
|
|
143
252
|
const client = new OAuthRemoteMcpClient({ serverUrl });
|
|
144
253
|
try {
|
|
145
254
|
await client.ensureAuthorized();
|
|
146
|
-
console.log("
|
|
255
|
+
console.log("Crush login complete.");
|
|
256
|
+
console.log("Credentials are stored locally and will be reused across supported MCP hosts.");
|
|
147
257
|
}
|
|
148
258
|
finally {
|
|
149
259
|
await client.close();
|
|
@@ -153,17 +263,29 @@ const run = async () => {
|
|
|
153
263
|
case "setup": {
|
|
154
264
|
const targets = getSetupTargets(flags);
|
|
155
265
|
if (targets.length === 0) {
|
|
156
|
-
throw new Error(`Specify at least one setup target: ${ALL_TARGETS.map((t) =>
|
|
266
|
+
throw new Error(`Specify at least one setup target: ${ALL_TARGETS.map((t) => `--${t}`).join(", ")}, or --all`);
|
|
157
267
|
}
|
|
158
268
|
const rawScope = typeof flags.scope === "string" ? flags.scope : "user";
|
|
159
269
|
if (rawScope !== "user" && rawScope !== "project") {
|
|
160
270
|
throw new Error("Invalid --scope. Expected 'user' or 'project'.");
|
|
161
271
|
}
|
|
162
272
|
const scope = rawScope;
|
|
273
|
+
const results = [];
|
|
163
274
|
for (const target of targets) {
|
|
164
|
-
|
|
165
|
-
console.log(`[setup] ${target}: configured (${location})`);
|
|
275
|
+
results.push(installClientConfig(target, scope));
|
|
166
276
|
}
|
|
277
|
+
console.log(formatSetupSummary(results, scope));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
case "auth:status": {
|
|
281
|
+
const serverUrl = getServerUrl(typeof flags.server === "string" ? flags.server : undefined);
|
|
282
|
+
const authStatus = await getCachedAuthStatus(serverUrl);
|
|
283
|
+
console.log(formatAuthStatus(authStatus));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
case "doctor": {
|
|
287
|
+
const { serverUrl, checks } = await buildDoctorChecks(flags);
|
|
288
|
+
console.log(formatDoctorReport(serverUrl, checks));
|
|
167
289
|
return;
|
|
168
290
|
}
|
|
169
291
|
case "tools:list": {
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import os from "node:os";
|
|
6
5
|
import path from "node:path";
|
|
7
|
-
|
|
6
|
+
import { DEFAULT_OAUTH_SCOPE } from "../config.js";
|
|
7
|
+
import { defaultStorageDir, getStorageFileForServer } from "./oauthStorage.js";
|
|
8
8
|
const DEFAULT_REDIRECT_PORT = 8787;
|
|
9
9
|
const DEFAULT_REDIRECT_PATH = "/oauth/callback";
|
|
10
10
|
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -50,8 +50,6 @@ const renderCallbackHtml = (title, message) => `<!DOCTYPE html>
|
|
|
50
50
|
</main>
|
|
51
51
|
</body>
|
|
52
52
|
</html>`;
|
|
53
|
-
const defaultStorageDir = () => path.join(os.homedir(), ".crush-mcp");
|
|
54
|
-
const hashServerUrl = (serverUrl) => createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
|
|
55
53
|
const openBrowser = async (authorizationUrl) => {
|
|
56
54
|
const url = authorizationUrl.toString();
|
|
57
55
|
if (process.env.BROWSER) {
|
|
@@ -82,10 +80,10 @@ export class InteractiveOAuthProvider {
|
|
|
82
80
|
const redirectPort = options.redirectPort ?? DEFAULT_REDIRECT_PORT;
|
|
83
81
|
const redirectPath = options.redirectPath ?? DEFAULT_REDIRECT_PATH;
|
|
84
82
|
this.redirectUrl = new URL(`http://127.0.0.1:${redirectPort}${redirectPath}`);
|
|
85
|
-
this.scope = options.scope ??
|
|
83
|
+
this.scope = options.scope ?? DEFAULT_OAUTH_SCOPE;
|
|
86
84
|
this.openBrowserByDefault = options.openBrowser ?? true;
|
|
87
85
|
const storageDir = options.storageDir ?? defaultStorageDir();
|
|
88
|
-
this.storageFile =
|
|
86
|
+
this.storageFile = getStorageFileForServer(options.serverUrl, storageDir);
|
|
89
87
|
this.onAuthorizationUrl = options.onAuthorizationUrl;
|
|
90
88
|
}
|
|
91
89
|
get clientMetadata() {
|
|
@@ -169,7 +167,11 @@ export class InteractiveOAuthProvider {
|
|
|
169
167
|
if (!this.pendingAuthorization) {
|
|
170
168
|
this.createPendingAuthorization();
|
|
171
169
|
}
|
|
172
|
-
|
|
170
|
+
const pendingAuthorization = this.pendingAuthorization;
|
|
171
|
+
if (!pendingAuthorization) {
|
|
172
|
+
throw new Error("Pending authorization was not initialized.");
|
|
173
|
+
}
|
|
174
|
+
return pendingAuthorization.promise;
|
|
173
175
|
}
|
|
174
176
|
async close() {
|
|
175
177
|
await this.closeCallbackServer();
|
|
@@ -208,9 +210,10 @@ export class InteractiveOAuthProvider {
|
|
|
208
210
|
this.callbackServer = createServer((req, res) => {
|
|
209
211
|
void this.handleCallbackRequest(req, res);
|
|
210
212
|
});
|
|
213
|
+
const callbackServer = this.callbackServer;
|
|
211
214
|
await new Promise((resolve, reject) => {
|
|
212
|
-
|
|
213
|
-
|
|
215
|
+
callbackServer.once("error", (error) => reject(error));
|
|
216
|
+
callbackServer.listen(Number(this.redirectUrl.port), this.redirectUrl.hostname, () => resolve());
|
|
214
217
|
});
|
|
215
218
|
}
|
|
216
219
|
async closeCallbackServer() {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
2
|
+
export type PersistedOAuthState = {
|
|
3
|
+
clientInformation?: OAuthClientInformationMixed;
|
|
4
|
+
tokens?: OAuthTokens;
|
|
5
|
+
codeVerifier?: string;
|
|
6
|
+
};
|
|
7
|
+
export type CachedAuthStatus = {
|
|
8
|
+
serverUrl: string;
|
|
9
|
+
matchedServerUrl?: string;
|
|
10
|
+
storageFile?: string;
|
|
11
|
+
status: "not_authenticated" | "registered" | "authenticated";
|
|
12
|
+
hasClientInformation: boolean;
|
|
13
|
+
hasAccessToken: boolean;
|
|
14
|
+
hasRefreshToken: boolean;
|
|
15
|
+
hasCodeVerifier: boolean;
|
|
16
|
+
scope?: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const defaultStorageDir: () => string;
|
|
19
|
+
export declare const hashServerUrl: (serverUrl: string) => string;
|
|
20
|
+
export declare const getStorageFileForServer: (serverUrl: string, storageDir?: string) => string;
|
|
21
|
+
export declare const getServerUrlCandidates: (serverUrl: string) => string[];
|
|
22
|
+
export declare const readPersistedOAuthState: (storageFile: string) => Promise<PersistedOAuthState | null>;
|
|
23
|
+
export declare const findPersistedOAuthState: (serverUrl: string, storageDir?: string) => Promise<{
|
|
24
|
+
matchedServerUrl: string;
|
|
25
|
+
storageFile: string;
|
|
26
|
+
state: PersistedOAuthState;
|
|
27
|
+
} | null>;
|
|
28
|
+
export declare const loadStoredTokens: (serverUrl: string, storageDir?: string) => Promise<OAuthTokens | undefined>;
|
|
29
|
+
export declare const getCachedAuthStatus: (serverUrl: string, storageDir?: string) => Promise<CachedAuthStatus>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const defaultStorageDir = () => path.join(os.homedir(), ".crush-mcp");
|
|
6
|
+
export const hashServerUrl = (serverUrl) => createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
|
|
7
|
+
export const getStorageFileForServer = (serverUrl, storageDir = defaultStorageDir()) => path.join(storageDir, `oauth-${hashServerUrl(serverUrl)}.json`);
|
|
8
|
+
export const getServerUrlCandidates = (serverUrl) => {
|
|
9
|
+
const candidates = [serverUrl];
|
|
10
|
+
if (serverUrl.endsWith("/mcp")) {
|
|
11
|
+
candidates.push(serverUrl.replace(/\/mcp$/, ""));
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
candidates.push(`${serverUrl}/mcp`);
|
|
15
|
+
}
|
|
16
|
+
return [...new Set(candidates)];
|
|
17
|
+
};
|
|
18
|
+
export const readPersistedOAuthState = async (storageFile) => {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(storageFile, "utf8");
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const err = error;
|
|
25
|
+
if (err.code === "ENOENT") {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
export const findPersistedOAuthState = async (serverUrl, storageDir = defaultStorageDir()) => {
|
|
32
|
+
for (const candidate of getServerUrlCandidates(serverUrl)) {
|
|
33
|
+
const storageFile = getStorageFileForServer(candidate, storageDir);
|
|
34
|
+
const state = await readPersistedOAuthState(storageFile);
|
|
35
|
+
if (state) {
|
|
36
|
+
return {
|
|
37
|
+
matchedServerUrl: candidate,
|
|
38
|
+
storageFile,
|
|
39
|
+
state,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
export const loadStoredTokens = async (serverUrl, storageDir = defaultStorageDir()) => {
|
|
46
|
+
const persisted = await findPersistedOAuthState(serverUrl, storageDir);
|
|
47
|
+
return persisted?.state.tokens;
|
|
48
|
+
};
|
|
49
|
+
export const getCachedAuthStatus = async (serverUrl, storageDir = defaultStorageDir()) => {
|
|
50
|
+
const persisted = await findPersistedOAuthState(serverUrl, storageDir);
|
|
51
|
+
if (!persisted) {
|
|
52
|
+
return {
|
|
53
|
+
serverUrl,
|
|
54
|
+
status: "not_authenticated",
|
|
55
|
+
hasClientInformation: false,
|
|
56
|
+
hasAccessToken: false,
|
|
57
|
+
hasRefreshToken: false,
|
|
58
|
+
hasCodeVerifier: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const { state, matchedServerUrl, storageFile } = persisted;
|
|
62
|
+
const hasClientInformation = Boolean(state.clientInformation);
|
|
63
|
+
const hasAccessToken = typeof state.tokens?.access_token === "string" && state.tokens.access_token.length > 0;
|
|
64
|
+
const hasRefreshToken = typeof state.tokens?.refresh_token === "string" && state.tokens.refresh_token.length > 0;
|
|
65
|
+
const hasCodeVerifier = typeof state.codeVerifier === "string" && state.codeVerifier.length > 0;
|
|
66
|
+
return {
|
|
67
|
+
serverUrl,
|
|
68
|
+
matchedServerUrl,
|
|
69
|
+
storageFile,
|
|
70
|
+
status: hasAccessToken
|
|
71
|
+
? "authenticated"
|
|
72
|
+
: hasClientInformation || hasCodeVerifier
|
|
73
|
+
? "registered"
|
|
74
|
+
: "not_authenticated",
|
|
75
|
+
hasClientInformation,
|
|
76
|
+
hasAccessToken,
|
|
77
|
+
hasRefreshToken,
|
|
78
|
+
hasCodeVerifier,
|
|
79
|
+
scope: state.tokens?.scope,
|
|
80
|
+
};
|
|
81
|
+
};
|
package/dist/mcp/proxy.js
CHANGED
|
@@ -11,44 +11,18 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 用户只需执行一次 `login` 获取 token,所有 AI 工具共享同一份凭证。
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
15
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
16
|
-
import os from "node:os";
|
|
14
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
15
|
import path from "node:path";
|
|
18
16
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
19
18
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
19
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
20
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, PingRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
+
import { getLoginCommand } from "../onboarding/cliOutput.js";
|
|
22
|
+
import { defaultStorageDir, getStorageFileForServer, loadStoredTokens, } from "./oauthStorage.js";
|
|
23
23
|
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
24
|
const saveTokens = async (serverUrl, tokens) => {
|
|
51
|
-
const storageFile =
|
|
25
|
+
const storageFile = getStorageFileForServer(serverUrl, defaultStorageDir());
|
|
52
26
|
let state = {};
|
|
53
27
|
try {
|
|
54
28
|
const raw = await readFile(storageFile, "utf8");
|
|
@@ -75,7 +49,7 @@ const refreshAccessToken = async (serverUrl, tokens) => {
|
|
|
75
49
|
return null;
|
|
76
50
|
const meta = (await metaRes.json());
|
|
77
51
|
// 获取 client_id
|
|
78
|
-
const storageFile =
|
|
52
|
+
const storageFile = getStorageFileForServer(serverUrl, defaultStorageDir());
|
|
79
53
|
const raw = await readFile(storageFile, "utf8");
|
|
80
54
|
const state = JSON.parse(raw);
|
|
81
55
|
const clientInfo = state.clientInformation;
|
|
@@ -103,10 +77,12 @@ const refreshAccessToken = async (serverUrl, tokens) => {
|
|
|
103
77
|
};
|
|
104
78
|
export async function runProxy(serverUrl) {
|
|
105
79
|
// 1. 加载缓存的 token
|
|
106
|
-
let tokens = await
|
|
80
|
+
let tokens = await loadStoredTokens(serverUrl);
|
|
107
81
|
if (!tokens?.access_token) {
|
|
108
|
-
process.stderr.write(
|
|
109
|
-
|
|
82
|
+
process.stderr.write("Crush is connected but not authenticated.\n\n" +
|
|
83
|
+
"Run this once in your terminal:\n" +
|
|
84
|
+
`${getLoginCommand(serverUrl)}\n\n` +
|
|
85
|
+
"After login, retry the same request.\n");
|
|
110
86
|
process.exit(1);
|
|
111
87
|
}
|
|
112
88
|
// 2. 创建到远程 MCP Server 的 HTTP 客户端
|
|
@@ -126,11 +102,13 @@ export async function runProxy(serverUrl) {
|
|
|
126
102
|
catch (error) {
|
|
127
103
|
const msg = String(error);
|
|
128
104
|
if (msg.includes("401") || msg.includes("Unauthorized")) {
|
|
129
|
-
process.stderr.write("
|
|
105
|
+
process.stderr.write("Crush authentication expired. Attempting refresh...\n");
|
|
130
106
|
const refreshed = await refreshAccessToken(serverUrl, tokens);
|
|
131
107
|
if (!refreshed) {
|
|
132
|
-
process.stderr.write("
|
|
133
|
-
|
|
108
|
+
process.stderr.write("Crush authentication needs to be renewed.\n\n" +
|
|
109
|
+
"Run:\n" +
|
|
110
|
+
`${getLoginCommand(serverUrl)}\n\n` +
|
|
111
|
+
"Then retry the same request.\n");
|
|
134
112
|
process.exit(1);
|
|
135
113
|
}
|
|
136
114
|
tokens = refreshed;
|
|
@@ -0,0 +1,12 @@
|
|
|
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 formatSetupSummary: (results: SetupInstallResult[], scope: SetupScope) => string;
|
|
11
|
+
export declare const formatAuthStatus: (status: CachedAuthStatus) => string;
|
|
12
|
+
export declare const formatDoctorReport: (serverUrl: string, checks: DoctorCheck[]) => string;
|
|
@@ -0,0 +1,65 @@
|
|
|
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 formatSetupSummary = (results, scope) => {
|
|
18
|
+
const targets = results.map((result) => getTargetLabel(result.target)).join(", ");
|
|
19
|
+
const lines = [
|
|
20
|
+
`Crush MCP configured for ${targets}.`,
|
|
21
|
+
"",
|
|
22
|
+
"Configuration:",
|
|
23
|
+
...results.map((result) => `- ${getTargetLabel(result.target)}: ${result.status} (${result.location})`),
|
|
24
|
+
"",
|
|
25
|
+
"Next steps:",
|
|
26
|
+
`1. Restart ${results.length === 1 ? getTargetLabel(results[0].target) : "your AI host"} to load the new MCP server.`,
|
|
27
|
+
`2. Authenticate once: ${getLoginCommand(DEFAULT_MCP_SERVER_URL)}`,
|
|
28
|
+
"3. Return to your AI host and ask it to use Crush tools.",
|
|
29
|
+
"",
|
|
30
|
+
"Tip:",
|
|
31
|
+
`This ${scope}-scope setup uses the official hosted Crush MCP for market data, indicators, backtests, and live strategies.`,
|
|
32
|
+
];
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
};
|
|
35
|
+
export const formatAuthStatus = (status) => {
|
|
36
|
+
const lines = [
|
|
37
|
+
"Crush authentication status",
|
|
38
|
+
"",
|
|
39
|
+
`Server: ${status.serverUrl}`,
|
|
40
|
+
`Status: ${status.status === "authenticated" ? "Authenticated" : status.status === "registered" ? "Registered but not authorized" : "Not authenticated"}`,
|
|
41
|
+
`Client registration: ${status.hasClientInformation ? "present" : "missing"}`,
|
|
42
|
+
`Access token: ${status.hasAccessToken ? "present" : "missing"}`,
|
|
43
|
+
`Refresh token: ${status.hasRefreshToken ? "present" : "missing"}`,
|
|
44
|
+
];
|
|
45
|
+
if (status.scope) {
|
|
46
|
+
lines.push(`Scope: ${status.scope}`);
|
|
47
|
+
}
|
|
48
|
+
if (status.storageFile) {
|
|
49
|
+
lines.push(`Cache file: ${status.storageFile}`);
|
|
50
|
+
}
|
|
51
|
+
if (status.matchedServerUrl && status.matchedServerUrl !== status.serverUrl) {
|
|
52
|
+
lines.push(`Matched cache URL: ${status.matchedServerUrl}`);
|
|
53
|
+
}
|
|
54
|
+
if (status.status !== "authenticated") {
|
|
55
|
+
lines.push("", "Run:", getLoginCommand(status.serverUrl));
|
|
56
|
+
}
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
};
|
|
59
|
+
export const formatDoctorReport = (serverUrl, checks) => {
|
|
60
|
+
const lines = ["Crush MCP doctor", "", `Server: ${serverUrl}`, ""];
|
|
61
|
+
for (const check of checks) {
|
|
62
|
+
lines.push(`[${check.status}] ${check.label} - ${check.detail}`);
|
|
63
|
+
}
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
};
|
|
@@ -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) => {
|