@crush-protocol/mcp-client 0.4.2 → 0.4.4
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 +21 -1
- package/README.md +278 -369
- package/dist/cli.js +140 -12
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/mcp/authPreflight.d.ts +9 -0
- package/dist/mcp/authPreflight.js +26 -0
- package/dist/mcp/oauthProvider.d.ts +2 -0
- package/dist/mcp/oauthProvider.js +21 -11
- package/dist/mcp/oauthRemoteClient.d.ts +3 -0
- package/dist/mcp/oauthRemoteClient.js +31 -18
- package/dist/mcp/oauthStorage.d.ts +29 -0
- package/dist/mcp/oauthStorage.js +81 -0
- package/dist/mcp/proxy.js +30 -137
- package/dist/onboarding/cliOutput.d.ts +14 -0
- package/dist/onboarding/cliOutput.js +80 -0
- package/dist/setup/setupClients.d.ts +8 -1
- package/dist/setup/setupClients.js +23 -20
- package/package.json +24 -3
- package/dist/__tests__/e2e.test.d.ts +0 -1
- package/dist/__tests__/e2e.test.js +0 -50
- package/dist/__tests__/oauthProvider.test.d.ts +0 -1
- package/dist/__tests__/oauthProvider.test.js +0 -45
package/dist/cli.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
import "dotenv/config";
|
|
3
3
|
import { BacktestClient } from "./backtest/backtestClient.js";
|
|
4
4
|
import { ClickHouseDirectClient } from "./clickhouse/directClient.js";
|
|
5
|
+
import { DEFAULT_MCP_SERVER_URL } from "./config.js";
|
|
6
|
+
import { assertManualLoginNotRequired } from "./mcp/authPreflight.js";
|
|
5
7
|
import { OAuthRemoteMcpClient } from "./mcp/oauthRemoteClient.js";
|
|
8
|
+
import { getCachedAuthStatus, loadStoredTokens } from "./mcp/oauthStorage.js";
|
|
6
9
|
import { runProxy } from "./mcp/proxy.js";
|
|
7
10
|
import { RemoteMcpClient } from "./mcp/remoteClient.js";
|
|
11
|
+
import { CLIENT_VERSION } from "./mcp/version.js";
|
|
12
|
+
import { formatAuthStatus, formatDoctorReport, formatSetupSummary, getLoginCommand, } from "./onboarding/cliOutput.js";
|
|
8
13
|
import { ALL_TARGETS, installClientConfig } from "./setup/setupClients.js";
|
|
9
|
-
const MCP_SERVER_URL = process.env.CRUSH_MCP_SERVER_URL ||
|
|
14
|
+
const MCP_SERVER_URL = process.env.CRUSH_MCP_SERVER_URL || DEFAULT_MCP_SERVER_URL;
|
|
10
15
|
const printUsage = () => {
|
|
11
|
-
console.log(`\ncrush-mcp-client\n\
|
|
16
|
+
console.log(`\ncrush-mcp-client v${CLIENT_VERSION}\n\nQuick start:\n setup [--cursor] [--claude] [--codex] [--gemini] [--opencode] [--all] [--scope user|project]\n login\n ping\n\nGeneral:\n auth:status Show whether Crush is authenticated on this machine\n doctor Check auth state and connectivity\n proxy [SERVER_URL] stdio proxy mode for MCP hosts\n tools:list [--token TOKEN]\n tool:call --name TOOL_NAME [--args JSON] [--token TOKEN]\n ping [--token TOKEN]\n --version\n\nBacktest:\n backtest:schema [--token TOKEN]\n backtest:tokens [--platform PLATFORM] [--token TOKEN]\n backtest:validate --expression JSON [--data-source kline|factors] [--token TOKEN]\n backtest:create --config JSON [--backtest-id ID] [--token TOKEN]\n backtest:list [--status STATUS] [--limit N] [--offset N] [--token TOKEN]\n\nClickHouse:\n clickhouse:list-tables [--ch-host HOST --ch-port PORT --ch-user USER --ch-password PASS --ch-database DB]\n clickhouse:query --sql SQL [--ch-host HOST --ch-port PORT --ch-user USER --ch-password PASS --ch-database DB --ch-row-cap N]\n\nAuthentication:\n Without --token, Crush uses locally cached OAuth credentials.\n If not authenticated yet, run:\n ${getLoginCommand(MCP_SERVER_URL)}\n\nEnv:\n CRUSH_MCP_SERVER_URL\n CRUSH_OAUTH_ACCESS_TOKEN\n CH_HOST, CH_PORT, CH_USER, CH_PASSWORD, CH_DATABASE, CH_ROW_CAP\n`);
|
|
12
17
|
};
|
|
13
18
|
const parseFlags = (args) => {
|
|
14
19
|
const flags = {};
|
|
@@ -34,6 +39,7 @@ const requireString = (value, message) => {
|
|
|
34
39
|
return value;
|
|
35
40
|
};
|
|
36
41
|
const getServerUrl = (override) => override || MCP_SERVER_URL;
|
|
42
|
+
const getExplicitToken = (flags) => typeof flags.token === "string" ? flags.token : process.env.CRUSH_OAUTH_ACCESS_TOKEN || "";
|
|
37
43
|
const getSetupTargets = (flags) => {
|
|
38
44
|
if (flags.all === true) {
|
|
39
45
|
return [...ALL_TARGETS];
|
|
@@ -54,7 +60,7 @@ const getSetupTargets = (flags) => {
|
|
|
54
60
|
const createSmartClient = (flags) => {
|
|
55
61
|
const serverUrl = getServerUrl(typeof flags.server === "string" ? flags.server : undefined);
|
|
56
62
|
// 显式传了 token → 用简单客户端
|
|
57
|
-
const explicitToken =
|
|
63
|
+
const explicitToken = getExplicitToken(flags);
|
|
58
64
|
if (explicitToken) {
|
|
59
65
|
return {
|
|
60
66
|
client: new RemoteMcpClient({ serverUrl, token: explicitToken }),
|
|
@@ -68,6 +74,11 @@ const createSmartClient = (flags) => {
|
|
|
68
74
|
};
|
|
69
75
|
};
|
|
70
76
|
const connectClient = async (flags) => {
|
|
77
|
+
const explicitToken = getExplicitToken(flags);
|
|
78
|
+
if (!explicitToken) {
|
|
79
|
+
const serverUrl = getServerUrl(typeof flags.server === "string" ? flags.server : undefined);
|
|
80
|
+
await assertManualLoginNotRequired(serverUrl);
|
|
81
|
+
}
|
|
71
82
|
const { client, needsOAuthConnect } = createSmartClient(flags);
|
|
72
83
|
if (needsOAuthConnect) {
|
|
73
84
|
await client.connect();
|
|
@@ -77,6 +88,78 @@ const connectClient = async (flags) => {
|
|
|
77
88
|
}
|
|
78
89
|
return client;
|
|
79
90
|
};
|
|
91
|
+
const buildDoctorChecks = async (flags) => {
|
|
92
|
+
const serverUrl = getServerUrl(typeof flags.server === "string" ? flags.server : undefined);
|
|
93
|
+
const checks = [];
|
|
94
|
+
const explicitToken = getExplicitToken(flags);
|
|
95
|
+
const authStatus = await getCachedAuthStatus(serverUrl);
|
|
96
|
+
checks.push({
|
|
97
|
+
status: serverUrl === DEFAULT_MCP_SERVER_URL ? "ok" : "warn",
|
|
98
|
+
label: "MCP server URL",
|
|
99
|
+
detail: serverUrl === DEFAULT_MCP_SERVER_URL
|
|
100
|
+
? "Using the official hosted Crush MCP URL."
|
|
101
|
+
: `Using a custom MCP URL: ${serverUrl}`,
|
|
102
|
+
});
|
|
103
|
+
if (explicitToken) {
|
|
104
|
+
checks.push({
|
|
105
|
+
status: "ok",
|
|
106
|
+
label: "Access token",
|
|
107
|
+
detail: "Using an explicit OAuth access token from --token or CRUSH_OAUTH_ACCESS_TOKEN.",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
else if (authStatus.status === "authenticated") {
|
|
111
|
+
checks.push({
|
|
112
|
+
status: "ok",
|
|
113
|
+
label: "Cached credentials",
|
|
114
|
+
detail: `OAuth credentials found${authStatus.storageFile ? ` in ${authStatus.storageFile}` : ""}.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else if (authStatus.status === "registered") {
|
|
118
|
+
checks.push({
|
|
119
|
+
status: "warn",
|
|
120
|
+
label: "Cached credentials",
|
|
121
|
+
detail: `Crush client registration exists, but no usable access token was found. Run: ${getLoginCommand(serverUrl)}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
checks.push({
|
|
126
|
+
status: "warn",
|
|
127
|
+
label: "Cached credentials",
|
|
128
|
+
detail: `No local Crush credentials found. Run: ${getLoginCommand(serverUrl)}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const storedTokens = explicitToken ? undefined : await loadStoredTokens(serverUrl);
|
|
132
|
+
const accessToken = explicitToken || storedTokens?.access_token || "";
|
|
133
|
+
if (!accessToken) {
|
|
134
|
+
checks.push({
|
|
135
|
+
status: "warn",
|
|
136
|
+
label: "Connectivity",
|
|
137
|
+
detail: "Skipped MCP connectivity check because no access token is available yet.",
|
|
138
|
+
});
|
|
139
|
+
return { serverUrl, checks };
|
|
140
|
+
}
|
|
141
|
+
const client = new RemoteMcpClient({ serverUrl, token: accessToken });
|
|
142
|
+
try {
|
|
143
|
+
await client.connect();
|
|
144
|
+
await client.ping();
|
|
145
|
+
checks.push({
|
|
146
|
+
status: "ok",
|
|
147
|
+
label: "Connectivity",
|
|
148
|
+
detail: "Connected to Crush MCP and ping succeeded.",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
checks.push({
|
|
153
|
+
status: "error",
|
|
154
|
+
label: "Connectivity",
|
|
155
|
+
detail: `Failed to connect to Crush MCP: ${error.message}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await client.close().catch(() => undefined);
|
|
160
|
+
}
|
|
161
|
+
return { serverUrl, checks };
|
|
162
|
+
};
|
|
80
163
|
const createClickHouseClient = (flags) => {
|
|
81
164
|
const host = typeof flags["ch-host"] === "string" ? flags["ch-host"] : (process.env.CH_HOST ?? "localhost");
|
|
82
165
|
const portRaw = typeof flags["ch-port"] === "string" ? flags["ch-port"] : (process.env.CH_PORT ?? "8123");
|
|
@@ -111,10 +194,27 @@ const run = async () => {
|
|
|
111
194
|
// 如果没有已知子命令,自动进入 proxy 模式
|
|
112
195
|
const isStdioPipe = !process.stdin.isTTY;
|
|
113
196
|
const knownCommands = new Set([
|
|
114
|
-
"proxy",
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
197
|
+
"proxy",
|
|
198
|
+
"login",
|
|
199
|
+
"setup",
|
|
200
|
+
"auth:status",
|
|
201
|
+
"doctor",
|
|
202
|
+
"tools:list",
|
|
203
|
+
"tool:call",
|
|
204
|
+
"ping",
|
|
205
|
+
"backtest:schema",
|
|
206
|
+
"backtest:tokens",
|
|
207
|
+
"backtest:validate",
|
|
208
|
+
"backtest:create",
|
|
209
|
+
"backtest:list",
|
|
210
|
+
"clickhouse:list-tables",
|
|
211
|
+
"clickhouse:query",
|
|
212
|
+
"help",
|
|
213
|
+
"--help",
|
|
214
|
+
"-h",
|
|
215
|
+
"version",
|
|
216
|
+
"--version",
|
|
217
|
+
"-v",
|
|
118
218
|
]);
|
|
119
219
|
if (isStdioPipe && (!command || !knownCommands.has(command))) {
|
|
120
220
|
// 无命令或第一个参数是 URL → 自动进入 proxy 模式
|
|
@@ -126,6 +226,10 @@ const run = async () => {
|
|
|
126
226
|
printUsage();
|
|
127
227
|
return;
|
|
128
228
|
}
|
|
229
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
230
|
+
console.log(CLIENT_VERSION);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
129
233
|
const flags = parseFlags(rest);
|
|
130
234
|
switch (command) {
|
|
131
235
|
case "proxy": {
|
|
@@ -139,11 +243,23 @@ const run = async () => {
|
|
|
139
243
|
// login [SERVER_URL] — 第一个非 flag 参数作为 server URL
|
|
140
244
|
const loginUrl = rest.find((a) => !a.startsWith("--"));
|
|
141
245
|
const serverUrl = getServerUrl(loginUrl);
|
|
142
|
-
|
|
246
|
+
const authStatus = await getCachedAuthStatus(serverUrl);
|
|
247
|
+
console.log("Connecting to Crush...");
|
|
248
|
+
console.log(`Server: ${serverUrl}`);
|
|
249
|
+
if (authStatus.status === "authenticated") {
|
|
250
|
+
console.log("Existing credentials found. Verifying they are still valid...");
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
console.log("Opening browser for authorization...");
|
|
254
|
+
console.log("If the browser does not open, use the authorization URL printed below.");
|
|
255
|
+
console.log("Waiting for authorization callback on:");
|
|
256
|
+
console.log("http://127.0.0.1:8787/oauth/callback");
|
|
257
|
+
}
|
|
143
258
|
const client = new OAuthRemoteMcpClient({ serverUrl });
|
|
144
259
|
try {
|
|
145
260
|
await client.ensureAuthorized();
|
|
146
|
-
console.log("
|
|
261
|
+
console.log("Crush login complete.");
|
|
262
|
+
console.log("Credentials are stored locally and will be reused across supported MCP hosts.");
|
|
147
263
|
}
|
|
148
264
|
finally {
|
|
149
265
|
await client.close();
|
|
@@ -153,17 +269,29 @@ const run = async () => {
|
|
|
153
269
|
case "setup": {
|
|
154
270
|
const targets = getSetupTargets(flags);
|
|
155
271
|
if (targets.length === 0) {
|
|
156
|
-
throw new Error(`Specify at least one setup target: ${ALL_TARGETS.map((t) =>
|
|
272
|
+
throw new Error(`Specify at least one setup target: ${ALL_TARGETS.map((t) => `--${t}`).join(", ")}, or --all`);
|
|
157
273
|
}
|
|
158
274
|
const rawScope = typeof flags.scope === "string" ? flags.scope : "user";
|
|
159
275
|
if (rawScope !== "user" && rawScope !== "project") {
|
|
160
276
|
throw new Error("Invalid --scope. Expected 'user' or 'project'.");
|
|
161
277
|
}
|
|
162
278
|
const scope = rawScope;
|
|
279
|
+
const results = [];
|
|
163
280
|
for (const target of targets) {
|
|
164
|
-
|
|
165
|
-
console.log(`[setup] ${target}: configured (${location})`);
|
|
281
|
+
results.push(installClientConfig(target, scope));
|
|
166
282
|
}
|
|
283
|
+
console.log(formatSetupSummary(results, scope));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
case "auth:status": {
|
|
287
|
+
const serverUrl = getServerUrl(typeof flags.server === "string" ? flags.server : undefined);
|
|
288
|
+
const authStatus = await getCachedAuthStatus(serverUrl);
|
|
289
|
+
console.log(formatAuthStatus(authStatus));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
case "doctor": {
|
|
293
|
+
const { serverUrl, checks } = await buildDoctorChecks(flags);
|
|
294
|
+
console.log(formatDoctorReport(serverUrl, checks));
|
|
167
295
|
return;
|
|
168
296
|
}
|
|
169
297
|
case "tools:list": {
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type CachedAuthStatus } from "./oauthStorage.js";
|
|
2
|
+
export type AuthPreflightResult = {
|
|
3
|
+
authStatus: CachedAuthStatus;
|
|
4
|
+
requiresManualLogin: boolean;
|
|
5
|
+
canAutoRefreshLogin: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare const getAuthPreflight: (serverUrl: string) => Promise<AuthPreflightResult>;
|
|
8
|
+
export declare const assertManualLoginNotRequired: (serverUrl: string) => Promise<AuthPreflightResult>;
|
|
9
|
+
export declare const getPreflightStatusMessage: (preflight: AuthPreflightResult) => string | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { formatAutomaticRefreshMessage, formatLoginRequiredMessage } from "../onboarding/cliOutput.js";
|
|
2
|
+
import { getCachedAuthStatus } from "./oauthStorage.js";
|
|
3
|
+
export const getAuthPreflight = async (serverUrl) => {
|
|
4
|
+
const authStatus = await getCachedAuthStatus(serverUrl);
|
|
5
|
+
return {
|
|
6
|
+
authStatus,
|
|
7
|
+
requiresManualLogin: authStatus.status === "not_authenticated",
|
|
8
|
+
canAutoRefreshLogin: authStatus.status !== "not_authenticated",
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export const assertManualLoginNotRequired = async (serverUrl) => {
|
|
12
|
+
const preflight = await getAuthPreflight(serverUrl);
|
|
13
|
+
if (preflight.requiresManualLogin) {
|
|
14
|
+
throw new Error(formatLoginRequiredMessage(serverUrl));
|
|
15
|
+
}
|
|
16
|
+
return preflight;
|
|
17
|
+
};
|
|
18
|
+
export const getPreflightStatusMessage = (preflight) => {
|
|
19
|
+
if (preflight.requiresManualLogin) {
|
|
20
|
+
return formatLoginRequiredMessage(preflight.authStatus.serverUrl);
|
|
21
|
+
}
|
|
22
|
+
if (preflight.authStatus.status === "registered") {
|
|
23
|
+
return formatAutomaticRefreshMessage(preflight.authStatus.serverUrl);
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
@@ -9,6 +9,7 @@ export type InteractiveOAuthProviderOptions = {
|
|
|
9
9
|
redirectPath?: string;
|
|
10
10
|
storageDir?: string;
|
|
11
11
|
openBrowser?: boolean;
|
|
12
|
+
authorizationOutput?: "stdout" | "stderr" | "silent";
|
|
12
13
|
onAuthorizationUrl?: (authorizationUrl: URL) => void | Promise<void>;
|
|
13
14
|
};
|
|
14
15
|
export declare class InteractiveOAuthProvider implements OAuthClientProvider {
|
|
@@ -17,6 +18,7 @@ export declare class InteractiveOAuthProvider implements OAuthClientProvider {
|
|
|
17
18
|
private readonly storageFile;
|
|
18
19
|
private readonly scope;
|
|
19
20
|
private readonly openBrowserByDefault;
|
|
21
|
+
private readonly authorizationOutput;
|
|
20
22
|
private readonly onAuthorizationUrl?;
|
|
21
23
|
private callbackServer?;
|
|
22
24
|
private pendingAuthorization?;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import os from "node:os";
|
|
6
5
|
import path from "node:path";
|
|
7
|
-
|
|
6
|
+
import { DEFAULT_OAUTH_SCOPE } from "../config.js";
|
|
7
|
+
import { defaultStorageDir, getStorageFileForServer } from "./oauthStorage.js";
|
|
8
8
|
const DEFAULT_REDIRECT_PORT = 8787;
|
|
9
9
|
const DEFAULT_REDIRECT_PATH = "/oauth/callback";
|
|
10
10
|
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -50,8 +50,6 @@ const renderCallbackHtml = (title, message) => `<!DOCTYPE html>
|
|
|
50
50
|
</main>
|
|
51
51
|
</body>
|
|
52
52
|
</html>`;
|
|
53
|
-
const defaultStorageDir = () => path.join(os.homedir(), ".crush-mcp");
|
|
54
|
-
const hashServerUrl = (serverUrl) => createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
|
|
55
53
|
const openBrowser = async (authorizationUrl) => {
|
|
56
54
|
const url = authorizationUrl.toString();
|
|
57
55
|
if (process.env.BROWSER) {
|
|
@@ -74,6 +72,7 @@ export class InteractiveOAuthProvider {
|
|
|
74
72
|
storageFile;
|
|
75
73
|
scope;
|
|
76
74
|
openBrowserByDefault;
|
|
75
|
+
authorizationOutput;
|
|
77
76
|
onAuthorizationUrl;
|
|
78
77
|
callbackServer;
|
|
79
78
|
pendingAuthorization;
|
|
@@ -82,10 +81,11 @@ export class InteractiveOAuthProvider {
|
|
|
82
81
|
const redirectPort = options.redirectPort ?? DEFAULT_REDIRECT_PORT;
|
|
83
82
|
const redirectPath = options.redirectPath ?? DEFAULT_REDIRECT_PATH;
|
|
84
83
|
this.redirectUrl = new URL(`http://127.0.0.1:${redirectPort}${redirectPath}`);
|
|
85
|
-
this.scope = options.scope ??
|
|
84
|
+
this.scope = options.scope ?? DEFAULT_OAUTH_SCOPE;
|
|
86
85
|
this.openBrowserByDefault = options.openBrowser ?? true;
|
|
86
|
+
this.authorizationOutput = options.authorizationOutput ?? "stdout";
|
|
87
87
|
const storageDir = options.storageDir ?? defaultStorageDir();
|
|
88
|
-
this.storageFile =
|
|
88
|
+
this.storageFile = getStorageFileForServer(options.serverUrl, storageDir);
|
|
89
89
|
this.onAuthorizationUrl = options.onAuthorizationUrl;
|
|
90
90
|
}
|
|
91
91
|
get clientMetadata() {
|
|
@@ -134,7 +134,12 @@ export class InteractiveOAuthProvider {
|
|
|
134
134
|
// Fall back to printing the URL when no browser launcher is available.
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
-
|
|
137
|
+
if (this.authorizationOutput === "stdout") {
|
|
138
|
+
process.stdout.write(`\nOpen this URL to authorize Crush MCP:\n${authorizationUrl.toString()}\n\n`);
|
|
139
|
+
}
|
|
140
|
+
else if (this.authorizationOutput === "stderr") {
|
|
141
|
+
process.stderr.write(`\nOpen this URL to authorize Crush MCP:\n${authorizationUrl.toString()}\n\n`);
|
|
142
|
+
}
|
|
138
143
|
}
|
|
139
144
|
async saveCodeVerifier(codeVerifier) {
|
|
140
145
|
const data = await this.loadState();
|
|
@@ -169,7 +174,11 @@ export class InteractiveOAuthProvider {
|
|
|
169
174
|
if (!this.pendingAuthorization) {
|
|
170
175
|
this.createPendingAuthorization();
|
|
171
176
|
}
|
|
172
|
-
|
|
177
|
+
const pendingAuthorization = this.pendingAuthorization;
|
|
178
|
+
if (!pendingAuthorization) {
|
|
179
|
+
throw new Error("Pending authorization was not initialized.");
|
|
180
|
+
}
|
|
181
|
+
return pendingAuthorization.promise;
|
|
173
182
|
}
|
|
174
183
|
async close() {
|
|
175
184
|
await this.closeCallbackServer();
|
|
@@ -208,9 +217,10 @@ export class InteractiveOAuthProvider {
|
|
|
208
217
|
this.callbackServer = createServer((req, res) => {
|
|
209
218
|
void this.handleCallbackRequest(req, res);
|
|
210
219
|
});
|
|
220
|
+
const callbackServer = this.callbackServer;
|
|
211
221
|
await new Promise((resolve, reject) => {
|
|
212
|
-
|
|
213
|
-
|
|
222
|
+
callbackServer.once("error", (error) => reject(error));
|
|
223
|
+
callbackServer.listen(Number(this.redirectUrl.port), this.redirectUrl.hostname, () => resolve());
|
|
214
224
|
});
|
|
215
225
|
}
|
|
216
226
|
async closeCallbackServer() {
|
|
@@ -16,6 +16,9 @@ export declare class OAuthRemoteMcpClient implements McpClientLike {
|
|
|
16
16
|
private readonly transport;
|
|
17
17
|
private readonly authProvider;
|
|
18
18
|
constructor(options: OAuthRemoteMcpClientOptions);
|
|
19
|
+
private isAuthError;
|
|
20
|
+
private recoverAuthorization;
|
|
21
|
+
private withReauthorization;
|
|
19
22
|
connect(): Promise<void>;
|
|
20
23
|
ensureAuthorized(): Promise<void>;
|
|
21
24
|
runAuthFlow(): Promise<AuthResult>;
|
|
@@ -35,25 +35,38 @@ export class OAuthRemoteMcpClient {
|
|
|
35
35
|
fetch: options.fetch,
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
|
+
isAuthError(error) {
|
|
39
|
+
return (error instanceof UnauthorizedError || String(error).includes("Unauthorized") || String(error).includes("401"));
|
|
40
|
+
}
|
|
41
|
+
async recoverAuthorization(error) {
|
|
42
|
+
if (!this.isAuthError(error)) {
|
|
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
|
+
const authorizationCode = await this.authProvider.waitForAuthorizationCode();
|
|
52
|
+
await this.transport.finishAuth(authorizationCode);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async withReauthorization(operation) {
|
|
56
|
+
try {
|
|
57
|
+
return await operation();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
await this.recoverAuthorization(error);
|
|
61
|
+
return operation();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
38
64
|
async connect() {
|
|
39
65
|
try {
|
|
40
66
|
await this.client.connect(this.transport);
|
|
41
67
|
}
|
|
42
68
|
catch (error) {
|
|
43
|
-
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
const authResult = await auth(this.authProvider, {
|
|
47
|
-
serverUrl: this.options.serverUrl,
|
|
48
|
-
scope: this.options.scope,
|
|
49
|
-
fetchFn: this.options.fetch,
|
|
50
|
-
});
|
|
51
|
-
if (authResult !== "REDIRECT") {
|
|
52
|
-
await this.client.connect(this.transport);
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const authorizationCode = await this.authProvider.waitForAuthorizationCode();
|
|
56
|
-
await this.transport.finishAuth(authorizationCode);
|
|
69
|
+
await this.recoverAuthorization(error);
|
|
57
70
|
await this.client.connect(this.transport);
|
|
58
71
|
}
|
|
59
72
|
}
|
|
@@ -82,15 +95,15 @@ export class OAuthRemoteMcpClient {
|
|
|
82
95
|
await this.transport.terminateSession();
|
|
83
96
|
}
|
|
84
97
|
async ping() {
|
|
85
|
-
return this.client.ping();
|
|
98
|
+
return this.withReauthorization(() => this.client.ping());
|
|
86
99
|
}
|
|
87
100
|
async listTools() {
|
|
88
|
-
return this.client.listTools();
|
|
101
|
+
return this.withReauthorization(() => this.client.listTools());
|
|
89
102
|
}
|
|
90
103
|
async callTool(name, args = {}) {
|
|
91
|
-
return this.client.callTool({
|
|
104
|
+
return this.withReauthorization(() => this.client.callTool({
|
|
92
105
|
name,
|
|
93
106
|
arguments: args,
|
|
94
|
-
});
|
|
107
|
+
}));
|
|
95
108
|
}
|
|
96
109
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
2
|
+
export type PersistedOAuthState = {
|
|
3
|
+
clientInformation?: OAuthClientInformationMixed;
|
|
4
|
+
tokens?: OAuthTokens;
|
|
5
|
+
codeVerifier?: string;
|
|
6
|
+
};
|
|
7
|
+
export type CachedAuthStatus = {
|
|
8
|
+
serverUrl: string;
|
|
9
|
+
matchedServerUrl?: string;
|
|
10
|
+
storageFile?: string;
|
|
11
|
+
status: "not_authenticated" | "registered" | "authenticated";
|
|
12
|
+
hasClientInformation: boolean;
|
|
13
|
+
hasAccessToken: boolean;
|
|
14
|
+
hasRefreshToken: boolean;
|
|
15
|
+
hasCodeVerifier: boolean;
|
|
16
|
+
scope?: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const defaultStorageDir: () => string;
|
|
19
|
+
export declare const hashServerUrl: (serverUrl: string) => string;
|
|
20
|
+
export declare const getStorageFileForServer: (serverUrl: string, storageDir?: string) => string;
|
|
21
|
+
export declare const getServerUrlCandidates: (serverUrl: string) => string[];
|
|
22
|
+
export declare const readPersistedOAuthState: (storageFile: string) => Promise<PersistedOAuthState | null>;
|
|
23
|
+
export declare const findPersistedOAuthState: (serverUrl: string, storageDir?: string) => Promise<{
|
|
24
|
+
matchedServerUrl: string;
|
|
25
|
+
storageFile: string;
|
|
26
|
+
state: PersistedOAuthState;
|
|
27
|
+
} | null>;
|
|
28
|
+
export declare const loadStoredTokens: (serverUrl: string, storageDir?: string) => Promise<OAuthTokens | undefined>;
|
|
29
|
+
export declare const getCachedAuthStatus: (serverUrl: string, storageDir?: string) => Promise<CachedAuthStatus>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const defaultStorageDir = () => path.join(os.homedir(), ".crush-mcp");
|
|
6
|
+
export const hashServerUrl = (serverUrl) => createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
|
|
7
|
+
export const getStorageFileForServer = (serverUrl, storageDir = defaultStorageDir()) => path.join(storageDir, `oauth-${hashServerUrl(serverUrl)}.json`);
|
|
8
|
+
export const getServerUrlCandidates = (serverUrl) => {
|
|
9
|
+
const candidates = [serverUrl];
|
|
10
|
+
if (serverUrl.endsWith("/mcp")) {
|
|
11
|
+
candidates.push(serverUrl.replace(/\/mcp$/, ""));
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
candidates.push(`${serverUrl}/mcp`);
|
|
15
|
+
}
|
|
16
|
+
return [...new Set(candidates)];
|
|
17
|
+
};
|
|
18
|
+
export const readPersistedOAuthState = async (storageFile) => {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(storageFile, "utf8");
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const err = error;
|
|
25
|
+
if (err.code === "ENOENT") {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
export const findPersistedOAuthState = async (serverUrl, storageDir = defaultStorageDir()) => {
|
|
32
|
+
for (const candidate of getServerUrlCandidates(serverUrl)) {
|
|
33
|
+
const storageFile = getStorageFileForServer(candidate, storageDir);
|
|
34
|
+
const state = await readPersistedOAuthState(storageFile);
|
|
35
|
+
if (state) {
|
|
36
|
+
return {
|
|
37
|
+
matchedServerUrl: candidate,
|
|
38
|
+
storageFile,
|
|
39
|
+
state,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
export const loadStoredTokens = async (serverUrl, storageDir = defaultStorageDir()) => {
|
|
46
|
+
const persisted = await findPersistedOAuthState(serverUrl, storageDir);
|
|
47
|
+
return persisted?.state.tokens;
|
|
48
|
+
};
|
|
49
|
+
export const getCachedAuthStatus = async (serverUrl, storageDir = defaultStorageDir()) => {
|
|
50
|
+
const persisted = await findPersistedOAuthState(serverUrl, storageDir);
|
|
51
|
+
if (!persisted) {
|
|
52
|
+
return {
|
|
53
|
+
serverUrl,
|
|
54
|
+
status: "not_authenticated",
|
|
55
|
+
hasClientInformation: false,
|
|
56
|
+
hasAccessToken: false,
|
|
57
|
+
hasRefreshToken: false,
|
|
58
|
+
hasCodeVerifier: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const { state, matchedServerUrl, storageFile } = persisted;
|
|
62
|
+
const hasClientInformation = Boolean(state.clientInformation);
|
|
63
|
+
const hasAccessToken = typeof state.tokens?.access_token === "string" && state.tokens.access_token.length > 0;
|
|
64
|
+
const hasRefreshToken = typeof state.tokens?.refresh_token === "string" && state.tokens.refresh_token.length > 0;
|
|
65
|
+
const hasCodeVerifier = typeof state.codeVerifier === "string" && state.codeVerifier.length > 0;
|
|
66
|
+
return {
|
|
67
|
+
serverUrl,
|
|
68
|
+
matchedServerUrl,
|
|
69
|
+
storageFile,
|
|
70
|
+
status: hasAccessToken
|
|
71
|
+
? "authenticated"
|
|
72
|
+
: hasClientInformation || hasCodeVerifier
|
|
73
|
+
? "registered"
|
|
74
|
+
: "not_authenticated",
|
|
75
|
+
hasClientInformation,
|
|
76
|
+
hasAccessToken,
|
|
77
|
+
hasRefreshToken,
|
|
78
|
+
hasCodeVerifier,
|
|
79
|
+
scope: state.tokens?.scope,
|
|
80
|
+
};
|
|
81
|
+
};
|