@crush-protocol/mcp-client 0.1.13 → 0.2.0

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,6 +2,8 @@
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.
6
+
5
7
  ## Tool Categories
6
8
 
7
9
  ### šŸ” Signal Discovery
@@ -19,7 +21,6 @@ You have access to **Crush Protocol MCP**, an AI-native quantitative trading pla
19
21
  | `get_available_tokens` | List tradable tokens, optionally filter by platform. |
20
22
  | `validate_expression` | Validate and compile an AST expression before using it in a backtest. |
21
23
  | `create_backtest` | Submit a backtest. Returns immediately with status `PENDING`. |
22
- | `get_backtest_result` | Poll a backtest by ID. Returns status, summary, portfolio history, and trades. |
23
24
  | `list_backtests` | List user's backtests with optional status filter (`PENDING`, `RUNNING`, `COMPLETED`, `FAILED`). |
24
25
 
25
26
  ### šŸ“ˆ Market Data (ClickHouse)
@@ -90,7 +91,7 @@ You have access to **Crush Protocol MCP**, an AI-native quantitative trading pla
90
91
  4. get_available_tokens → Find the token to trade
91
92
  5. validate_expression → Validate the entry/exit expression AST
92
93
  6. create_backtest → Submit the backtest (returns backtestId)
93
- 7. get_backtest_result → Poll until status is COMPLETED or FAILED
94
+ 7. list_backtests → Refresh the user's backtests and inspect the created record until status is COMPLETED or FAILED
94
95
  └─ If COMPLETED: present summary (totalReturn, sharpeRatio, maxDrawdown, winRate, tradeCount)
95
96
  └─ If FAILED: show error, suggest fixes, iterate
96
97
  ```
@@ -119,7 +120,7 @@ You have access to **Crush Protocol MCP**, an AI-native quantitative trading pla
119
120
 
120
121
  ## Important Rules
121
122
 
122
- 1. **Backtest is async**: `create_backtest` returns immediately. You MUST poll `get_backtest_result` until status is `COMPLETED` or `FAILED`. Typical wait: 30s–3min.
123
+ 1. **Backtest is async**: `create_backtest` returns immediately. Refresh with `list_backtests` until status is `COMPLETED` or `FAILED`. Typical wait: 30s–3min.
123
124
  2. **Data row caps**: `fetch_ohlcv` and `fetch_indicator` have a 5000 row limit. For larger datasets, use `get_connection_config` and process locally.
124
125
  3. **Always validate first**: Call `validate_expression` before using an expression in `create_backtest` to catch errors early.
125
126
  4. **Signal discovery order**: Always call `get_signal_metadata` → `get_signals_by_category` before building expressions. Don't guess signal IDs.
package/README.md CHANGED
@@ -30,30 +30,101 @@ The AI agent calls the Crush Protocol MCP tools directly — no tab-switching, n
30
30
 
31
31
  ---
32
32
 
33
- ## Getting a Token
33
+ ## Authentication
34
34
 
35
- Tokens are issued from the Crush Protocol web app. After logging in, navigate to **Settings → API Tokens** to create an `mcp_xxx` token.
35
+ Crush Protocol MCP uses **OAuth 2.1 Authorization Code + PKCE**.
36
+
37
+ For a standards-based MCP experience, configure only the MCP server URL. The client can complete browser authorization automatically and persist tokens locally.
38
+
39
+ This means the published CLI and the URL-only MCP setup now follow the same auth model:
40
+
41
+ - `npx -y @crush-protocol/mcp-client --url <mcp-url>` uses automatic OAuth on first use
42
+ - host integrations such as Claude Code / Cursor / Windsurf can also provide only the MCP URL
43
+ - both paths ultimately use the same OAuth access tokens against `/mcp`
44
+
45
+ Non-interactive OAuth overrides are still available for scripts and already-published client workflows:
46
+
47
+ 1. `crush-mcp-client login --url <mcp-url>` to pre-authorize locally
48
+ 2. `--token <access-token>` for debugging
49
+ 3. `CRUSH_OAUTH_ACCESS_TOKEN=<access-token>` for non-interactive automation
36
50
 
37
51
  ---
38
52
 
39
- ## Installation
53
+ ## CLI Usage
40
54
 
41
- ### Claude Code
55
+ The CLI is now an OAuth-aware MCP client, not a separate legacy auth path.
56
+
57
+ ### Common commands
42
58
 
43
59
  ```sh
44
- claude mcp add --scope user crush-protocol -- npx -y @crush-protocol/mcp-client
60
+ # Show help
61
+ npx -y @crush-protocol/mcp-client help
62
+
63
+ # Pre-authorize locally (optional)
64
+ npx -y @crush-protocol/mcp-client login --url https://mcp.crush-protocol.com/mcp
65
+
66
+ # List tools
67
+ npx -y @crush-protocol/mcp-client tools:list --url https://mcp.crush-protocol.com/mcp
68
+
69
+ # Ping MCP server
70
+ npx -y @crush-protocol/mcp-client ping --url https://mcp.crush-protocol.com/mcp
71
+
72
+ # Call a tool directly
73
+ npx -y @crush-protocol/mcp-client tool:call \
74
+ --url https://mcp.crush-protocol.com/mcp \
75
+ --name get_backtest_config_schema
45
76
  ```
46
77
 
47
- Then set your credentials:
78
+ ### Backtest commands
79
+
80
+ ```sh
81
+ # Read backtest schema
82
+ npx -y @crush-protocol/mcp-client backtest:schema --url https://mcp.crush-protocol.com/mcp
83
+
84
+ # List available tokens
85
+ npx -y @crush-protocol/mcp-client backtest:tokens --url https://mcp.crush-protocol.com/mcp
86
+
87
+ # List backtests
88
+ npx -y @crush-protocol/mcp-client backtest:list --url https://mcp.crush-protocol.com/mcp --limit 10
89
+ ```
90
+
91
+ ### How auth behaves
92
+
93
+ ```sh
94
+ # Recommended: URL only, browser OAuth happens automatically if needed
95
+ npx -y @crush-protocol/mcp-client tools:list --url https://mcp.crush-protocol.com/mcp
96
+
97
+ # Optional: inject a pre-issued OAuth access token
98
+ npx -y @crush-protocol/mcp-client tools:list \
99
+ --url https://mcp.crush-protocol.com/mcp \
100
+ --token <oauth-access-token>
101
+ ```
102
+
103
+ Behavior priority is:
104
+
105
+ 1. `--token`
106
+ 2. `CRUSH_OAUTH_ACCESS_TOKEN`
107
+ 3. automatic OAuth with local token cache
108
+
109
+ So the CLI remains compatible with script usage, but the default interactive path is the same URL-only OAuth flow used by MCP hosts.
110
+
111
+ ---
112
+
113
+ ## Installation
114
+
115
+ ### Claude Code
48
116
 
49
117
  ```sh
50
118
  claude mcp add --scope user crush-protocol \
51
119
  -- npx -y @crush-protocol/mcp-client \
52
- --url https://mcp.crush-protocol.com/mcp \
53
- --token mcp_xxx
120
+ --url https://mcp.crush-protocol.com/mcp
54
121
  ```
55
122
 
56
- Or via environment variables in `~/.claude.json`:
123
+ On first use, Claude-compatible hosts should open the browser and complete OAuth automatically.
124
+
125
+ ### Cursor
126
+
127
+ Add to `~/.cursor/mcp.json`:
57
128
 
58
129
  ```json
