@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/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 || "https://crush-mcp-ats.dev.xexlab.com/mcp";
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\nGeneral:\n login [SERVER_URL]\n proxy [SERVER_URL] — stdio proxy (login once, use everywhere)\n setup [--cursor] [--claude] [--codex] [--gemini] [--opencode] [--all] [--scope user|project]\n tools:list [--token TOKEN]\n tool:call --name TOOL_NAME [--args JSON] [--token TOKEN]\n ping [--token TOKEN]\n\nBacktest:\n backtest:schema [--token TOKEN]\n backtest:tokens [--platform PLATFORM] [--token TOKEN]\n backtest:validate --expression JSON [--data-source kline|factors] [--token TOKEN]\n backtest:create --config JSON [--backtest-id ID] [--token TOKEN]\n backtest:list [--status STATUS] [--limit N] [--offset N] [--token TOKEN]\n\nClickHouse:\n clickhouse:list-tables [--ch-host HOST --ch-port PORT --ch-user USER --ch-password PASS --ch-database DB]\n clickhouse:query --sql SQL [--ch-host HOST --ch-port PORT --ch-user USER --ch-password PASS --ch-database DB --ch-row-cap N]\n\nAuth:\n --token TOKEN uses a provided OAuth access token.\n Without --token, OAuth runs automatically in the browser when needed.\n\nProxy Mode (recommended for AI tools):\n 1. Login once: npx @crush-protocol/mcp-client login SERVER_URL\n 2. Configure: { "command": "npx", "args": ["-y", "@crush-protocol/mcp-client", "proxy", "SERVER_URL"] }\n\nEnv:\n CRUSH_MCP_SERVER_URL\n CRUSH_OAUTH_ACCESS_TOKEN\n CH_HOST, CH_PORT, CH_USER, CH_PASSWORD, CH_DATABASE, CH_ROW_CAP\n`);
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 = typeof flags.token === "string" ? flags.token : process.env.CRUSH_OAUTH_ACCESS_TOKEN || "";
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", "login", "setup", "tools:list", "tool:call", "ping",
115
- "backtest:schema", "backtest:tokens", "backtest:validate", "backtest:create", "backtest:list",
116
- "clickhouse:list-tables", "clickhouse:query",
117
- "help", "--help", "-h",
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
- console.log(`Connecting to ${serverUrl} ...`);
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("OAuth authorization is ready. Tokens are stored locally.");
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) => "--" + t).join(", ")}, or --all`);
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
- const location = installClientConfig(target, scope);
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": {
@@ -0,0 +1,4 @@
1
+ export declare const PACKAGE_NAME = "@crush-protocol/mcp-client";
2
+ export declare const SERVER_NAME = "crush-protocol";
3
+ export declare const DEFAULT_MCP_SERVER_URL = "https://crush-mcp-ats.dev.xexlab.com/mcp";
4
+ export declare const DEFAULT_OAUTH_SCOPE = "mcp:tools";
package/dist/config.js ADDED
@@ -0,0 +1,4 @@
1
+ export const PACKAGE_NAME = "@crush-protocol/mcp-client";
2
+ export const SERVER_NAME = "crush-protocol";
3
+ export const DEFAULT_MCP_SERVER_URL = "https://crush-mcp-ats.dev.xexlab.com/mcp";
4
+ export const DEFAULT_OAUTH_SCOPE = "mcp:tools";
@@ -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 { createHash, randomBytes } from "node:crypto";
2
+ import { randomBytes } from "node:crypto";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
- import os from "node:os";
6
5
  import path from "node:path";
7
- const DEFAULT_SCOPE = "mcp:tools";
6
+ import { DEFAULT_OAUTH_SCOPE } from "../config.js";
7
+ import { defaultStorageDir, getStorageFileForServer } from "./oauthStorage.js";
8
8
  const DEFAULT_REDIRECT_PORT = 8787;
9
9
  const DEFAULT_REDIRECT_PATH = "/oauth/callback";
10
10
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -50,8 +50,6 @@ const renderCallbackHtml = (title, message) => `<!DOCTYPE html>
50
50
  </main>
51
51
  </body>
52
52
  </html>`;
53
- const defaultStorageDir = () => path.join(os.homedir(), ".crush-mcp");
54
- const hashServerUrl = (serverUrl) => createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
55
53
  const openBrowser = async (authorizationUrl) => {
56
54
  const url = authorizationUrl.toString();
57
55
  if (process.env.BROWSER) {
@@ -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 ?? DEFAULT_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 = path.join(storageDir, `oauth-${hashServerUrl(options.serverUrl)}.json`);
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
- process.stdout.write(`\nOpen this URL to authorize Crush MCP:\n${authorizationUrl.toString()}\n\n`);
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
- return this.pendingAuthorization.promise;
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
- this.callbackServer.once("error", (error) => reject(error));
213
- this.callbackServer.listen(Number(this.redirectUrl.port), this.redirectUrl.hostname, () => resolve());
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
- if (!(error instanceof UnauthorizedError)) {
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
+ };