@crush-protocol/mcp-client 0.4.1 → 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 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 interactive clients, users should only need the MCP URL; OAuth Authorization Code + PKCE is handled by the client and browser.
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
@@ -3,56 +3,73 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@crush-protocol/mcp-client)](https://www.npmjs.com/package/@crush-protocol/mcp-client)
4
4
  [![MIT licensed](https://img.shields.io/npm/l/@crush-protocol/mcp-client)](./LICENSE)
5
5
 
6
- `@crush-protocol/mcp-client` is the npm entrypoint for Crush Protocol MCP.
6
+ > Connect Cursor, Claude Code, Codex, Gemini CLI, and other MCP hosts to Crush's hosted trading tools through one npm package.
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 Setup
10
+ ## Quick Start
11
+
12
+ Use this flow for the first successful Crush session:
11
13
 
12
14
  ```sh
13
- npx -y @crush-protocol/mcp-client setup --all
15
+ npx -y @crush-protocol/mcp-client setup --cursor
16
+ npx -y @crush-protocol/mcp-client login
17
+ npx -y @crush-protocol/mcp-client ping
14
18
  ```
15
19
 
16
- This writes MCP config for all **9** supported hosts: Cursor, Claude Code, Codex, Gemini CLI, OpenCode, VS Code, Windsurf, Claude Desktop, Warp.
20
+ What these commands do:
17
21
 
18
- To target a single host: `npx -y @crush-protocol/mcp-client setup --cursor`
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.
19
25
 
20
- The default hosted MCP endpoint is `https://crush-mcp-ats.dev.xexlab.com/mcp`.
26
+ The official hosted MCP URL is built in. Users do not need to add it manually to host configs.
21
27
 
22
- **[All Client Configurations →](#client-configuration)**
28
+ To configure every supported host at once, run `npx -y @crush-protocol/mcp-client setup --all`.
23
29
 
24
- ## How It Works
30
+ ## What You Can Do
25
31
 
26
- The CLI acts as a **stdio ↔ HTTP proxy**: it reads cached OAuth tokens from `~/.crush-mcp/`, connects to the Crush MCP server, and bridges all requests over stdio. No extra arguments needed.
32
+ Crush tools are grouped around four common workflows:
27
33
 
28
- ```
29
- AI Tool ──stdio──▶ @crush-protocol/mcp-client ──HTTP+Bearer──▶ MCP Server
30
-
31
- reads ~/.crush-mcp/
32
- (cached OAuth tokens)
33
- ```
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`
34
38
 
35
- **First-time setup (one-time):**
39
+ Detailed tool guidance is in [INSTRUCTIONS.md](./INSTRUCTIONS.md).
36
40
 
37
- ```sh
38
- npx -y @crush-protocol/mcp-client login
39
- ```
41
+ ## First Things To Ask
40
42
 
41
- This opens a browser for OAuth login. Tokens are cached in `~/.crush-mcp/` and shared across all AI tools. Token refresh is automatic.
43
+ These prompts work well for both users and MCP hosts:
42
44
 
43
- ## Available Tools
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.`
44
51
 
45
- **Market Data** — `list_tables` · `list_tokens` · `list_indicators` · `list_timeframes` · `get_data_range` · `check_query_size` · `fetch_ohlcv` · `fetch_indicator` · `fetch_news` · `get_connection_config` · `save_custom_indicator` · `list_custom_indicators` · `get_custom_indicator` · `delete_custom_indicator`
52
+ ## Authentication
46
53
 
47
- **Backtest** `get_backtest_config_schema` · `get_available_tokens` · `validate_expression` · `create_backtest` · `list_backtests`
54
+ Crush stores OAuth credentials in `~/.crush-mcp/` and reuses them across supported MCP hosts.
48
55
 
49
- **Strategy** `create_strategy` · `list_strategies` · `get_strategy` · `update_strategy` · `delete_strategy` · `toggle_strategy` · `get_strategy_logs`
56
+ If tools appear in your AI host but return an auth error, run:
50
57
 
51
- **Signal** — `get_signal_metadata` · `get_signals_by_category`
58
+ ```sh
59
+ npx -y @crush-protocol/mcp-client login
60
+ ```
52
61
 
53
- **Utilities** `search_tokens` · `get_token_info` · `get_trending_tokens` · `get_alpha_feed` · `get_token_feed`
62
+ After login, retry the same request in your AI host. Token refresh is automatic when possible.
54
63
 
55
- Detailed tool guidance is in [INSTRUCTIONS.md](./INSTRUCTIONS.md).
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.
56
73
 
57
74
  ## Client Configuration
58
75
 
@@ -639,7 +656,9 @@ Use `cmd` as the command wrapper:
639
656
 
640
657
  ```sh
641
658
  # Auth
642
- npx -y @crush-protocol/mcp-client login # OAuth login (browser)
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
643
662
  npx -y @crush-protocol/mcp-client setup --all # Auto-write config for all supported hosts
644
663
 
645
664
  # Tools
@@ -651,6 +670,38 @@ npx -y @crush-protocol/mcp-client backtest:schema # Backtest config schema
651
670
  npx -y @crush-protocol/mcp-client backtest:list --limit 10
652
671
  ```
653
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
+
654
705
  ### Environment Variables
655
706
 
656
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 || "https://crush-mcp-ats.dev.xexlab.com/mcp";
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\nGeneral:\n login [SERVER_URL]\n proxy [SERVER_URL] — stdio proxy (login once, use everywhere)\n setup [--cursor] [--claude] [--codex] [--gemini] [--opencode] [--all] [--scope user|project]\n tools:list [--token TOKEN]\n tool:call --name TOOL_NAME [--args JSON] [--token TOKEN]\n ping [--token TOKEN]\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\nAuth:\n --token TOKEN uses a provided OAuth access token.\n Without --token, OAuth runs automatically in the browser when needed.\n\nProxy Mode (recommended for AI tools):\n 1. Login once: npx @crush-protocol/mcp-client login SERVER_URL\n 2. Configure: { "command": "npx", "args": ["-y", "@crush-protocol/mcp-client", "proxy", "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`);
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 = typeof flags.token === "string" ? flags.token : process.env.CRUSH_OAUTH_ACCESS_TOKEN || "";
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", "login", "setup", "tools:list", "tool:call", "ping",
115
- "backtest:schema", "backtest:tokens", "backtest:validate", "backtest:create", "backtest:list",
116
- "clickhouse:list-tables", "clickhouse:query",
117
- "help", "--help", "-h",
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
- console.log(`Connecting to ${serverUrl} ...`);
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("OAuth authorization is ready. Tokens are stored locally.");
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) => "--" + t).join(", ")}, or --all`);
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
- const location = installClientConfig(target, scope);
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": {
@@ -0,0 +1,4 @@
1
+ export declare const PACKAGE_NAME = "@crush-protocol/mcp-client";
2
+ export declare const SERVER_NAME = "crush-protocol";
3
+ export declare const DEFAULT_MCP_SERVER_URL = "https://crush-mcp-ats.dev.xexlab.com/mcp";
4
+ export declare const DEFAULT_OAUTH_SCOPE = "mcp:tools";
package/dist/config.js ADDED
@@ -0,0 +1,4 @@
1
+ export const PACKAGE_NAME = "@crush-protocol/mcp-client";
2
+ export const SERVER_NAME = "crush-protocol";
3
+ export const DEFAULT_MCP_SERVER_URL = "https://crush-mcp-ats.dev.xexlab.com/mcp";
4
+ export const DEFAULT_OAUTH_SCOPE = "mcp:tools";
@@ -1,10 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createHash, randomBytes } from "node:crypto";
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
- const DEFAULT_SCOPE = "mcp:tools";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -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 ?? DEFAULT_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 = path.join(storageDir, `oauth-${hashServerUrl(options.serverUrl)}.json`);
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
- return this.pendingAuthorization.promise;
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
- this.callbackServer.once("error", (error) => reject(error));
213
- this.callbackServer.listen(Number(this.redirectUrl.port), this.redirectUrl.hostname, () => resolve());
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 { createHash } from "node:crypto";
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 { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
22
- import { ListToolsRequestSchema, CallToolRequestSchema, PingRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
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 = path.join(STORAGE_DIR, `oauth-${hashServerUrl(serverUrl)}.json`);
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 = path.join(STORAGE_DIR, `oauth-${hashServerUrl(serverUrl)}.json`);
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 loadTokens(serverUrl);
80
+ let tokens = await loadStoredTokens(serverUrl);
107
81
  if (!tokens?.access_token) {
108
- process.stderr.write(`[crush-mcp-proxy] No cached token found for ${serverUrl}\n` +
109
- ` Run: npx @crush-protocol/mcp-client login ${serverUrl}\n`);
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("[crush-mcp-proxy] Token expired, refreshing...\n");
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("[crush-mcp-proxy] Token refresh failed. Please re-login:\n" +
133
- ` npx @crush-protocol/mcp-client login ${serverUrl}\n`);
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) => string;
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
- const SERVER_NAME = "crush-protocol";
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, `${JSON.stringify(value, null, 2)}\n`, "utf8");
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@crush-protocol/mcp-client",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Crush MCP npm client package (remote Streamable HTTP + optional ClickHouse direct)",
5
5
  "type": "module",
6
6
  "license": "MIT",