59
130
  {
@@ -62,17 +133,16 @@ Or via environment variables in `~/.claude.json`:
62
133
  "command": "npx",
63
134
  "args": ["-y", "@crush-protocol/mcp-client"],
64
135
  "env": {
65
- "CRUSH_MCP_SERVER_URL": "https://mcp.crush-protocol.com/mcp",
66
- "CRUSH_MCP_TOKEN": "mcp_xxx"
136
+ "CRUSH_MCP_SERVER_URL": "https://mcp.crush-protocol.com/mcp"
67
137
  }
68
138
  }
69
139
  }
70
140
  }
71
141
  ```
72
142
 
73
- ### Cursor
143
+ ### Windsurf
74
144
 
75
- Add to `~/.cursor/mcp.json`:
145
+ Add to your MCP configuration:
76
146
 
77
147
  ```json
78
148
  {
@@ -81,14 +151,44 @@ Add to `~/.cursor/mcp.json`:
81
151
  "command": "npx",
82
152
  "args": ["-y", "@crush-protocol/mcp-client"],
83
153
  "env": {
84
- "CRUSH_MCP_SERVER_URL": "https://mcp.crush-protocol.com/mcp",
85
- "CRUSH_MCP_TOKEN": "mcp_xxx"
154
+ "CRUSH_MCP_SERVER_URL": "https://mcp.crush-protocol.com/mcp"
86
155
  }
87
156
  }
88
157
  }
89
158
  }
90
159
  ```
91
160
 
161
+ > On first use, the client will prompt you to log in via your browser and persist OAuth tokens under `~/.crush-mcp/`.
162
+
163
+ ### Host Integration Notes
164
+
165
+ For Scheme C hosts, the recommended integration is:
166
+
167
+ 1. Configure only the MCP URL
168
+ 2. Let the client attempt `/mcp`
169
+ 3. When the server returns `401` with `WWW-Authenticate`, start OAuth discovery
170
+ 4. Complete Authorization Code + PKCE in the browser
171
+ 5. Persist tokens locally and reconnect
172
+
173
+ If a host cannot yet complete this flow automatically, run:
174
+
175
+ ```sh
176
+ crush-mcp-client login --url https://mcp.crush-protocol.com/mcp
177
+ ```
178
+
179
+ This pre-authorizes the local client cache without requiring manual token copying.
180
+
181
+ ### Unified Production Architecture
182
+
183
+ Crush MCP now uses one production auth path for remote access:
184
+
185
+ 1. The host knows only the MCP URL
186
+ 2. The MCP server returns OAuth discovery hints when auth is required
187
+ 3. The client dynamically registers, runs Authorization Code + PKCE, and persists credentials locally
188
+ 4. Authorization codes, access tokens, and refresh tokens are all managed server-side under the OAuth data model
189
+
190
+ There is no separate legacy MCP token service in the production path.
191
+
92
192
  ---
93
193
 
94
194
  ## Available Tools
@@ -96,7 +196,7 @@ Add to `~/.cursor/mcp.json`:
96
196
  | Category | Tools |
97
197
  | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
98
198
  | **Signal Discovery** | `get_signal_metadata`, `get_signals_by_category` |
99
- | **Backtest** | `get_backtest_config_schema`, `get_available_tokens`, `validate_expression`, `create_backtest`, `get_backtest_result`, `list_backtests` |
199
+ | **Backtest** | `get_backtest_config_schema`, `get_available_tokens`, `validate_expression`, `create_backtest`, `list_backtests` |
100
200
  | **Market Data** | `list_tables`, `list_tokens`, `list_indicators`, `list_timeframes`, `get_data_range`, `check_query_size`, `fetch_ohlcv`, `fetch_indicator`, `get_connection_config` |
101
201
  | **Custom Indicators** | `save_custom_indicator`, `list_custom_indicators`, `get_custom_indicator`, `delete_custom_indicator` |
102
202
  | **Strategy Management** | `create_strategy`, `list_strategies`, `get_strategy`, `update_strategy`, `delete_strategy`, `toggle_strategy`, `get_strategy_logs` |
@@ -109,10 +209,10 @@ Add to `~/.cursor/mcp.json`:
109
209
 
110
210
  ## Environment Variables
111
211
 
112
- | Variable | Description |
113
- | ---------------------- | -------------------------------------------------------------- |
114
- | `CRUSH_MCP_SERVER_URL` | MCP server URL (default: `https://mcp.crush-protocol.com/mcp`) |
115
- | `CRUSH_MCP_TOKEN` | MCP auth token (`mcp_xxx`) |
212
+ | Variable | Description |
213
+ | --------------------------- | --------------------------------------------------------------- |
214
+ | `CRUSH_MCP_SERVER_URL` | MCP server URL (default: `https://mcp.crush-protocol.com/mcp`) |
215
+ | `CRUSH_OAUTH_ACCESS_TOKEN` | Optional override for a pre-issued OAuth access token |
116
216
 
117
217
  ---
118
218
 
@@ -121,11 +221,10 @@ Add to `~/.cursor/mcp.json`:
121
221
  For programmatic access:
122
222
 
123
223
  ```typescript
124
- import { RemoteMcpClient, BacktestClient } from '@crush-protocol/mcp-client'
224
+ import { OAuthRemoteMcpClient, BacktestClient } from '@crush-protocol/mcp-client'
125
225
 
126
- const mcp = new RemoteMcpClient({
226
+ const mcp = new OAuthRemoteMcpClient({
127
227
  serverUrl: 'https://mcp.crush-protocol.com/mcp',
128
- token: 'mcp_xxx',
129
228
  })
130
229
  await mcp.connect()
131
230
 
@@ -135,7 +234,7 @@ const bt = await backtest.createBacktest({
135
234
  /* ... */
136
235
  },
137
236
  })
138
- const result = await backtest.getResult({ backtestId: bt.backtestId })
237
+ const result = await backtest.list({ limit: 10 })
139
238
 
140
239
  await mcp.close()
141
240
  ```
@@ -1,22 +1,22 @@
1
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { RemoteMcpClient } from "../mcp/remoteClient.js";
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
2
  import { BacktestClient } from "../backtest/backtestClient.js";
3
+ import { OAuthRemoteMcpClient } from "../mcp/oauthRemoteClient.js";
4
4
  /**
5
5
  * E2E ęµ‹čÆ•ļ¼šéŖŒčÆ MCP client čƒ½ęˆåŠŸčæžęŽ„ęœåŠ”ē«Æå¹¶č°ƒē”Øå·„å…·ć€‚
6
6
  *
7
7
  * å‰ē½®ę”ä»¶ļ¼ˆé€ščæ‡ēŽÆå¢ƒå˜é‡ęˆ– .env.e2e é…ē½®ļ¼‰ļ¼š
8
8
  * CRUSH_MCP_SERVER_URL — ęŒ‡å‘čæč”Œäø­ēš„ MCP server(默认 http://localhost:3333/mcp)
9
- * CRUSH_MCP_TOKEN — ęœ‰ę•ˆēš„ mcp_xxx token
9
+ * CRUSH_OAUTH_ACCESS_TOKEN — ęœ‰ę•ˆēš„ OAuth access token
10
10
  */
11
11
  const serverUrl = process.env.CRUSH_MCP_SERVER_URL ?? "http://localhost:3333/mcp";
