@scorehub/mcp-server 0.2.8 → 0.2.9

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/README.md CHANGED
@@ -82,7 +82,7 @@ In `mcp.json`:
82
82
  2. It fetches the list of available tools
83
83
  3. The first time you use a remote tool, you'll be prompted to run the `authorize` tool
84
84
  4. Your browser opens to the ScoreHub login page
85
- 5. After authorizing, the browser redirects to `localhost:19836` and the token is saved
85
+ 5. After authorizing, the browser redirects to `127.0.0.1:<random-port>/callback`, shows a local status page, and the token is saved
86
86
  6. Subsequent calls use the cached token — no repeated login needed
87
87
 
88
88
  ## Configuration
@@ -151,18 +151,21 @@ These tools are **dynamically loaded** from the remote MCP endpoint at startup a
151
151
  ├─ tools/call: authorize ──────────┤
152
152
  │ (clears any existing token) │
153
153
  │ │
154
- ├─ generates random state │
154
+ ├─ opens local callback on 127.0.0.1:<random-port>
155
+ ├─ generates random state + PKCE │
155
156
  ├─ opens browser → /authorize ────▶│
156
157
  │ user logs in + grants access
157
- │ ◀── redirect to localhost:19836 │
158
+ │ ◀── redirect to 127.0.0.1:<random-port>
158
159
  │ with authorization_code │
159
160
  │ │
160
- ├─ POST /token ───────────────────▶│
161
+ ├─ POST /token (code_verifier) ───▶│
161
162
  │◀── access_token + refresh_token │
162
163
  │ │
163
164
  ├─ saves to ~/.scorehub/<endpoint>/oauth.json
164
165
  ```
165
166
 
167
+ After the redirect, the local callback page gives a clear success or failure state and tells the user whether to return to the terminal or rerun `authorize`.
168
+
166
169
  ### Token lifecycle
167
170
 
168
171
  | Stage | Behavior |
@@ -175,7 +178,9 @@ These tools are **dynamically loaded** from the remote MCP endpoint at startup a
175
178
 
176
179
  ### Headless environments
177
180
 
178
- If you're running on a server or in a headless environment where a browser cannot open automatically, the authorization URL will be printed to stderr. Copy it and open it manually in any browser. After login, the callback goes to `http://127.0.0.1:19836/callback` — make sure port 19836 is accessible on the machine running mcp-server.
181
+ If you're running on a server or in a headless environment where a browser cannot open automatically, the authorization URL will be printed to stderr. Copy it and open it manually in any browser. After login, the callback goes to `http://127.0.0.1:<random-port>/callback` — make sure the local loopback port chosen by mcp-server is reachable on the same machine.
182
+
183
+ On Windows, the same flow uses `cmd.exe /c start` to open the browser. If Windows blocks the automatic launch, use the printed authorization URL manually; the callback still lands on `127.0.0.1` and writes the token to your local profile.
179
184
 
180
185
  ### Local remote development
181
186
 
package/dist/auth.d.ts CHANGED
@@ -10,20 +10,17 @@ export interface TokenStore {
10
10
  expires_at: number;
11
11
  updated_at: string;
12
12
  }
13
- export interface AuthorizationCodeResult {
14
- code: string;
15
- state: string;
16
- port: number;
17
- }
18
13
  export interface AuthorizeParams {
19
14
  authEndpoint: string;
20
15
  tokenEndpoint: string;
21
16
  clientId: string;
22
- redirectUri: string;
17
+ redirectUri?: string;
23
18
  port?: number;
24
19
  scope?: string;
25
20
  timeoutMs?: number;
26
21
  host?: string;
22
+ callbackHost?: string;
23
+ callbackPath?: string;
27
24
  }
