@crush-protocol/mcp-client 0.1.14 → 0.3.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 +139 -89
- package/dist/__tests__/e2e.test.js +7 -7
- 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 +97 -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/dist/setup/setupClients.d.ts +3 -0
- package/dist/setup/setupClients.js +119 -0
- package/package.json +6 -2
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
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";
|
|
7
|
+
import { installClientConfig } from "./setup/setupClients.js";
|
|
8
|
+
const DEFAULT_MCP_SERVER_URL = "https://crush-mcp-ats.dev.xexlab.com/mcp";
|
|
6
9
|
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:
|
|
10
|
+
console.log(`\ncrush-mcp-client\n\nGeneral:\n login [--url URL]\n setup [--cursor] [--claude] [--codex] [--gemini] [--opencode] [--all] [--scope user|project] [--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 uses a provided OAuth access token.\n Without --token, OAuth runs automatically in the browser when needed.\n\nEnv:\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
11
|
};
|
|
9
12
|
const parseFlags = (args) => {
|
|
10
13
|
const flags = {};
|
|
@@ -29,34 +32,60 @@ const requireString = (value, message) => {
|
|
|
29
32
|
}
|
|
30
33
|
return value;
|
|
31
34
|
};
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
const getServerUrl = (flags) => typeof flags.url === "string"
|
|
36
|
+
? flags.url
|
|
37
|
+
: (process.env.CRUSH_MCP_SERVER_URL ?? DEFAULT_MCP_SERVER_URL);
|
|
38
|
+
const getSetupTargets = (flags) => {
|
|
39
|
+
const explicitTargets = ["cursor", "claude", "codex", "gemini", "opencode"].filter((target) => flags[target] === true);
|
|
40
|
+
if (flags.all === true) {
|
|
41
|
+
return ["cursor", "claude", "codex", "gemini", "opencode"];
|
|
42
|
+
}
|
|
43
|
+
return explicitTargets;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* 创建 MCP 客户端(统一认证入口)
|
|
47
|
+
*
|
|
48
|
+
* 优先级:
|
|
49
|
+
* 1. --token 参数 → 直接使用(适用于 CI/脚本)
|
|
50
|
+
* 2. CRUSH_OAUTH_ACCESS_TOKEN 环境变量 → 直接使用
|
|
51
|
+
* 3. 以上都没有 → 走 OAuthRemoteMcpClient 自动 OAuth 流程
|
|
52
|
+
* - 本地有缓存 token → 直接用
|
|
53
|
+
* - token 过期 → 自动 refresh
|
|
54
|
+
* - 无 token → 自动拉起浏览器登录
|
|
55
|
+
*/
|
|
56
|
+
const createSmartClient = (flags) => {
|
|
57
|
+
const serverUrl = getServerUrl(flags);
|
|
58
|
+
// 显式传了 token → 用简单客户端
|
|
59
|
+
const explicitToken = typeof flags.token === "string" ? flags.token : process.env.CRUSH_OAUTH_ACCESS_TOKEN || "";
|
|
60
|
+
if (explicitToken) {
|
|
61
|
+
return {
|
|
62
|
+
client: new RemoteMcpClient({ serverUrl, token: explicitToken }),
|
|
63
|
+
needsOAuthConnect: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// 否则走 OAuth 自动流程
|
|
67
|
+
return {
|
|
68
|
+
client: new OAuthRemoteMcpClient({ serverUrl }),
|
|
69
|
+
needsOAuthConnect: true,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
const connectClient = async (flags) => {
|
|
73
|
+
const { client, needsOAuthConnect } = createSmartClient(flags);
|
|
74
|
+
if (needsOAuthConnect) {
|
|
75
|
+
await client.connect();
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await client.connect();
|
|
79
|
+
}
|
|
80
|
+
return client;
|
|
40
81
|
};
|
|
41
82
|
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;
|
|
83
|
+
const host = typeof flags["ch-host"] === "string" ? flags["ch-host"] : (process.env.CH_HOST ?? "localhost");
|
|
84
|
+
const portRaw = typeof flags["ch-port"] === "string" ? flags["ch-port"] : (process.env.CH_PORT ?? "8123");
|
|
85
|
+
const user = typeof flags["ch-user"] === "string" ? flags["ch-user"] : (process.env.CH_USER ?? "default");
|
|
86
|
+
const password = typeof flags["ch-password"] === "string" ? flags["ch-password"] : (process.env.CH_PASSWORD ?? "");
|
|
87
|
+
const database = typeof flags["ch-database"] === "string" ? flags["ch-database"] : (process.env.CH_DATABASE ?? "crush_ats");
|
|
88
|
+
const rowCapRaw = typeof flags["ch-row-cap"] === "string" ? flags["ch-row-cap"] : process.env.CH_ROW_CAP;
|
|
60
89
|
const port = Number(portRaw);
|
|
61
90
|
if (!Number.isFinite(port) || port <= 0) {
|
|
62
91
|
throw new Error(`Invalid ClickHouse port: ${portRaw}`);
|
|
@@ -80,18 +109,43 @@ const createClickHouseClient = (flags) => {
|
|
|
80
109
|
};
|
|
81
110
|
const run = async () => {
|
|
82
111
|
const [, , command, ...rest] = process.argv;
|
|
83
|
-
if (!command ||
|
|
84
|
-
command === "help" ||
|
|
85
|
-
command === "--help" ||
|
|
86
|
-
command === "-h") {
|
|
112
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
87
113
|
printUsage();
|
|
88
114
|
return;
|
|
89
115
|
}
|
|
90
116
|
const flags = parseFlags(rest);
|
|
91
117
|
switch (command) {
|
|
118
|
+
case "login": {
|
|
119
|
+
const serverUrl = getServerUrl(flags);
|
|
120
|
+
const client = new OAuthRemoteMcpClient({ serverUrl });
|
|
121
|
+
try {
|
|
122
|
+
await client.ensureAuthorized();
|
|
123
|
+
console.log("OAuth authorization is ready. Tokens are stored locally.");
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await client.close();
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
case "setup": {
|
|
131
|
+
const targets = getSetupTargets(flags);
|
|
132
|
+
if (targets.length === 0) {
|
|
133
|
+
throw new Error("Specify at least one setup target: --cursor, --claude, --codex, --gemini, --opencode, or --all");
|
|
134
|
+
}
|
|
135
|
+
const rawScope = typeof flags.scope === "string" ? flags.scope : "user";
|
|
136
|
+
if (rawScope !== "user" && rawScope !== "project") {
|
|
137
|
+
throw new Error("Invalid --scope. Expected 'user' or 'project'.");
|
|
138
|
+
}
|
|
139
|
+
const scope = rawScope;
|
|
140
|
+
const serverUrl = getServerUrl(flags);
|
|
141
|
+
for (const target of targets) {
|
|
142
|
+
const location = installClientConfig(target, serverUrl, scope);
|
|
143
|
+
console.log(`[setup] ${target}: configured (${location})`);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
92
147
|
case "tools:list": {
|
|
93
|
-
const client =
|
|
94
|
-
await client.connect();
|
|
148
|
+
const client = await connectClient(flags);
|
|
95
149
|
try {
|
|
96
150
|
const result = await client.listTools();
|
|
97
151
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -111,8 +165,7 @@ const run = async () => {
|
|
|
111
165
|
catch (e) {
|
|
112
166
|
throw new Error(`Invalid --args JSON: ${e.message}`);
|
|
113
167
|
}
|
|
114
|
-
const client =
|
|
115
|
-
await client.connect();
|
|
168
|
+
const client = await connectClient(flags);
|
|
116
169
|
try {
|
|
117
170
|
const result = await client.callTool(name, parsedArgs);
|
|
118
171
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -123,8 +176,7 @@ const run = async () => {
|
|
|
123
176
|
return;
|
|
124
177
|
}
|
|
125
178
|
case "ping": {
|
|
126
|
-
const client =
|
|
127
|
-
await client.connect();
|
|
179
|
+
const client = await connectClient(flags);
|
|
128
180
|
try {
|
|
129
181
|
const result = await client.ping();
|
|
130
182
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -135,8 +187,7 @@ const run = async () => {
|
|
|
135
187
|
return;
|
|
136
188
|
}
|
|
137
189
|
case "backtest:schema": {
|
|
138
|
-
const client =
|
|
139
|
-
await client.connect();
|
|
190
|
+
const client = await connectClient(flags);
|
|
140
191
|
try {
|
|
141
192
|
const bt = new BacktestClient(client);
|
|
142
193
|
const result = await bt.getConfigSchema();
|
|
@@ -148,8 +199,7 @@ const run = async () => {
|
|
|
148
199
|
return;
|
|
149
200
|
}
|
|
150
201
|
case "backtest:tokens": {
|
|
151
|
-
const client =
|
|
152
|
-
await client.connect();
|
|
202
|
+
const client = await connectClient(flags);
|
|
153
203
|
try {
|
|
154
204
|
const bt = new BacktestClient(client);
|
|
155
205
|
const platform = typeof flags.platform === "string" ? flags.platform : undefined;
|
|
@@ -174,11 +224,8 @@ const run = async () => {
|
|
|
174
224
|
catch (e) {
|
|
175
225
|
throw new Error(`Invalid --expression JSON: ${e.message}`);
|
|
176
226
|
}
|
|
177
|
-
const dataSource = typeof flags["data-source"] === "string"
|
|
178
|
-
|
|
179
|
-
: undefined;
|
|
180
|
-
const client = createRemoteClient(flags);
|
|
181
|
-
await client.connect();
|
|
227
|
+
const dataSource = typeof flags["data-source"] === "string" ? flags["data-source"] : undefined;
|
|
228
|
+
const client = await connectClient(flags);
|
|
182
229
|
try {
|
|
183
230
|
const bt = new BacktestClient(client);
|
|
184
231
|
const result = await bt.validateExpression({ expression, dataSource });
|
|
@@ -198,11 +245,8 @@ const run = async () => {
|
|
|
198
245
|
catch (e) {
|
|
199
246
|
throw new Error(`Invalid --config JSON: ${e.message}`);
|
|
200
247
|
}
|
|
201
|
-
const backtestId = typeof flags["backtest-id"] === "string"
|
|
202
|
-
|
|
203
|
-
: undefined;
|
|
204
|
-
const client = createRemoteClient(flags);
|
|
205
|
-
await client.connect();
|
|
248
|
+
const backtestId = typeof flags["backtest-id"] === "string" ? flags["backtest-id"] : undefined;
|
|
249
|
+
const client = await connectClient(flags);
|
|
206
250
|
try {
|
|
207
251
|
const bt = new BacktestClient(client);
|
|
208
252
|
const result = await bt.createBacktest({ config, backtestId });
|
|
@@ -213,28 +257,11 @@ const run = async () => {
|
|
|
213
257
|
}
|
|
214
258
|
return;
|
|
215
259
|
}
|
|
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
260
|
case "backtest:list": {
|
|
231
|
-
const status = typeof flags.status === "string"
|
|
232
|
-
? flags.status
|
|
233
|
-
: undefined;
|
|
261
|
+
const status = typeof flags.status === "string" ? flags.status : undefined;
|
|
234
262
|
const limit = typeof flags.limit === "string" ? Number(flags.limit) : undefined;
|
|
235
263
|
const offset = typeof flags.offset === "string" ? Number(flags.offset) : undefined;
|
|
236
|
-
const client =
|
|
237
|
-
await client.connect();
|
|
264
|
+
const client = await connectClient(flags);
|
|
238
265
|
try {
|
|
239
266
|
const bt = new BacktestClient(client);
|
|
240
267
|
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
|
+
}
|