12
- const token = process.env.CRUSH_MCP_TOKEN ?? "";
12
+ const token = process.env.CRUSH_OAUTH_ACCESS_TOKEN ?? "";
13
13
  // ę²”ęœ‰ token ę—¶č·³čæ‡ę‰€ęœ‰ e2e 测试
14
14
  const describeE2E = token ? describe : describe.skip;
15
15
  describeE2E("MCP Client E2E", () => {
16
16
  let mcp;
17
17
  let backtest;
18
18
  beforeAll(async () => {
19
- mcp = new RemoteMcpClient({ serverUrl, token });
19
+ mcp = new OAuthRemoteMcpClient({ serverUrl, token, oauth: { openBrowser: false } });
20
20
  await mcp.connect();
21
21
  backtest = new BacktestClient(mcp);
22
22
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { mkdtemp, readFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { InteractiveOAuthProvider } from "../mcp/oauthProvider.js";
6
+ const tempDirs = [];
7
+ const createProvider = async () => {
8
+ const storageDir = await mkdtemp(path.join(os.tmpdir(), "crush-mcp-oauth-"));
9
+ tempDirs.push(storageDir);
10
+ const provider = new InteractiveOAuthProvider({
11
+ serverUrl: "https://example.com/mcp",
12
+ storageDir,
13
+ openBrowser: false,
14
+ });
15
+ return { provider, storageDir };
16
+ };
17
+ describe("InteractiveOAuthProvider", () => {
18
+ afterEach(async () => {
19
+ await Promise.all(tempDirs
20
+ .splice(0)
21
+ .map((dir) => import("node:fs/promises").then((fs) => fs.rm(dir, { recursive: true, force: true }))));
22
+ });
23
+ it("persists client info, tokens, and code verifier", async () => {
24
+ const { provider, storageDir } = await createProvider();
25
+ await provider.saveClientInformation({ client_id: "client_123" });
26
+ await provider.saveTokens({ access_token: "atk_123", token_type: "Bearer", refresh_token: "rt_123" });
27
+ await provider.saveCodeVerifier("verifier-123");
28
+ expect(await provider.clientInformation()).toEqual({ client_id: "client_123" });
29
+ expect(await provider.tokens()).toEqual({ access_token: "atk_123", token_type: "Bearer", refresh_token: "rt_123" });
30
+ expect(await provider.codeVerifier()).toBe("verifier-123");
31
+ const files = await import("node:fs/promises").then((fs) => fs.readdir(storageDir));
32
+ expect(files.length).toBe(1);
33
+ const raw = await readFile(path.join(storageDir, files[0]), "utf8");
34
+ expect(raw).toContain("client_123");
35
+ expect(raw).toContain("atk_123");
36
+ });
37
+ it("invalidates token state without removing client registration", async () => {
38
+ const { provider } = await createProvider();
39
+ await provider.saveClientInformation({ client_id: "client_123" });
40
+ await provider.saveTokens({ access_token: "atk_123", token_type: "Bearer", refresh_token: "rt_123" });
41
+ await provider.invalidateCredentials?.("tokens");
42
+ expect(await provider.clientInformation()).toEqual({ client_id: "client_123" });
43
+ expect(await provider.tokens()).toBeUndefined();
44
+ });
45
+ });
@@ -1,5 +1,5 @@
1
1
  import { type BacktestStatus, type Platform } from "@crush-protocol/mcp-contracts";
2
- import type { RemoteMcpClient } from "../mcp/remoteClient.js";
2
+ import type { McpClientLike } from "../mcp/types.js";
3
3
  export type GetAvailableTokensInput = {
4
4
  platform?: Platform;
5
5
  };
@@ -11,9 +11,6 @@ export type CreateBacktestInput = {
11
11
  config: Record<string, unknown>;
12
12
  backtestId?: string;
13
13
  };
14
- export type GetBacktestResultInput = {
15
- backtestId: string;
16
- };
17
14
  export type ListBacktestsInput = {
18
15
  status?: BacktestStatus;
19
16
  limit?: number;
@@ -81,7 +78,7 @@ export type ListBacktestsResult = {
81
78
  */
82
79
  export declare class BacktestClient {
83
80
  private readonly mcp;
84
- constructor(mcp: RemoteMcpClient);
81
+ constructor(mcp: McpClientLike);
85
82
  /**
86
83
  * Return supported backtest configuration schema.
87
84
  */
@@ -103,10 +100,6 @@ export declare class BacktestClient {
103
100
  * Create a new backtest or update an existing one.
104
101
  */
105
102
  createBacktest(input: CreateBacktestInput): Promise<BacktestRecord>;
106
- /**
107
- * Get a backtest by ID with summary, portfolio history and trades.
108
- */
109
- getResult(input: GetBacktestResultInput): Promise<BacktestRecord>;
110
103
  /**
111
104
  * List backtests for the current user with optional status filter and pagination.
112
105
  */
@@ -1,4 +1,4 @@
1
- import { BacktestTools, } from "@crush-protocol/mcp-contracts";
1
+ import { BacktestTools } from "@crush-protocol/mcp-contracts";
2
2
  const extractContent = (result) => {
3
3
  // Compatibility path: some SDK result types expose `toolResult` directly.
4
4
  if ("toolResult" in result && result.toolResult !== undefined) {
@@ -53,13 +53,6 @@ export class BacktestClient {
53
53
  const result = await this.mcp.callTool(BacktestTools.CREATE_BACKTEST, input);
54
54
  return extractContent(result);
55
55
  }
56
- /**
57
- * Get a backtest by ID with summary, portfolio history and trades.
58
- */
59
- async getResult(input) {
60
- const result = await this.mcp.callTool(BacktestTools.GET_BACKTEST_RESULT, input);
61
- return extractContent(result);
62
- }
63
56
  /**
64
57
  * List backtests for the current user with optional status filter and pagination.
65
58
  */
package/dist/cli.js CHANGED
@@ -2,9 +2,10 @@
2
2
  import "dotenv/config";
3
3
  import { BacktestClient } from "./backtest/backtestClient.js";
4
4
  import { ClickHouseDirectClient } from "./clickhouse/directClient.js";
5
+ import { OAuthRemoteMcpClient } from "./mcp/oauthRemoteClient.js";
5
6
  import { RemoteMcpClient } from "./mcp/remoteClient.js";
6
7
  const printUsage = () => {
7
- console.log(`\ncrush-mcp-client\n\nGeneral:\n tools:list [--url URL] [--token TOKEN]\n tool:call --name TOOL_NAME [--args JSON] [--url URL] [--token TOKEN]\n ping [--url URL] [--token TOKEN]\n\nBacktest:\n backtest:schema [--url URL] [--token TOKEN]\n backtest:tokens [--platform PLATFORM] [--url URL] [--token TOKEN]\n backtest:validate --expression JSON [--data-source kline|factors] [--url URL] [--token TOKEN]\n backtest:create --config JSON [--backtest-id ID] [--url URL] [--token TOKEN]\n backtest:get --backtest-id ID [--url URL] [--token TOKEN]\n backtest:list [--status STATUS] [--limit N] [--offset N] [--url URL] [--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\nEnv fallback:\n CRUSH_MCP_SERVER_URL, CRUSH_MCP_TOKEN\n CH_HOST, CH_PORT, CH_USER, CH_PASSWORD, CH_DATABASE, CH_ROW_CAP\n`);
8
+ console.log(`\ncrush-mcp-client\n\nGeneral:\n login [--url URL]\n tools:list [--url URL] [--token TOKEN]\n tool:call --name TOOL_NAME [--args JSON] [--url URL] [--token TOKEN]\n ping [--url URL] [--token TOKEN]\n\nBacktest:\n backtest:schema [--url URL] [--token TOKEN]\n backtest:tokens [--platform PLATFORM] [--url URL] [--token TOKEN]\n backtest:validate --expression JSON [--data-source kline|factors] [--url URL] [--token TOKEN]\n backtest:create --config JSON [--backtest-id ID] [--url URL] [--token TOKEN]\n backtest:list [--status STATUS] [--limit N] [--offset N] [--url URL] [--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 overrides auto OAuth (for CI/scripts).\n Without --token, OAuth flow runs automatically (browser login on first use).\n\nEnv fallback:\n CRUSH_MCP_SERVER_URL, CRUSH_OAUTH_ACCESS_TOKEN\n CH_HOST, CH_PORT, CH_USER, CH_PASSWORD, CH_DATABASE, CH_ROW_CAP\n`);
8
9
  };
9
10
  const parseFlags = (args) => {
10
11
  const flags = {};
@@ -29,34 +30,53 @@ const requireString = (value, message) => {
29
30
  }
30
31
  return value;
31
32
  };
32
- const createRemoteClient = (flags) => {
33
- const serverUrl = typeof flags.url === "string"
34
- ? flags.url
35
- : (process.env.CRUSH_MCP_SERVER_URL ?? "https://mcp.crush-protocol.com/mcp");
36
- const token = typeof flags.token === "string"
37
- ? flags.token
38
- : requireString(process.env.CRUSH_MCP_TOKEN, "Missing token. Use --token or CRUSH_MCP_TOKEN");
39
- return new RemoteMcpClient({ serverUrl, token });
33
+ const getServerUrl = (flags) => typeof flags.url === "string"
34
+ ? flags.url
35
+ : (process.env.CRUSH_MCP_SERVER_URL ?? "https://mcp.crush-protocol.com/mcp");
36
+ /**
37
+ * åˆ›å»ŗ MCP å®¢ęˆ·ē«Æļ¼ˆē»Ÿäø€č®¤čÆå…„å£ļ¼‰
38
+ *
39
+ * ä¼˜å…ˆēŗ§ļ¼š
40
+ * 1. --token å‚ę•° → ē›“ęŽ„ä½æē”Øļ¼ˆé€‚ē”ØäŗŽ CI/č„šęœ¬ļ¼‰
41
+ * 2. CRUSH_OAUTH_ACCESS_TOKEN ēŽÆå¢ƒå˜é‡ → ē›“ęŽ„ä½æē”Ø
42
+ * 3. ä»„äøŠéƒ½ę²”ęœ‰ → čµ° OAuthRemoteMcpClient č‡ŖåŠØ OAuth 流程
43
+ * - ęœ¬åœ°ęœ‰ē¼“å­˜ token → ē›“ęŽ„ē”Ø
44
+ * - token čæ‡ęœŸ → č‡ŖåŠØ refresh
45
+ * - ꗠ token → č‡ŖåŠØę‹‰čµ·ęµč§ˆå™Øē™»å½•
46
+ */
47
+ const createSmartClient = (flags) => {
48
+ const serverUrl = getServerUrl(flags);
49
+ // ę˜¾å¼ä¼ äŗ† token → ē”Øē®€å•å®¢ęˆ·ē«Æ
50
+ const explicitToken = typeof flags.token === "string" ? flags.token : process.env.CRUSH_OAUTH_ACCESS_TOKEN || "";
51
+ if (explicitToken) {
52
+ return {
53
+ client: new RemoteMcpClient({ serverUrl, token: explicitToken }),
54
+ needsOAuthConnect: false,
55
+ };
56
+ }
57
+ // å¦åˆ™čµ° OAuth č‡ŖåŠØęµēØ‹
58
+ return {
59
+ client: new OAuthRemoteMcpClient({ serverUrl }),
60
+ needsOAuthConnect: true,
61
+ };
62
+ };
63
+ const connectClient = async (flags) => {
64
+ const { client, needsOAuthConnect } = createSmartClient(flags);
65
+ if (needsOAuthConnect) {
66
+ await client.connect();
67
+ }
68
+ else {
69
+ await client.connect();
70
+ }
71
+ return client;
40
72
  };
41
73
  const createClickHouseClient = (flags) => {
42
- const host = typeof flags["ch-host"] === "string"
43
- ? flags["ch-host"]
44
- : (process.env.CH_HOST ?? "localhost");
45
- const portRaw = typeof flags["ch-port"] === "string"
46
- ? flags["ch-port"]
47
- : (process.env.CH_PORT ?? "8123");
48
- const user = typeof flags["ch-user"] === "string"
49
- ? flags["ch-user"]
50
- : (process.env.CH_USER ?? "default");
51
- const password = typeof flags["ch-password"] === "string"
52
- ? flags["ch-password"]
53
- : (process.env.CH_PASSWORD ?? "");
54
- const database = typeof flags["ch-database"] === "string"
55
- ? flags["ch-database"]
56
- : (process.env.CH_DATABASE ?? "crush_ats");
57
- const rowCapRaw = typeof flags["ch-row-cap"] === "string"
58
- ? flags["ch-row-cap"]
59
- : process.env.CH_ROW_CAP;
74
+ const host = typeof flags["ch-host"] === "string" ? flags["ch-host"] : (process.env.CH_HOST ?? "localhost");
75
+ const portRaw = typeof flags["ch-port"] === "string" ? flags["ch-port"] : (process.env.CH_PORT ?? "8123");
76
+ const user = typeof flags["ch-user"] === "string" ? flags["ch-user"] : (process.env.CH_USER ?? "default");
77
+ const password = typeof flags["ch-password"] === "string" ? flags["ch-password"] : (process.env.CH_PASSWORD ?? "");
78
+ const database = typeof flags["ch-database"] === "string" ? flags["ch-database"] : (process.env.CH_DATABASE ?? "crush_ats");
79
+ const rowCapRaw = typeof flags["ch-row-cap"] === "string" ? flags["ch-row-cap"] : process.env.CH_ROW_CAP;
60
80
  const port = Number(portRaw);
61
81
  if (!Number.isFinite(port) || port <= 0) {
62
82
  throw new Error(`Invalid ClickHouse port: ${portRaw}`);
@@ -80,18 +100,26 @@ const createClickHouseClient = (flags) => {
80
100
  };
81
101
  const run = async () => {
82
102
  const [, , command, ...rest] = process.argv;
83
- if (!command ||
84
- command === "help" ||
85
- command === "--help" ||
86
- command === "-h") {
103
+ if (!command || command === "help" || command === "--help" || command === "-h") {
87
104
  printUsage();
88
105
  return;
89
106
  }
90
107
  const flags = parseFlags(rest);
91
108
  switch (command) {
109
+ case "login": {
110
+ const serverUrl = getServerUrl(flags);
111
+ const client = new OAuthRemoteMcpClient({ serverUrl });
112
+ try {
113
+ await client.ensureAuthorized();
114
+ console.log("OAuth authorization is ready. Tokens are stored locally.");
115
+ }
116
+ finally {
117
+ await client.close();
118
+ }
119
+ return;
120
+ }
92
121
  case "tools:list": {
93
- const client = createRemoteClient(flags);
94
- await client.connect();
122
+ const client = await connectClient(flags);
95
123
  try {
96
124
  const result = await client.listTools();
97
125
  console.log(JSON.stringify(result, null, 2));
@@ -111,8 +139,7 @@ const run = async () => {
111
139
  catch (e) {
112
140
  throw new Error(`Invalid --args JSON: ${e.message}`);
113
141
  }
114
- const client = createRemoteClient(flags);
115
- await client.connect();
142
+ const client = await connectClient(flags);
116
143
  try {
117
144
  const result = await client.callTool(name, parsedArgs);
118
145
  console.log(JSON.stringify(result, null, 2));
@@ -123,8 +150,7 @@ const run = async () => {
123
150
  return;
124
151
  }
125
152
  case "ping": {
126
- const client = createRemoteClient(flags);
127
- await client.connect();
153
+ const client = await connectClient(flags);
128
154
  try {
129
155
  const result = await client.ping();
130
156
  console.log(JSON.stringify(result, null, 2));
@@ -135,8 +161,7 @@ const run = async () => {
135
161
  return;
136
162
  }
137
163
  case "backtest:schema": {
138
- const client = createRemoteClient(flags);
139
- await client.connect();
164
+ const client = await connectClient(flags);
140
165
  try {
141
166
  const bt = new BacktestClient(client);
142
167
  const result = await bt.getConfigSchema();
@@ -148,8 +173,7 @@ const run = async () => {
148
173
  return;
149
174
  }
150
175
  case "backtest:tokens": {
151
- const client = createRemoteClient(flags);
152
- await client.connect();
176
+ const client = await connectClient(flags);
153
177
  try {
154
178
  const bt = new BacktestClient(client);
155
179
  const platform = typeof flags.platform === "string" ? flags.platform : undefined;
@@ -174,11 +198,8 @@ const run = async () => {
174
198
  catch (e) {
175
199
  throw new Error(`Invalid --expression JSON: ${e.message}`);
176
200
  }
177
- const dataSource = typeof flags["data-source"] === "string"
178
- ? flags["data-source"]
179
- : undefined;
180
- const client = createRemoteClient(flags);
181
- await client.connect();
201
+ const dataSource = typeof flags["data-source"] === "string" ? flags["data-source"] : undefined;
202
+ const client = await connectClient(flags);
182
203
  try {
183
204
  const bt = new BacktestClient(client);
184
205
  const result = await bt.validateExpression({ expression, dataSource });
@@ -198,11 +219,8 @@ const run = async () => {
198
219
  catch (e) {
199
220
  throw new Error(`Invalid --config JSON: ${e.message}`);
200
221
  }
201
- const backtestId = typeof flags["backtest-id"] === "string"
202
- ? flags["backtest-id"]
203
- : undefined;
204
- const client = createRemoteClient(flags);
205
- await client.connect();
222
+ const backtestId = typeof flags["backtest-id"] === "string" ? flags["backtest-id"] : undefined;
223
+ const client = await connectClient(flags);
206
224
  try {
207
225
  const bt = new BacktestClient(client);
208
226
  const result = await bt.createBacktest({ config, backtestId });
@@ -213,28 +231,11 @@ const run = async () => {
213
231
  }
214
232
  return;
215
233
  }
216
- case "backtest:get": {
217
- const backtestId = requireString(flags["backtest-id"], "Missing --backtest-id for backtest:get");
218
- const client = createRemoteClient(flags);
219
- await client.connect();
220
- try {
221
- const bt = new BacktestClient(client);
222
- const result = await bt.getResult({ backtestId });
223
- console.log(JSON.stringify(result, null, 2));
224
- }
225
- finally {
226
- await client.close();
227
- }
228
- return;
229
- }
230
234
  case "backtest:list": {
231
- const status = typeof flags.status === "string"
232
- ? flags.status
233
- : undefined;
235
+ const status = typeof flags.status === "string" ? flags.status : undefined;
234
236
  const limit = typeof flags.limit === "string" ? Number(flags.limit) : undefined;
235
237
  const offset = typeof flags.offset === "string" ? Number(flags.offset) : undefined;
236
- const client = createRemoteClient(flags);
237
- await client.connect();
238
+ const client = await connectClient(flags);
238
239
  try {
239
240
  const bt = new BacktestClient(client);
240
241
  const result = await bt.list({ status, limit, offset });
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
- export { ClickHouseDirectClient, DEFAULT_ROW_CAP, type ClickHouseDirectConfig, } from "./clickhouse/directClient.js";
1
+ export { BacktestClient, type BacktestConfigSchema, type BacktestRecord, type CreateBacktestInput, type GetAvailableTokensInput, type ListBacktestsInput, type ListBacktestsResult, type TokenInfo, type ValidationResult, } from "./backtest/backtestClient.js";
2
+ export { ClickHouseDirectClient, type ClickHouseDirectConfig, DEFAULT_ROW_CAP, } from "./clickhouse/directClient.js";
3
+ export { InteractiveOAuthProvider, type InteractiveOAuthProviderOptions, } from "./mcp/oauthProvider.js";
4
+ export { OAuthRemoteMcpClient, type OAuthRemoteMcpClientOptions, } from "./mcp/oauthRemoteClient.js";
2
5
  export { RemoteMcpClient, type RemoteMcpClientOptions, } from "./mcp/remoteClient.js";
3
- export { BacktestClient, type BacktestConfigSchema, type BacktestRecord, type CreateBacktestInput, type GetAvailableTokensInput, type GetBacktestResultInput, type ListBacktestsInput, type ListBacktestsResult, type TokenInfo, type ValidationResult, } from "./backtest/backtestClient.js";
6
+ export type { McpClientLike } from "./mcp/types.js";
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ export { BacktestClient, } from "./backtest/backtestClient.js";
1
2
  export { ClickHouseDirectClient, DEFAULT_ROW_CAP, } from "./clickhouse/directClient.js";
3
+ export { InteractiveOAuthProvider, } from "./mcp/oauthProvider.js";
4
+ export { OAuthRemoteMcpClient, } from "./mcp/oauthRemoteClient.js";
2
5
  export { RemoteMcpClient, } from "./mcp/remoteClient.js";
3
- export { BacktestClient, } from "./backtest/backtestClient.js";
@@ -0,0 +1,42 @@
1
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2
+ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
3
+ export type InteractiveOAuthProviderOptions = {
4
+ serverUrl: string;
5
+ clientName?: string;
6
+ clientVersion?: string;
7
+ scope?: string;
8
+ redirectPort?: number;
9
+ redirectPath?: string;
10
+ storageDir?: string;
11
+ openBrowser?: boolean;
12
+ onAuthorizationUrl?: (authorizationUrl: URL) => void | Promise<void>;
13
+ };
14
+ export declare class InteractiveOAuthProvider implements OAuthClientProvider {
15
+ private readonly options;
16
+ readonly redirectUrl: URL;
17
+ private readonly storageFile;
18
+ private readonly scope;
19
+ private readonly openBrowserByDefault;
20
+ private readonly onAuthorizationUrl?;
21
+ private callbackServer?;
22
+ private pendingAuthorization?;
23
+ constructor(options: InteractiveOAuthProviderOptions);
24
+ get clientMetadata(): OAuthClientMetadata;
25
+ state(): Promise<string>;
26
+ clientInformation(): Promise<OAuthClientInformationMixed | undefined>;
27
+ saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void>;
28
+ tokens(): Promise<OAuthTokens | undefined>;
29
+ saveTokens(tokens: OAuthTokens): Promise<void>;
30
+ redirectToAuthorization(authorizationUrl: URL): Promise<void>;
31
+ saveCodeVerifier(codeVerifier: string): Promise<void>;
32
+ codeVerifier(): Promise<string>;
33
+ invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier"): Promise<void>;
34
+ waitForAuthorizationCode(): Promise<string>;
35
+ close(): Promise<void>;
36
+ private loadState;
37
+ private saveState;
38
+ private createPendingAuthorization;
39
+ private ensureCallbackServer;
40
+ private closeCallbackServer;
41
+ private handleCallbackRequest;
42
+ }
@@ -0,0 +1,264 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:http";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ const DEFAULT_SCOPE = "mcp:tools";
8
+ const DEFAULT_REDIRECT_PORT = 8787;
9
+ const DEFAULT_REDIRECT_PATH = "/oauth/callback";
10
+ const renderCallbackHtml = (title, message) => `<!DOCTYPE html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
15
+ <title>${title}</title>
16
+ <style>
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ display: grid;
21
+ place-items: center;
22
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
23
+ background: linear-gradient(135deg, #f6f7fb, #eef2ff);
24
+ color: #0f172a;
25
+ }
26
+ main {
27
+ max-width: 560px;
28
+ padding: 32px;
29
+ border-radius: 20px;
30
+ background: rgba(255, 255, 255, 0.96);
31
+ box-shadow: 0 20px 45px rgba(15, 23, 42, 0.14);
32
+ text-align: center;
33
+ }
34
+ h1 {
35
+ margin: 0 0 12px;
36
+ font-size: 28px;
37
+ }
38
+ p {
39
+ margin: 0;
40
+ line-height: 1.6;
41
+ color: #334155;
42
+ }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ <h1>${title}</h1>
48
+ <p>${message}</p>
49
+ </main>
50
+ </body>
51
+ </html>`;
52
+ const defaultStorageDir = () => path.join(os.homedir(), ".crush-mcp");
53
+ const hashServerUrl = (serverUrl) => createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
54
+ const openBrowser = async (authorizationUrl) => {
55
+ const url = authorizationUrl.toString();
56
+ if (process.env.BROWSER) {
57
+ spawn(process.env.BROWSER, [url], { stdio: "ignore", detached: true }).unref();
58
+ return;
59
+ }
60
+ if (process.platform === "darwin") {
61
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
62
+ return;
63
+ }
64
+ if (process.platform === "win32") {
65
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
66
+ return;
67
+ }
68
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
69
+ };
70
+ export class InteractiveOAuthProvider {
71
+ options;
72
+ redirectUrl;
73
+ storageFile;
74
+ scope;
75
+ openBrowserByDefault;
76
+ onAuthorizationUrl;
77
+ callbackServer;
78
+ pendingAuthorization;
79
+ constructor(options) {
80
+ this.options = options;
81
+ const redirectPort = options.redirectPort ?? DEFAULT_REDIRECT_PORT;
82
+ const redirectPath = options.redirectPath ?? DEFAULT_REDIRECT_PATH;
83
+ this.redirectUrl = new URL(`http://127.0.0.1:${redirectPort}${redirectPath}`);
84
+ this.scope = options.scope ?? DEFAULT_SCOPE;
85
+ this.openBrowserByDefault = options.openBrowser ?? true;
86
+ const storageDir = options.storageDir ?? defaultStorageDir();
87
+ this.storageFile = path.join(storageDir, `oauth-${hashServerUrl(options.serverUrl)}.json`);
88
+ this.onAuthorizationUrl = options.onAuthorizationUrl;
89
+ }
90
+ get clientMetadata() {
91
+ return {
92
+ client_name: this.options.clientName ?? "Crush MCP Client",
93
+ grant_types: ["authorization_code", "refresh_token"],
94
+ redirect_uris: [this.redirectUrl.toString()],
95
+ response_types: ["code"],
96
+ scope: this.scope,
97
+ software_version: this.options.clientVersion,
98
+ token_endpoint_auth_method: "none",
99
+ };
100
+ }
101
+ async state() {
102
+ return randomBytes(16).toString("hex");
103
+ }
104
+ async clientInformation() {
105
+ const data = await this.loadState();
106
+ return data.clientInformation;
107
+ }
108
+ async saveClientInformation(clientInformation) {
109
+ const data = await this.loadState();
110
+ data.clientInformation = clientInformation;
111
+ await this.saveState(data);
112
+ }
113
+ async tokens() {
114
+ const data = await this.loadState();
115
+ return data.tokens;
116
+ }
117
+ async saveTokens(tokens) {
118
+ const data = await this.loadState();
119
+ data.tokens = tokens;
120
+ await this.saveState(data);
121
+ }
122
+ async redirectToAuthorization(authorizationUrl) {
123
+ this.createPendingAuthorization();
124
+ await this.ensureCallbackServer();
125
+ if (this.onAuthorizationUrl) {
126
+ await this.onAuthorizationUrl(authorizationUrl);
127
+ }
128
+ if (this.openBrowserByDefault) {
129
+ try {
130
+ await openBrowser(authorizationUrl);
131
+ }
132
+ catch {
133
+ // Fall back to printing the URL when no browser launcher is available.
134
+ }
135
+ }
136
+ process.stdout.write(`\nOpen this URL to authorize Crush MCP:\n${authorizationUrl.toString()}\n\n`);
137
+ }
138
+ async saveCodeVerifier(codeVerifier) {
139
+ const data = await this.loadState();
140
+ data.codeVerifier = codeVerifier;
141
+ await this.saveState(data);
142
+ }
143
+ async codeVerifier() {
144
+ const data = await this.loadState();
145
+ if (!data.codeVerifier) {
146
+ throw new Error("Missing PKCE code verifier. Restart the OAuth flow.");
147
+ }
148
+ return data.codeVerifier;
149
+ }
150
+ async invalidateCredentials(scope) {
151
+ const data = await this.loadState();
152
+ if (scope === "all") {
153
+ await rm(this.storageFile, { force: true });
154
+ return;
155
+ }
156
+ if (scope === "client") {
157
+ delete data.clientInformation;
158
+ }
159
+ if (scope === "tokens") {
160
+ delete data.tokens;
161
+ }
162
+ if (scope === "verifier") {
163
+ delete data.codeVerifier;
164
+ }
165
+ await this.saveState(data);
166
+ }
167
+ async waitForAuthorizationCode() {
168
+ if (!this.pendingAuthorization) {
169
+ this.createPendingAuthorization();
170
+ }
171
+ return this.pendingAuthorization.promise;
172
+ }
173
+ async close() {
174
+ await this.closeCallbackServer();
175
+ }
176
+ async loadState() {
177
+ try {
178
+ const raw = await readFile(this.storageFile, "utf8");
179
+ return JSON.parse(raw);
180
+ }
181
+ catch (error) {
182
+ const err = error;
183
+ if (err.code === "ENOENT") {
184
+ return {};
185
+ }
186
+ throw error;
187
+ }
188
+ }
189
+ async saveState(data) {
190
+ await mkdir(path.dirname(this.storageFile), { recursive: true });
191
+ await writeFile(this.storageFile, JSON.stringify(data, null, 2), "utf8");
192
+ }
193
+ createPendingAuthorization() {
194
+ let resolve;
195
+ let reject;
196
+ const promise = new Promise((innerResolve, innerReject) => {
197
+ resolve = innerResolve;
198
+ reject = innerReject;
199
+ });
200
+ this.pendingAuthorization = { promise, resolve, reject };
201
+ return this.pendingAuthorization;
202
+ }
203
+ async ensureCallbackServer() {
204
+ if (this.callbackServer) {
205
+ return;
206
+ }
207
+ this.callbackServer = createServer((req, res) => {
208
+ void this.handleCallbackRequest(req, res);
209
+ });
210
+ await new Promise((resolve, reject) => {
211
+ this.callbackServer.once("error", (error) => reject(error));
212
+ this.callbackServer.listen(Number(this.redirectUrl.port), this.redirectUrl.hostname, () => resolve());
213
+ });
214
+ }
215
+ async closeCallbackServer() {
216
+ if (!this.callbackServer) {
217
+ return;
218
+ }
219
+ const server = this.callbackServer;
220
+ this.callbackServer = undefined;
221
+ await new Promise((resolve, reject) => {
222
+ server.close((error) => {
223
+ if (error) {
224
+ reject(error);
225
+ return;
226
+ }
227
+ resolve();
228
+ });
229
+ });
230
+ }
231
+ async handleCallbackRequest(req, res) {
232
+ const requestUrl = new URL(req.url || "/", this.redirectUrl);
233
+ if (requestUrl.pathname !== this.redirectUrl.pathname) {
234
+ res.statusCode = 404;
235
+ res.end("Not found");
236
+ return;
237
+ }
238
+ const error = requestUrl.searchParams.get("error");
239
+ const errorDescription = requestUrl.searchParams.get("error_description");
240
+ const code = requestUrl.searchParams.get("code");
241
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
242
+ if (error) {
243
+ res.statusCode = 400;
244
+ res.end(renderCallbackHtml("Authorization failed", errorDescription || error));
245
+ this.pendingAuthorization?.reject(new Error(errorDescription || error));
246
+ this.pendingAuthorization = undefined;
247
+ await this.closeCallbackServer();
248
+ return;
249
+ }
250
+ if (!code) {
251
+ res.statusCode = 400;
252
+ res.end(renderCallbackHtml("Authorization failed", "Missing authorization code."));
253
+ this.pendingAuthorization?.reject(new Error("Missing authorization code in callback."));
254
+ this.pendingAuthorization = undefined;
255
+ await this.closeCallbackServer();
256
+ return;
257
+ }
258
+ res.statusCode = 200;
259
+ res.end(renderCallbackHtml("Authorization complete", "You can close this tab and return to your MCP client."));
260
+ this.pendingAuthorization?.resolve(code);
261
+ this.pendingAuthorization = undefined;
262
+ await this.closeCallbackServer();
263
+ }
264
+ }
@@ -0,0 +1,171 @@
1
+ import { type AuthResult } from "@modelcontextprotocol/sdk/client/auth.js";
2
+ import { type InteractiveOAuthProviderOptions } from "./oauthProvider.js";
3
+ import type { McpClientLike } from "./types.js";
4
+ export type OAuthRemoteMcpClientOptions = {
5
+ serverUrl: string;
6
+ clientName?: string;
7
+ clientVersion?: string;
8
+ scope?: string;
9
+ token?: string;
10
+ fetch?: typeof fetch;
11
+ oauth?: Omit<InteractiveOAuthProviderOptions, "serverUrl" | "clientName" | "clientVersion" | "scope">;
12
+ };
13
+ export declare class OAuthRemoteMcpClient implements McpClientLike {
14
+ private readonly options;
15
+ private readonly client;
16
+ private readonly transport;
17
+ private readonly authProvider;
18
+ constructor(options: OAuthRemoteMcpClientOptions);
19
+ connect(): Promise<void>;
20
+ ensureAuthorized(): Promise<void>;
21
+ runAuthFlow(): Promise<AuthResult>;
22
+ completeAuthorization(): Promise<void>;
23
+ close(): Promise<void>;
24
+ terminateSession(): Promise<void>;
25
+ ping(): Promise<{
26
+ _meta?: {
27
+ [x: string]: unknown;
28
+ progressToken?: string | number | undefined;
29
+ "io.modelcontextprotocol/related-task"?: {
30
+ taskId: string;
31
+ } | undefined;
32
+ } | undefined;
33
+ }>;
34
+ listTools(): Promise<{
35
+ [x: string]: unknown;
36
+ tools: {
37
+ inputSchema: {
38
+ [x: string]: unknown;
39
+ type: "object";
40
+ properties?: Record<string, object> | undefined;
41
+ required?: string[] | undefined;
42
+ };
43
+ name: string;
44
+ description?: string | undefined;
45
+ outputSchema?: {
46
+ [x: string]: unknown;
47
+ type: "object";
48
+ properties?: Record<string, object> | undefined;
49
+ required?: string[] | undefined;
50
+ } | undefined;
51
+ annotations?: {
52
+ title?: string | undefined;
53
+ readOnlyHint?: boolean | undefined;
54
+ destructiveHint?: boolean | undefined;
55
+ idempotentHint?: boolean | undefined;
56
+ openWorldHint?: boolean | undefined;
57
+ } | undefined;
58
+ execution?: {
59
+ taskSupport?: "optional" | "required" | "forbidden" | undefined;
60
+ } | undefined;
61
+ _meta?: Record<string, unknown> | undefined;
62
+ icons?: {
63
+ src: string;
64
+ mimeType?: string | undefined;
65
+ sizes?: string[] | undefined;
66
+ theme?: "light" | "dark" | undefined;
67
+ }[] | undefined;
68
+ title?: string | undefined;
69
+ }[];
70
+ _meta?: {
71
+ [x: string]: unknown;
72
+ progressToken?: string | number | undefined;
73
+ "io.modelcontextprotocol/related-task"?: {
74
+ taskId: string;
75
+ } | undefined;
76
+ } | undefined;
77
+ nextCursor?: string | undefined;
78
+ }>;
79
+ callTool(name: string, args?: Record<string, unknown>): Promise<{
80
+ [x: string]: unknown;
81
+ content: ({
82
+ type: "text";
83
+ text: string;
84
+ annotations?: {
85
+ audience?: ("user" | "assistant")[] | undefined;
86
+ priority?: number | undefined;
87
+ lastModified?: string | undefined;
88
+ } | undefined;
89
+ _meta?: Record<string, unknown> | undefined;
90
+ } | {
91
+ type: "image";
92
+ data: string;
93
+ mimeType: string;
94
+ annotations?: {
95
+ audience?: ("user" | "assistant")[] | undefined;
96
+ priority?: number | undefined;
97
+ lastModified?: string | undefined;
98
+ } | undefined;
99
+ _meta?: Record<string, unknown> | undefined;
100
+ } | {
101
+ type: "audio";
102
+ data: string;
103
+ mimeType: string;
104
+ annotations?: {
105
+ audience?: ("user" | "assistant")[] | undefined;
106
+ priority?: number | undefined;
107
+ lastModified?: string | undefined;
108
+ } | undefined;
109
+ _meta?: Record<string, unknown> | undefined;
110
+ } | {
111
+ type: "resource";
112
+ resource: {
113
+ uri: string;
114
+ text: string;
115
+ mimeType?: string | undefined;
116
+ _meta?: Record<string, unknown> | undefined;
117
+ } | {
118
+ uri: string;
119
+ blob: string;
120
+ mimeType?: string | undefined;
121
+ _meta?: Record<string, unknown> | undefined;
122
+ };
123
+ annotations?: {
124
+ audience?: ("user" | "assistant")[] | undefined;
125
+ priority?: number | undefined;
126
+ lastModified?: string | undefined;
127
+ } | undefined;
128
+ _meta?: Record<string, unknown> | undefined;
129
+ } | {
130
+ uri: string;
131
+ name: string;
132
+ type: "resource_link";
133
+ description?: string | undefined;
134
+ mimeType?: string | undefined;
135
+ annotations?: {
136
+ audience?: ("user" | "assistant")[] | undefined;
137
+ priority?: number | undefined;
138
+ lastModified?: string | undefined;
139
+ } | undefined;
140
+ _meta?: {
141
+ [x: string]: unknown;
142
+ } | undefined;
143
+ icons?: {
144
+ src: string;
145
+ mimeType?: string | undefined;
146
+ sizes?: string[] | undefined;
147
+ theme?: "light" | "dark" | undefined;
148
+ }[] | undefined;
149
+ title?: string | undefined;
150
+ })[];
151
+ _meta?: {
152
+ [x: string]: unknown;
153
+ progressToken?: string | number | undefined;
154
+ "io.modelcontextprotocol/related-task"?: {
155
+ taskId: string;
156
+ } | undefined;
157
+ } | undefined;
158
+ structuredContent?: Record<string, unknown> | undefined;
159
+ isError?: boolean | undefined;
160
+ } | {
161
+ [x: string]: unknown;
162
+ toolResult: unknown;
163
+ _meta?: {
164
+ [x: string]: unknown;
165
+ progressToken?: string | number | undefined;
166
+ "io.modelcontextprotocol/related-task"?: {
167
+ taskId: string;
168
+ } | undefined;
169
+ } | undefined;
170
+ }>;
171
+ }
@@ -0,0 +1,95 @@
1
+ import { auth, UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import { InteractiveOAuthProvider } from "./oauthProvider.js";
5
+ export class OAuthRemoteMcpClient {
6
+ options;
7
+ client;
8
+ transport;
9
+ authProvider;
10
+ constructor(options) {
11
+ this.options = options;
12
+ this.client = new Client({
13
+ name: options.clientName ?? "crush-mcp-client",
14
+ version: options.clientVersion ?? "0.2.0",
15
+ }, {
16
+ capabilities: {},
17
+ });
18
+ this.authProvider = new InteractiveOAuthProvider({
19
+ serverUrl: options.serverUrl,
20
+ clientName: options.clientName,
21
+ clientVersion: options.clientVersion,
22
+ scope: options.scope,
23
+ ...options.oauth,
24
+ });
25
+ this.transport = new StreamableHTTPClientTransport(new URL(options.serverUrl), {
26
+ authProvider: this.authProvider,
27
+ requestInit: options.token
28
+ ? {
29
+ headers: {
30
+ Authorization: `Bearer ${options.token}`,
31
+ },
32
+ }
33
+ : undefined,
34
+ fetch: options.fetch,
35
+ });
36
+ }
37
+ async connect() {
38
+ try {
39
+ await this.client.connect(this.transport);
40
+ }
41
+ catch (error) {
42
+ if (!(error instanceof UnauthorizedError)) {
43
+ throw error;
44
+ }
45
+ const authResult = await auth(this.authProvider, {
46
+ serverUrl: this.options.serverUrl,
47
+ scope: this.options.scope,
48
+ fetchFn: this.options.fetch,
49
+ });
50
+ if (authResult !== "REDIRECT") {
51
+ await this.client.connect(this.transport);
52
+ return;
53
+ }
54
+ const authorizationCode = await this.authProvider.waitForAuthorizationCode();
55
+ await this.transport.finishAuth(authorizationCode);
56
+ await this.client.connect(this.transport);
57
+ }
58
+ }
59
+ async ensureAuthorized() {
60
+ const authResult = await this.runAuthFlow();
61
+ if (authResult === "REDIRECT") {
62
+ await this.completeAuthorization();
63
+ }
64
+ }
65
+ async runAuthFlow() {
66
+ return auth(this.authProvider, {
67
+ serverUrl: this.options.serverUrl,
68
+ scope: this.options.scope,
69
+ fetchFn: this.options.fetch,
70
+ });
71
+ }
72
+ async completeAuthorization() {
73
+ const authorizationCode = await this.authProvider.waitForAuthorizationCode();
74
+ await this.transport.finishAuth(authorizationCode);
75
+ }
76
+ async close() {
77
+ await this.authProvider.close();
78
+ await this.transport.close();
79
+ }
80
+ async terminateSession() {
81
+ await this.transport.terminateSession();
82
+ }
83
+ async ping() {
84
+ return this.client.ping();
85
+ }
86
+ async listTools() {
87
+ return this.client.listTools();
88
+ }
89
+ async callTool(name, args = {}) {
90
+ return this.client.callTool({
91
+ name,
92
+ arguments: args,
93
+ });
94
+ }
95
+ }
@@ -1,10 +1,11 @@
1
+ import type { McpClientLike } from "./types.js";
1
2
  export type RemoteMcpClientOptions = {
2
3
  serverUrl: string;
3
4
  token: string;
4
5
  clientName?: string;
5
6
  clientVersion?: string;
6
7
  };
7
- export declare class RemoteMcpClient {
8
+ export declare class RemoteMcpClient implements McpClientLike {
8
9
  private readonly client;
9
10
  private readonly transport;
10
11
  constructor(options: RemoteMcpClientOptions);
@@ -4,8 +4,8 @@ export class RemoteMcpClient {
4
4
  client;
5
5
  transport;
6
6
  constructor(options) {
7
- if (!options.token.startsWith("mcp_")) {
8
- throw new Error("Invalid token format. Expected mcp_xxx");
7
+ if (typeof options.token !== "string" || options.token.trim().length === 0) {
8
+ throw new Error("Invalid token. Expected a non-empty OAuth access token");
9
9
  }
10
10
  this.client = new Client({
11
11
  name: options.clientName || "crush-mcp-client",
@@ -0,0 +1,8 @@
1
+ export interface McpClientLike {
2
+ connect(): Promise<void>;
3
+ close(): Promise<void>;
4
+ terminateSession(): Promise<void>;
5
+ ping(): Promise<unknown>;
6
+ listTools(): Promise<unknown>;
7
+ callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crush-protocol/mcp-client",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "description": "Crush MCP npm client package (remote Streamable HTTP + optional ClickHouse direct)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,9 @@
13
13
  "dist",
14
14
  "INSTRUCTIONS.md"
15
15
  ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
16
19
  "exports": {
17
20
  ".": {
18
21
  "types": "./dist/index.d.ts",
@@ -23,7 +26,7 @@
23
26
  "@modelcontextprotocol/sdk": "^1.26.0",
24
27
  "dotenv": "^17.2.1",
25
28
  "zod": "^3.25.76",
26
- "@crush-protocol/mcp-contracts": "0.1.0"
29
+ "@crush-protocol/mcp-contracts": "0.1.1"
27
30
  },
28
31
  "devDependencies": {
29
32
  "@types/node": "^24.3.0",
@@ -38,6 +41,7 @@
38
41
  "scripts": {
39
42
  "build": "tsc -p tsconfig.json",
40
43
  "dev": "tsx src/cli.ts",
44
+ "test": "vitest run",
41
45
  "test:e2e": "dotenv -e .env.e2e vitest run src/__tests__/e2e.test.ts"
42
46
  }
43
47
  }