@crush-protocol/mcp-client 0.1.14 ā 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 +4 -3
- package/README.md +111 -16
- package/dist/__tests__/e2e.test.js +5 -5
- package/dist/__tests__/oauthProvider.test.d.ts +1 -0
- package/dist/__tests__/oauthProvider.test.js +45 -0
- package/dist/backtest/backtestClient.d.ts +2 -9
- package/dist/backtest/backtestClient.js +1 -8
- package/dist/cli.js +71 -70
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -1
- package/dist/mcp/oauthProvider.d.ts +42 -0
- package/dist/mcp/oauthProvider.js +264 -0
- package/dist/mcp/oauthRemoteClient.d.ts +171 -0
- package/dist/mcp/oauthRemoteClient.js +95 -0
- package/dist/mcp/remoteClient.d.ts +2 -1
- package/dist/mcp/remoteClient.js +2 -2
- package/dist/mcp/types.d.ts +8 -0
- package/dist/mcp/types.js +1 -0
- package/package.json +6 -2
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.
|
|
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.
|
|
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
|
@@ -32,18 +32,81 @@ The AI agent calls the Crush Protocol MCP tools directly ā no tab-switching, n
|
|
|
32
32
|
|
|
33
33
|
## Authentication
|
|
34
34
|
|
|
35
|
-
Crush Protocol uses **
|
|
35
|
+
Crush Protocol MCP uses **OAuth 2.1 Authorization Code + PKCE**.
|
|
36
36
|
|
|
37
|
-
|
|
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
38
|
|
|
39
|
-
|
|
40
|
-
2. A browser window opens automatically
|
|
41
|
-
3. Complete wallet authentication in the browser (Privy + wallet signature)
|
|
42
|
-
4. The terminal receives the authorization and saves it locally
|
|
39
|
+
This means the published CLI and the URL-only MCP setup now follow the same auth model:
|
|
43
40
|
|
|
44
|
-
|
|
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`
|
|
45
44
|
|
|
46
|
-
|
|
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
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## CLI Usage
|
|
54
|
+
|
|
55
|
+
The CLI is now an OAuth-aware MCP client, not a separate legacy auth path.
|
|
56
|
+
|
|
57
|
+
### Common commands
|
|
58
|
+
|
|
59
|
+
```sh
|
|
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
|
|
76
|
+
```
|
|
77
|
+
|
|
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.
|
|
47
110
|
|
|
48
111
|
---
|
|
49
112
|
|
|
@@ -57,6 +120,8 @@ claude mcp add --scope user crush-protocol \
|
|
|
57
120
|
--url https://mcp.crush-protocol.com/mcp
|
|
58
121
|
```
|
|
59
122
|
|
|
123
|
+
On first use, Claude-compatible hosts should open the browser and complete OAuth automatically.
|
|
124
|
+
|
|
60
125
|
### Cursor
|
|
61
126
|
|
|
62
127
|
Add to `~/.cursor/mcp.json`:
|
|
@@ -93,7 +158,36 @@ Add to your MCP configuration:
|
|
|
93
158
|
}
|
|
94
159
|
```
|
|
95
160
|
|
|
96
|
-
> On first use, the client will prompt you to log in via your browser
|
|
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.
|
|
97
191
|
|
|
98
192
|
---
|
|
99
193
|
|
|
@@ -102,7 +196,7 @@ Add to your MCP configuration:
|
|
|
102
196
|
| Category | Tools |
|
|
103
197
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
104
198
|
| **Signal Discovery** | `get_signal_metadata`, `get_signals_by_category` |
|
|
105
|
-
| **Backtest** | `get_backtest_config_schema`, `get_available_tokens`, `validate_expression`, `create_backtest`, `
|
|
199
|
+
| **Backtest** | `get_backtest_config_schema`, `get_available_tokens`, `validate_expression`, `create_backtest`, `list_backtests` |
|
|
106
200
|
| **Market Data** | `list_tables`, `list_tokens`, `list_indicators`, `list_timeframes`, `get_data_range`, `check_query_size`, `fetch_ohlcv`, `fetch_indicator`, `get_connection_config` |
|
|
107
201
|
| **Custom Indicators** | `save_custom_indicator`, `list_custom_indicators`, `get_custom_indicator`, `delete_custom_indicator` |
|
|
108
202
|
| **Strategy Management** | `create_strategy`, `list_strategies`, `get_strategy`, `update_strategy`, `delete_strategy`, `toggle_strategy`, `get_strategy_logs` |
|
|
@@ -115,9 +209,10 @@ Add to your MCP configuration:
|
|
|
115
209
|
|
|
116
210
|
## Environment Variables
|
|
117
211
|
|
|
118
|
-
| Variable
|
|
119
|
-
|
|
|
120
|
-
| `CRUSH_MCP_SERVER_URL`
|
|
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 |
|
|
121
216
|
|
|
122
217
|
---
|
|
123
218
|
|
|
@@ -126,9 +221,9 @@ Add to your MCP configuration:
|
|
|
126
221
|
For programmatic access:
|
|
127
222
|
|
|
128
223
|
```typescript
|
|
129
|
-
import {
|
|
224
|
+
import { OAuthRemoteMcpClient, BacktestClient } from '@crush-protocol/mcp-client'
|
|
130
225
|
|
|
131
|
-
const mcp = new
|
|
226
|
+
const mcp = new OAuthRemoteMcpClient({
|
|
132
227
|
serverUrl: 'https://mcp.crush-protocol.com/mcp',
|
|
133
228
|
})
|
|
134
229
|
await mcp.connect()
|
|
@@ -139,7 +234,7 @@ const bt = await backtest.createBacktest({
|
|
|
139
234
|
/* ... */
|
|
140
235
|
},
|
|
141
236
|
})
|
|
142
|
-
const result = await backtest.
|
|
237
|
+
const result = await backtest.list({ limit: 10 })
|
|
143
238
|
|
|
144
239
|
await mcp.close()
|
|
145
240
|
```
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
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);
|
package/dist/mcp/remoteClient.js
CHANGED
|
@@ -4,8 +4,8 @@ export class RemoteMcpClient {
|
|
|
4
4
|
client;
|
|
5
5
|
transport;
|
|
6
6
|
constructor(options) {
|
|
7
|
-
if (
|
|
8
|
-
throw new Error("Invalid token
|
|
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 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crush-protocol/mcp-client",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
}
|