28
25
  export interface AuthorizeResult {
29
26
  accessToken: string;
@@ -43,6 +40,5 @@ export declare function getValidAccessToken(host: string, tokenEndpoint: string,
43
40
  export declare function generateCodeVerifier(length?: number): string;
44
41
  export declare function generateCodeChallenge(verifier: string): string;
45
42
  export declare function generateState(length?: number): string;
46
- export declare function startCallbackServer(port: number, timeoutMs?: number): Promise<AuthorizationCodeResult>;
47
43
  export declare function openBrowser(url: string): void;
48
44
  export declare function authorize(params: AuthorizeParams): Promise<AuthorizeResult>;
package/dist/auth.js CHANGED
@@ -9,7 +9,6 @@ exports.getValidAccessToken = getValidAccessToken;
9
9
  exports.generateCodeVerifier = generateCodeVerifier;
10
10
  exports.generateCodeChallenge = generateCodeChallenge;
11
11
  exports.generateState = generateState;
12
- exports.startCallbackServer = startCallbackServer;
13
12
  exports.openBrowser = openBrowser;
14
13
  exports.authorize = authorize;
15
14
  const node_https_1 = require("node:https");
@@ -17,12 +16,23 @@ const node_http_1 = require("node:http");
17
16
  const node_fs_1 = require("node:fs");
18
17
  const node_path_1 = require("node:path");
19
18
  const node_os_1 = require("node:os");
20
- const node_http_2 = require("node:http");
21
19
  const node_crypto_1 = require("node:crypto");
22
20
  const node_child_process_1 = require("node:child_process");
21
+ class CallbackError extends Error {
22
+ page;
23
+ statusCode;
24
+ constructor(message, page, statusCode = 400) {
25
+ super(message);
26
+ this.name = "CallbackError";
27
+ this.page = page;
28
+ this.statusCode = statusCode;
29
+ }
30
+ }
23
31
  // ── Token Storage ────────────────────────────────────────────
24
32
  const SCOREHUB_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".scorehub");
25
33
  const TOKEN_FILE = (host) => (0, node_path_1.join)(SCOREHUB_DIR, host, "oauth.json");
34
+ const DEFAULT_CALLBACK_HOST = "127.0.0.1";
35
+ const DEFAULT_CALLBACK_PATH = "/callback";
26
36
  function ensureDir(filePath) {
27
37
  const dir = (0, node_path_1.dirname)(filePath);
28
38
  if (!(0, node_fs_1.existsSync)(dir)) {
@@ -54,6 +64,221 @@ function clearTokens(host) {
54
64
  function hasSignedTokenShape(token) {
55
65
  return token.split(".").length === 3;
56
66
  }
67
+ function normalizeCallbackPath(path) {
68
+ if (!path || path === "/")
69
+ return DEFAULT_CALLBACK_PATH;
70
+ return path.startsWith("/") ? path : `/${path}`;
71
+ }
72
+ function buildLocalRedirectUri(host, port, path) {
73
+ const url = new URL(`http://${host}:${port}`);
74
+ url.pathname = normalizeCallbackPath(path);
75
+ return url.toString();
76
+ }
77
+ function escapeHtml(value) {
78
+ return value
79
+ .replaceAll("&", "&amp;")
80
+ .replaceAll("<", "&lt;")
81
+ .replaceAll(">", "&gt;")
82
+ .replaceAll("\"", "&quot;")
83
+ .replaceAll("'", "&#39;");
84
+ }
85
+ function buildCallbackPage(page) {
86
+ const palette = page.tone === "success"
87
+ ? {
88
+ badgeBg: "#dcfce7",
89
+ badgeFg: "#166534",
90
+ accent: "#16a34a",
91
+ border: "rgba(22, 163, 74, 0.18)",
92
+ }
93
+ : {
94
+ badgeBg: "#fee2e2",
95
+ badgeFg: "#991b1b",
96
+ accent: "#dc2626",
97
+ border: "rgba(220, 38, 38, 0.16)",
98
+ };
99
+ const detail = page.detail
100
+ ? `<p class="detail">${escapeHtml(page.detail)}</p>`
101
+ : "";
102
+ const steps = page.steps
103
+ .map((step) => `<li>${escapeHtml(step)}</li>`)
104
+ .join("");
105
+ return `<!DOCTYPE html>
106
+ <html lang="zh-CN">
107
+ <head>
108
+ <meta charset="utf-8">
109
+ <meta name="viewport" content="width=device-width, initial-scale=1">
110
+ <title>${escapeHtml(page.title)} | ScoreHub MCP</title>
111
+ <style>
112
+ :root {
113
+ color-scheme: light;
114
+ font-family: "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif;
115
+ background:
116
+ radial-gradient(circle at top left, rgba(168, 85, 247, 0.18), transparent 38%),
117
+ radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.14), transparent 36%),
118
+ linear-gradient(180deg, #f5f3ff 0%, #eef4ff 100%);
119
+ }
120
+ * {
121
+ box-sizing: border-box;
122
+ }
123
+ body {
124
+ margin: 0;
125
+ min-height: 100vh;
126
+ display: grid;
127
+ place-items: center;
128
+ padding: 24px;
129
+ color: #1f2937;
130
+ }
131
+ .shell {
132
+ width: min(100%, 640px);
133
+ border-radius: 28px;
134
+ padding: 32px;
135
+ background: rgba(255, 255, 255, 0.92);
136
+ border: 1px solid rgba(148, 163, 184, 0.22);
137
+ box-shadow: 0 28px 80px rgba(76, 29, 149, 0.16);
138
+ backdrop-filter: blur(18px);
139
+ }
140
+ .brand {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ gap: 10px;
144
+ font-size: 13px;
145
+ font-weight: 700;
146
+ letter-spacing: 0.08em;
147
+ text-transform: uppercase;
148
+ color: #6d28d9;
149
+ }
150
+ .brand-mark {
151
+ width: 12px;
152
+ height: 12px;
153
+ border-radius: 999px;
154
+ background: linear-gradient(135deg, #7c3aed, #2563eb);
155
+ box-shadow: 0 0 0 6px rgba(124, 58, 237, 0.12);
156
+ }
157
+ .panel {
158
+ margin-top: 28px;
159
+ border-radius: 24px;
160
+ padding: 28px;
161
+ border: 1px solid ${palette.border};
162
+ background:
163
+ linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.92) 100%);
164
+ }
165
+ .eyebrow {
166
+ display: inline-flex;
167
+ align-items: center;
168
+ padding: 7px 12px;
169
+ border-radius: 999px;
170
+ font-size: 12px;
171
+ font-weight: 700;
172
+ letter-spacing: 0.04em;
173
+ color: ${palette.badgeFg};
174
+ background: ${palette.badgeBg};
175
+ }
176
+ h1 {
177
+ margin: 18px 0 10px;
178
+ font-size: clamp(30px, 4vw, 38px);
179
+ line-height: 1.08;
180
+ color: #0f172a;
181
+ }
182
+ .message {
183
+ margin: 0;
184
+ font-size: 16px;
185
+ line-height: 1.7;
186
+ color: #334155;
187
+ }
188
+ .detail {
189
+ margin: 16px 0 0;
190
+ padding: 12px 14px;
191
+ border-radius: 16px;
192
+ font-size: 14px;
193
+ line-height: 1.6;
194
+ color: #475569;
195
+ background: rgba(248, 250, 252, 0.96);
196
+ border: 1px solid rgba(148, 163, 184, 0.16);
197
+ }
198
+ .next {
199
+ margin-top: 24px;
200
+ padding: 20px;
201
+ border-radius: 20px;
202
+ background: rgba(248, 250, 252, 0.94);
203
+ }
204
+ .next h2 {
205
+ margin: 0 0 12px;
206
+ font-size: 15px;
207
+ color: ${palette.accent};
208
+ }
209
+ ol {
210
+ margin: 0;
211
+ padding-left: 20px;
212
+ color: #334155;
213
+ }
214
+ li + li {
215
+ margin-top: 10px;
216
+ }
217
+ .footer {
218
+ margin-top: 22px;
219
+ font-size: 13px;
220
+ color: #64748b;
221
+ }
222
+ @media (max-width: 640px) {
223
+ .shell {
224
+ padding: 22px;
225
+ border-radius: 24px;
226
+ }
227
+ .panel {
228
+ padding: 22px;
229
+ }
230
+ }
231
+ </style>
232
+ </head>
233
+ <body>
234
+ <main class="shell">
235
+ <div class="brand">
236
+ <span class="brand-mark"></span>
237
+ <span>ScoreHub MCP</span>
238
+ </div>
239
+ <section class="panel">
240
+ <div class="eyebrow">${escapeHtml(page.eyebrow)}</div>
241
+ <h1>${escapeHtml(page.title)}</h1>
242
+ <p class="message">${escapeHtml(page.message)}</p>
243
+ ${detail}
244
+ <div class="next">
245
+ <h2>下一步</h2>
246
+ <ol>${steps}</ol>
247
+ </div>
248
+ </section>
249
+ <p class="footer">这是本机回调页。完成后可以直接关闭当前页面。</p>
250
+ </main>
251
+ </body>
252
+ </html>`;
253
+ }
254
+ function getTokenErrorMessage(payload) {
255
+ if (payload && typeof payload === "object") {
256
+ const record = payload;
257
+ if (typeof record.error_description === "string" && record.error_description) {
258
+ return record.error_description;
259
+ }
260
+ if (typeof record.error === "string" && record.error) {
261
+ return record.error;
262
+ }
263
+ }
264
+ return JSON.stringify(payload);
265
+ }
266
+ function parseOAuthTokens(payload) {
267
+ if (payload && typeof payload === "object") {
268
+ const record = payload;
269
+ if (typeof record.access_token === "string"
270
+ && typeof record.refresh_token === "string"
271
+ && typeof record.expires_in === "number") {
272
+ return {
273
+ access_token: record.access_token,
274
+ refresh_token: record.refresh_token,
275
+ expires_in: record.expires_in,
276
+ ...(typeof record.scope === "string" ? { scope: record.scope } : {}),
277
+ };
278
+ }
279
+ }
280
+ throw new Error(getTokenErrorMessage(payload));
281
+ }
57
282
  // ── Token Exchange ───────────────────────────────────────────
58
283
  function tokenPostRequest(tokenEndpoint, body) {
59
284
  const url = new URL(tokenEndpoint);
@@ -91,7 +316,7 @@ async function exchangeCodeForTokens(tokenEndpoint, code, codeVerifier, clientId
91
316
  client_id: clientId,
92
317
  redirect_uri: redirectUri,
93
318
  }).toString();
94
- return tokenPostRequest(tokenEndpoint, body);
319
+ return parseOAuthTokens(await tokenPostRequest(tokenEndpoint, body));
95
320
  }
96
321
  async function refreshAccessToken(tokenEndpoint, refreshToken, clientId) {
97
322
  const body = new URLSearchParams({
@@ -99,7 +324,7 @@ async function refreshAccessToken(tokenEndpoint, refreshToken, clientId) {
99
324
  refresh_token: refreshToken,
100
325
  client_id: clientId,
101
326
  }).toString();
102
- return tokenPostRequest(tokenEndpoint, body);
327
+ return parseOAuthTokens(await tokenPostRequest(tokenEndpoint, body));
103
328
  }
104
329
  async function getValidAccessToken(host, tokenEndpoint, clientId) {
105
330
  const cached = loadTokens(host);
@@ -135,125 +360,193 @@ function generateCodeChallenge(verifier) {
135
360
  function generateState(length = 16) {
136
361
  return (0, node_crypto_1.randomBytes)(length).toString("hex");
137
362
  }
138
- // ── Local Callback Server ────────────────────────────────────
139
- function startCallbackServer(port, timeoutMs = 120_000) {
140
- return new Promise((resolve, reject) => {
141
- let timeoutId;
142
- const server = (0, node_http_2.createServer)((req, res) => {
143
- const actualPort = server.address()?.port || port;
144
- const requestUrl = req.url ?? "/";
145
- const url = new URL(requestUrl, `http://127.0.0.1:${actualPort}`);
146
- const code = url.searchParams.get("code");
147
- const state = url.searchParams.get("state");
148
- if (code) {
149
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
150
- res.end("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Authorization Complete</title></head>" +
151
- "<body style=\"font-family:sans-serif;text-align:center;padding-top:80px;\">" +
152
- "<h2>✅ Authorization Successful</h2>" +
153
- "<p>You may close this window and return to the terminal.</p>" +
154
- "</body></html>");
155
- }
156
- else {
157
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
158
- res.end("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Authorization Failed</title></head>" +
159
- "<body style=\"font-family:sans-serif;text-align:center;padding-top:80px;\">" +
160
- "<h2>❌ Authorization Failed</h2>" +
161
- "<p>No authorization code received from the provider.</p>" +
162
- "</body></html>");
163
- }
164
- if (timeoutId)
165
- clearTimeout(timeoutId);
166
- server.close();
167
- if (code) {
168
- resolve({ code, state: state ?? "", port: actualPort });
169
- }
170
- else {
171
- reject(new Error("No authorization code in callback"));
172
- }
173
- });
174
- server.on("error", (err) => {
175
- if (timeoutId)
176
- clearTimeout(timeoutId);
177
- reject(err);
178
- });
179
- server.listen(port, "127.0.0.1", () => {
180
- server.unref();
181
- timeoutId = setTimeout(() => {
182
- server.close();
183
- reject(new Error(`Authorization timed out after ${timeoutMs / 1000}s. Please try again.`));
184
- }, timeoutMs);
185
- });
186
- });
187
- }
188
363
  // ── Browser Launch ───────────────────────────────────────────
189
364
  function openBrowser(url) {
190
365
  try {
191
366
  const platform = process.platform;
192
367
  if (platform === "darwin") {
193
- (0, node_child_process_1.execSync)(`open "${url}"`, { stdio: "ignore", timeout: 3000 });
368
+ (0, node_child_process_1.execFileSync)("open", [url], { stdio: "ignore", timeout: 3000 });
194
369
  }
195
370
  else if (platform === "win32") {
196
- (0, node_child_process_1.execSync)(`start "" "${url}"`, { shell: "cmd.exe", stdio: "ignore", timeout: 3000 });
371
+ (0, node_child_process_1.execFileSync)("cmd.exe", ["/c", "start", "", url], { stdio: "ignore", timeout: 3000 });
197
372
  }
198
373
  else {
199
- (0, node_child_process_1.execSync)(`xdg-open "${url}"`, { stdio: "ignore", timeout: 3000 });
374
+ (0, node_child_process_1.execFileSync)("xdg-open", [url], { stdio: "ignore", timeout: 3000 });
200
375
  }
201
376
  }
202
377
  catch {
203
- console.error(`[scorehub] Could not open browser automatically.`);
378
+ console.error("[scorehub] Could not open browser automatically.");
204
379
  console.error(`[scorehub] Please visit this URL manually:\n ${url}`);
205
380
  }
206
381
  }
207
382
  // ── Full OAuth Flow ──────────────────────────────────────────
208
383
  async function authorize(params) {
209
- const { authEndpoint, tokenEndpoint, clientId, redirectUri, port = 19836, scope, timeoutMs = 120_000, host, } = params;
384
+ const { authEndpoint, tokenEndpoint, clientId, redirectUri, scope, timeoutMs = 120_000, host, } = params;
385
+ const redirectUrl = redirectUri ? new URL(redirectUri) : null;
386
+ const callbackHost = params.callbackHost ?? redirectUrl?.hostname ?? DEFAULT_CALLBACK_HOST;
387
+ const callbackPath = normalizeCallbackPath(params.callbackPath ?? redirectUrl?.pathname ?? DEFAULT_CALLBACK_PATH);
388
+ const listenPort = params.port ?? (redirectUrl?.port ? Number(redirectUrl.port) : 0);
210
389
  const storageHost = host ?? new URL(authEndpoint).host;
211
390
  const codeVerifier = generateCodeVerifier();
212
391
  const codeChallenge = generateCodeChallenge(codeVerifier);
213
392
  const state = generateState();
214
- const authUrl = new URL(authEndpoint);
215
- authUrl.searchParams.set("client_id", clientId);
216
- authUrl.searchParams.set("redirect_uri", redirectUri);
217
- authUrl.searchParams.set("code_challenge", codeChallenge);
218
- authUrl.searchParams.set("code_challenge_method", "S256");
219
- authUrl.searchParams.set("state", state);
220
- authUrl.searchParams.set("response_type", "code");
221
- if (scope)
222
- authUrl.searchParams.set("scope", scope);
223
- openBrowser(authUrl.toString());
224
- let returnedCode;
225
- let returnedState;
226
- try {
227
- const result = await startCallbackServer(port, timeoutMs);
228
- returnedCode = result.code;
229
- returnedState = result.state;
230
- }
231
- catch (err) {
232
- clearTokens(storageHost);
233
- throw err;
234
- }
235
- if (returnedState !== state) {
236
- clearTokens(storageHost);
237
- throw new Error("OAuth state mismatch — possible CSRF attack. Please restart the authorization flow.");
238
- }
239
- let tokens;
240
- try {
241
- tokens = await exchangeCodeForTokens(tokenEndpoint, returnedCode, codeVerifier, clientId, redirectUri);
242
- }
243
- catch (err) {
244
- clearTokens(storageHost);
245
- throw new Error(`Token exchange failed: ${err instanceof Error ? err.message : String(err)}`);
246
- }
247
- saveTokens(storageHost, {
248
- access_token: tokens.access_token,
249
- refresh_token: tokens.refresh_token,
250
- expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000,
251
- updated_at: new Date().toISOString(),
393
+ return new Promise((resolve, reject) => {
394
+ let timeoutId;
395
+ let settled = false;
396
+ const stopServer = (server) => {
397
+ if (timeoutId) {
398
+ clearTimeout(timeoutId);
399
+ timeoutId = undefined;
400
+ }
401
+ server.close();
402
+ };
403
+ const server = (0, node_http_1.createServer)((req, res) => {
404
+ const actualPort = server.address()?.port ?? listenPort;
405
+ const requestUrl = req.url ?? "/";
406
+ const url = new URL(requestUrl, `http://${callbackHost}:${actualPort}`);
407
+ if (url.pathname !== callbackPath) {
408
+ res.writeHead(204);
409
+ res.end();
410
+ return;
411
+ }
412
+ if (settled) {
413
+ res.writeHead(409, { "Content-Type": "text/html; charset=utf-8" });
414
+ res.end(buildCallbackPage({
415
+ tone: "error",
416
+ eyebrow: "Request Closed",
417
+ title: "授权请求已结束",
418
+ message: "当前授权请求已经结束,请返回终端重新执行 authorize。",
419
+ steps: [
420
+ "回到 MCP 客户端终端查看最近一次授权结果。",
421
+ "如果仍需登录,请重新执行 authorize。",
422
+ ],
423
+ }));
424
+ return;
425
+ }
426
+ void (async () => {
427
+ try {
428
+ const oauthError = url.searchParams.get("error");
429
+ const oauthErrorDescription = url.searchParams.get("error_description") ?? undefined;
430
+ if (oauthError) {
431
+ throw new CallbackError(oauthError === "access_denied" ? "User denied authorization" : `OAuth callback returned error: ${oauthError}`, {
432
+ tone: "error",
433
+ eyebrow: "Authorization Canceled",
434
+ title: oauthError === "access_denied" ? "你已取消授权" : "授权未完成",
435
+ message: "ScoreHub 没有返回可用的授权结果,本次登录不会写入本地 token。",
436
+ detail: oauthErrorDescription ?? oauthError,
437
+ steps: [
438
+ "返回终端查看本次授权失败提示。",
439
+ "如仍需使用 ScoreHub,请重新执行 authorize。",
440
+ ],
441
+ });
442
+ }
443
+ const code = url.searchParams.get("code");
444
+ if (!code) {
445
+ throw new CallbackError("No authorization code in callback", {
446
+ tone: "error",
447
+ eyebrow: "Missing Code",
448
+ title: "没有收到授权码",
449
+ message: "本地回调页已打开,但远端没有带回可交换 token 的授权码。",
450
+ steps: [
451
+ "返回终端查看详细错误信息。",
452
+ "重新执行 authorize,并确认浏览器完成了全部授权步骤。",
453
+ ],
454
+ });
455
+ }
456
+ const returnedState = url.searchParams.get("state") ?? "";
457
+ if (returnedState !== state) {
458
+ throw new CallbackError("OAuth state mismatch — possible CSRF attack. Please restart the authorization flow.", {
459
+ tone: "error",
460
+ eyebrow: "State Mismatch",
461
+ title: "本地状态校验失败",
462
+ message: "回调中的 state 与本地发起的请求不一致,本次授权已被拒绝写入。",
463
+ steps: [
464
+ "关闭当前页面并返回终端。",
465
+ "重新执行 authorize 发起一次新的授权流程。",
466
+ ],
467
+ });
468
+ }
469
+ const actualRedirectUri = redirectUri ?? buildLocalRedirectUri(callbackHost, actualPort, callbackPath);
470
+ const tokens = await exchangeCodeForTokens(tokenEndpoint, code, codeVerifier, clientId, actualRedirectUri);
471
+ saveTokens(storageHost, {
472
+ access_token: tokens.access_token,
473
+ refresh_token: tokens.refresh_token,
474
+ expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000,
475
+ updated_at: new Date().toISOString(),
476
+ });
477
+ settled = true;
478
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
479
+ res.end(buildCallbackPage({
480
+ tone: "success",
481
+ eyebrow: "Authorization Complete",
482
+ title: "ScoreHub 已完成授权",
483
+ message: "本地 token 已写入当前终端配置,你现在可以返回 MCP 客户端继续使用工具。",
484
+ steps: [
485
+ "回到终端继续执行刚才的操作。",
486
+ "如果当前客户端没有自动继续,可直接重新调用目标 ScoreHub 工具。",
487
+ ],
488
+ }));
489
+ stopServer(server);
490
+ resolve({
491
+ accessToken: tokens.access_token,
492
+ refreshToken: tokens.refresh_token,
493
+ expiresIn: tokens.expires_in,
494
+ scope: tokens.scope,
495
+ });
496
+ }
497
+ catch (error) {
498
+ settled = true;
499
+ clearTokens(storageHost);
500
+ const err = error instanceof CallbackError
501
+ ? error
502
+ : new CallbackError(error instanceof Error ? error.message : String(error), {
503
+ tone: "error",
504
+ eyebrow: "Token Exchange Failed",
505
+ title: "授权结果未能保存",
506
+ message: "浏览器已经完成跳转,但本地未能成功换取或保存 ScoreHub token。",
507
+ detail: error instanceof Error ? error.message : String(error),
508
+ steps: [
509
+ "返回终端查看失败原因。",
510
+ "确认网络和远端状态后,重新执行 authorize。",
511
+ ],
512
+ });
513
+ res.writeHead(err.statusCode, { "Content-Type": "text/html; charset=utf-8" });
514
+ res.end(buildCallbackPage(err.page));
515
+ stopServer(server);
516
+ reject(err);
517
+ }
518
+ })();
519
+ });
520
+ server.on("error", (err) => {
521
+ if (settled)
522
+ return;
523
+ settled = true;
524
+ clearTokens(storageHost);
525
+ stopServer(server);
526
+ reject(err);
527
+ });
528
+ server.listen(listenPort, callbackHost, () => {
529
+ server.unref();
530
+ const actualPort = server.address().port;
531
+ const actualRedirectUri = redirectUri ?? buildLocalRedirectUri(callbackHost, actualPort, callbackPath);
532
+ const authUrl = new URL(authEndpoint);
533
+ authUrl.searchParams.set("client_id", clientId);
534
+ authUrl.searchParams.set("redirect_uri", actualRedirectUri);
535
+ authUrl.searchParams.set("code_challenge", codeChallenge);
536
+ authUrl.searchParams.set("code_challenge_method", "S256");
537
+ authUrl.searchParams.set("state", state);
538
+ authUrl.searchParams.set("response_type", "code");
539
+ if (scope)
540
+ authUrl.searchParams.set("scope", scope);
541
+ openBrowser(authUrl.toString());
542
+ timeoutId = setTimeout(() => {
543
+ if (settled)
544
+ return;
545
+ settled = true;
546
+ clearTokens(storageHost);
547
+ stopServer(server);
548
+ reject(new Error(`Authorization timed out after ${timeoutMs / 1000}s. Please try again.`));
549
+ }, timeoutMs);
550
+ });
252
551
  });
253
- return {
254
- accessToken: tokens.access_token,
255
- refreshToken: tokens.refresh_token,
256
- expiresIn: tokens.expires_in,
257
- scope: tokens.scope,
258
- };
259
552
  }
package/dist/index.js CHANGED
@@ -37,8 +37,6 @@ function endpointTokenKey(baseUrl) {
37
37
  }
38
38
  // ── Auth State ──────────────────────────────────────────────
39
39
  const auth_js_1 = require("./auth.js");
40
- const node_http_1 = require("node:http");
41
- const node_child_process_1 = require("node:child_process");
42
40
  const TOKEN_STORE_KEY = endpointTokenKey(SCOREHUB_BASE_URL);
43
41
  const CLIENT_ID = "scorehub-mcp";
44
42
  // Proactively refresh token every 4 hours to prevent expiry during long sessions
@@ -135,12 +133,6 @@ async function callRemoteTool(name, args, token) {
135
133
  await client.close();
136
134
  }
137
135
  }
138
- // ── OAuth authorize helper ──────────────────────────────────
139
- function generateAuthUrl(port = 19836) {
140
- // Generate random state for CSRF protection
141
- const state = Math.random().toString(36).substring(2);
142
- return `${AUTHORIZE_URL}?client_id=scorehub-mcp&redirect_uri=http://127.0.0.1:${port}/callback&state=${state}`;
143
- }
144
136
  // ── Fetch remote tools on startup ───────────────────────────
145
137
  let remoteTools = [];
146
138
  let lastFetchError = null;
@@ -208,97 +200,23 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
208
200
  else {
209
201
  (0, auth_js_1.clearTokens)(TOKEN_STORE_KEY);
210
202
  }
211
- // Port 0 lets the OS assign a random available port,
212
- // avoiding conflicts when multiple MCP clients run simultaneously
213
- let actualPort = 0;
214
- return new Promise((resolve) => {
215
- let timeoutId;
216
- const callbackServer = (0, node_http_1.createServer)((req, res) => {
217
- const requestUrl = req.url ?? "/";
218
- const url = new URL(requestUrl, `http://127.0.0.1:${actualPort}`);
219
- // 只处理 /callback 路径的请求,忽略浏览器探测请求(favicon、安全检查等)
220
- if (url.pathname !== "/callback") {
221
- res.writeHead(204);
222
- res.end();
223
- return;
224
- }
225
- if (timeoutId)
226
- clearTimeout(timeoutId);
227
- const code = url.searchParams.get("code");
228
- if (code) {
229
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
230
- res.end("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Authorization Complete</title></head>" +
231
- "<body style=\"font-family:sans-serif;text-align:center;padding-top:80px;\">" +
232
- "<h2>✅ Authorization Successful</h2>" +
233
- "<p>You may close this window and return to the terminal.</p>" +
234
- "</body></html>");
235
- callbackServer.close();
236
- fetch(TOKEN_ENDPOINT, {
237
- method: "POST",
238
- headers: { "Content-Type": "application/json" },
239
- body: JSON.stringify({ grant_type: "authorization_code", code }),
240
- })
241
- .then((r) => r.json())
242
- .then((tokenData) => {
243
- if (!tokenData.access_token) {
244
- resolve({
245
- content: [{ type: "text", text: `❌ Token 获取失败: ${JSON.stringify(tokenData)}` }],
246
- });
247
- return;
248
- }
249
- (0, auth_js_1.saveTokens)(TOKEN_STORE_KEY, {
250
- access_token: tokenData.access_token,
251
- refresh_token: tokenData.refresh_token ?? "",
252
- expires_at: Date.now() + (tokenData.expires_in ?? 3600) * 1000,
253
- updated_at: new Date().toISOString(),
254
- });
255
- resolve({
256
- content: [{ type: "text", text: "✅ 授权成功!可以开始使用工具了。" }],
257
- });
258
- })
259
- .catch((e) => resolve({
260
- content: [{ type: "text", text: `❌ Token 获取失败: ${String(e)}` }],
261
- }));
262
- }
263
- else {
264
- // 到达 /callback 但没有 code —— 真正的错误
265
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
266
- res.end("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Authorization Failed</title></head>" +
267
- "<body style=\"font-family:sans-serif;text-align:center;padding-top:80px;\">" +
268
- "<h2>❌ Authorization Failed</h2>" +
269
- "<p>No authorization code received from the provider.</p>" +
270
- "</body></html>");
271
- callbackServer.close();
272
- resolve({
273
- content: [{ type: "text", text: "❌ 授权失败: No authorization code in callback" }],
274
- });
275
- }
276
- });
277
- callbackServer.on("error", (err) => {
278
- if (timeoutId)
279
- clearTimeout(timeoutId);
280
- resolve({
281
- content: [{ type: "text", text: `❌ 授权失败: ${String(err)}` }],
282
- });
283
- });
284
- callbackServer.listen(0, "127.0.0.1", () => {
285
- actualPort = callbackServer.address().port;
286
- callbackServer.unref();
287
- const authUrl = generateAuthUrl(actualPort);
288
- const os = process.platform;
289
- const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
290
- try {
291
- (0, node_child_process_1.execSync)(`${cmd} "${authUrl}"`);
292
- }
293
- catch { /* headless env: user opens manually */ }
294
- timeoutId = setTimeout(() => {
295
- callbackServer.close();
296
- resolve({
297
- content: [{ type: "text", text: "❌ 授权超时 (120s)。请重试。" }],
298
- });
299
- }, 120_000);
203
+ try {
204
+ await (0, auth_js_1.authorize)({
205
+ authEndpoint: AUTHORIZE_URL,
206
+ tokenEndpoint: TOKEN_ENDPOINT,
207
+ clientId: CLIENT_ID,
208
+ host: TOKEN_STORE_KEY,
209
+ port: 0,
300
210
  });
301
- });
211
+ return {
212
+ content: [{ type: "text", text: "✅ 授权成功!浏览器中的本地回调页已确认,可以直接继续使用 ScoreHub 工具。" }],
213
+ };
214
+ }
215
+ catch (error) {
216
+ return {
217
+ content: [{ type: "text", text: `❌ 授权失败: ${error instanceof Error ? error.message : String(error)}` }],
218
+ };
219
+ }
302
220
  }
303
221
  case "status": {
304
222
  const tokenResult = await (0, auth_js_1.getValidAccessToken)(TOKEN_STORE_KEY, TOKEN_ENDPOINT, CLIENT_ID);
@@ -321,12 +239,11 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
321
239
  // Check authorization for remote tools — re-validates on every call
322
240
  const tokenResult = await (0, auth_js_1.getValidAccessToken)(TOKEN_STORE_KEY, TOKEN_ENDPOINT, CLIENT_ID);
323
241
  if (!tokenResult) {
324
- const authUrl = generateAuthUrl();
325
242
  return {
326
243
  content: [
327
244
  {
328
245
  type: "text",
329
- text: `⚠️ 未授权。请先调用 "authorize" 工具登录 ScoreHub。\n授权地址: ${authUrl}`,
246
+ text: "⚠️ 未授权。请先调用 \"authorize\" 工具登录 ScoreHub。若浏览器未自动打开,请复制终端输出的授权链接到浏览器完成登录。",
330
247
  },
331
248
  ],
332
249
  };
@@ -335,24 +252,22 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
335
252
  const remoteResult = await callRemoteTool(name, args, tokenResult.access_token);
336
253
  if (remoteResult.envelope?.ok === false && remoteResult.envelope.error_type === "oauth_invalid") {
337
254
  (0, auth_js_1.clearTokens)(TOKEN_STORE_KEY);
338
- const authUrl = generateAuthUrl();
339
255
  return {
340
256
  content: [
341
257
  {
342
258
  type: "text",
343
- text: `⚠️ 授权已失效,已清除本地缓存。请调用 "authorize" 工具重新登录 ScoreHub。\n授权地址: ${authUrl}`,
259
+ text: "⚠️ 授权已失效,已清除本地缓存。请调用 \"authorize\" 工具重新登录 ScoreHub。若浏览器未自动打开,请复制终端输出的授权链接到浏览器完成登录。",
344
260
  },
345
261
  ],
346
262
  };
347
263
  }
348
264
  if (remoteResult.rawText.includes("ScoreHub OAuth 授权已过期或无效")) {
349
265
  (0, auth_js_1.clearTokens)(TOKEN_STORE_KEY);
350
- const authUrl = generateAuthUrl();
351
266
  return {
352
267
  content: [
353
268
  {
354
269
  type: "text",
355
- text: `⚠️ 授权已失效,已清除本地缓存。请调用 "authorize" 工具重新登录 ScoreHub。\n授权地址: ${authUrl}`,
270
+ text: "⚠️ 授权已失效,已清除本地缓存。请调用 \"authorize\" 工具重新登录 ScoreHub。若浏览器未自动打开,请复制终端输出的授权链接到浏览器完成登录。",
356
271
  },
357
272
  ],
358
273
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scorehub/mcp-server",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "ScoreHub local MCP proxy — OAuth login, token caching, and tool forwarding to remote MCP endpoint",
5
5
  "main": "dist/index.js",
6
6
  "bin": {