@scorehub/mcp-server 0.2.9 → 0.2.10

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
@@ -164,7 +164,7 @@ These tools are **dynamically loaded** from the remote MCP endpoint at startup a
164
164
  ├─ saves to ~/.scorehub/<endpoint>/oauth.json
165
165
  ```
166
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`.
167
+ After the redirect, the local callback page uses the same visual language as the ScoreHub OAuth login and consent pages, with a branded summary, local callback metadata, and state-specific guidance on whether to return to the terminal or rerun `authorize`.
168
168
 
169
169
  ### Token lifecycle
170
170
 
package/dist/auth.js CHANGED
@@ -18,6 +18,7 @@ const node_path_1 = require("node:path");
18
18
  const node_os_1 = require("node:os");
19
19
  const node_crypto_1 = require("node:crypto");
20
20
  const node_child_process_1 = require("node:child_process");
21
+ const callback_pages_js_1 = require("./pages/callback-pages.js");
21
22
  class CallbackError extends Error {
22
23
  page;
23
24
  statusCode;
@@ -74,183 +75,6 @@ function buildLocalRedirectUri(host, port, path) {
74
75
  url.pathname = normalizeCallbackPath(path);
75
76
  return url.toString();
76
77
  }
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
78
  function getTokenErrorMessage(payload) {
255
79
  if (payload && typeof payload === "object") {
256
80
  const record = payload;
@@ -279,6 +103,24 @@ function parseOAuthTokens(payload) {
279
103
  }
280
104
  throw new Error(getTokenErrorMessage(payload));
281
105
  }
106
+ function createCallbackPageModel(input) {
107
+ return {
108
+ tone: input.tone,
109
+ eyebrow: input.eyebrow,
110
+ title: input.title,
111
+ message: input.message,
112
+ ...(input.detail ? { detail: input.detail } : {}),
113
+ summaryLabel: input.summaryLabel ?? "结果说明",
114
+ summaryText: input.summaryText,
115
+ metaLabel: "本地回调地址",
116
+ metaValue: input.callbackUrl,
117
+ resultLabel: input.resultLabel ?? "当前状态",
118
+ resultValue: input.resultValue,
119
+ steps: input.steps,
120
+ closeLabel: input.closeLabel ?? "关闭当前页面",
121
+ helperText: input.helperText ?? "如果浏览器无法自动关闭,请手动切回终端继续。",
122
+ };
123
+ }
282
124
  // ── Token Exchange ───────────────────────────────────────────
283
125
  function tokenPostRequest(tokenEndpoint, body) {
284
126
  const url = new URL(tokenEndpoint);
@@ -404,6 +246,7 @@ async function authorize(params) {
404
246
  const actualPort = server.address()?.port ?? listenPort;
405
247
  const requestUrl = req.url ?? "/";
406
248
  const url = new URL(requestUrl, `http://${callbackHost}:${actualPort}`);
249
+ const callbackUrl = buildLocalRedirectUri(callbackHost, actualPort, callbackPath);
407
250
  if (url.pathname !== callbackPath) {
408
251
  res.writeHead(204);
409
252
  res.end();
@@ -411,16 +254,20 @@ async function authorize(params) {
411
254
  }
412
255
  if (settled) {
413
256
  res.writeHead(409, { "Content-Type": "text/html; charset=utf-8" });
414
- res.end(buildCallbackPage({
257
+ res.end((0, callback_pages_js_1.buildCallbackPage)(createCallbackPageModel({
415
258
  tone: "error",
416
259
  eyebrow: "Request Closed",
417
260
  title: "授权请求已结束",
418
- message: "当前授权请求已经结束,请返回终端重新执行 authorize。",
261
+ message: "当前回调监听已经完成,不能再用这个页面继续授权流程。",
262
+ summaryText: "这通常表示你刷新了已经处理过的 callback 页面,或者在授权完成后再次访问了同一地址。",
263
+ resultValue: "本次请求已关闭,请回终端查看最终结果",
419
264
  steps: [
420
- "回到 MCP 客户端终端查看最近一次授权结果。",
421
- "如果仍需登录,请重新执行 authorize。",
265
+ "回到 MCP 客户端终端查看最近一次 authorize 的执行结果。",
266
+ "如果仍需重新登录,请在终端重新执行 authorize,而不是刷新当前页面。",
422
267
  ],
423
- }));
268
+ callbackUrl,
269
+ closeLabel: "关闭当前页面",
270
+ })));
424
271
  return;
425
272
  }
426
273
  void (async () => {
@@ -428,45 +275,59 @@ async function authorize(params) {
428
275
  const oauthError = url.searchParams.get("error");
429
276
  const oauthErrorDescription = url.searchParams.get("error_description") ?? undefined;
430
277
  if (oauthError) {
431
- throw new CallbackError(oauthError === "access_denied" ? "User denied authorization" : `OAuth callback returned error: ${oauthError}`, {
278
+ throw new CallbackError(oauthError === "access_denied" ? "User denied authorization" : `OAuth callback returned error: ${oauthError}`, createCallbackPageModel({
432
279
  tone: "error",
433
280
  eyebrow: "Authorization Canceled",
434
281
  title: oauthError === "access_denied" ? "你已取消授权" : "授权未完成",
435
282
  message: "ScoreHub 没有返回可用的授权结果,本次登录不会写入本地 token。",
436
283
  detail: oauthErrorDescription ?? oauthError,
284
+ summaryText: oauthError === "access_denied"
285
+ ? "你在 OAuth 授权页拒绝了本次请求,ScoreHub MCP 不会继续交换 token。"
286
+ : "远端授权流程返回了错误状态,本地不会保存任何新的授权信息。",
287
+ resultValue: "未写入本地 token,可重新发起授权",
437
288
  steps: [
438
289
  "返回终端查看本次授权失败提示。",
439
290
  "如仍需使用 ScoreHub,请重新执行 authorize。",
440
291
  ],
441
- });
292
+ callbackUrl,
293
+ closeLabel: "关闭后回终端",
294
+ }));
442
295
  }
443
296
  const code = url.searchParams.get("code");
444
297
  if (!code) {
445
- throw new CallbackError("No authorization code in callback", {
298
+ throw new CallbackError("No authorization code in callback", createCallbackPageModel({
446
299
  tone: "error",
447
300
  eyebrow: "Missing Code",
448
301
  title: "没有收到授权码",
449
302
  message: "本地回调页已打开,但远端没有带回可交换 token 的授权码。",
303
+ summaryText: "浏览器已经回到本地 callback,但缺少完成 token 交换必需的 authorization code。",
304
+ resultValue: "授权结果不完整,本地未保存任何 token",
450
305
  steps: [
451
306
  "返回终端查看详细错误信息。",
452
307
  "重新执行 authorize,并确认浏览器完成了全部授权步骤。",
453
308
  ],
454
- });
309
+ callbackUrl,
310
+ closeLabel: "关闭后回终端",
311
+ }));
455
312
  }
456
313
  const returnedState = url.searchParams.get("state") ?? "";
457
314
  if (returnedState !== state) {
458
- throw new CallbackError("OAuth state mismatch — possible CSRF attack. Please restart the authorization flow.", {
315
+ throw new CallbackError("OAuth state mismatch — possible CSRF attack. Please restart the authorization flow.", createCallbackPageModel({
459
316
  tone: "error",
460
317
  eyebrow: "State Mismatch",
461
318
  title: "本地状态校验失败",
462
319
  message: "回调中的 state 与本地发起的请求不一致,本次授权已被拒绝写入。",
320
+ summaryText: "为了避免把错误来源的授权结果写入本机,ScoreHub MCP 已主动丢弃这次回调。",
321
+ resultValue: "安全校验未通过,需要重新发起授权",
463
322
  steps: [
464
323
  "关闭当前页面并返回终端。",
465
324
  "重新执行 authorize 发起一次新的授权流程。",
466
325
  ],
467
- });
326
+ callbackUrl,
327
+ closeLabel: "关闭后回终端",
328
+ }));
468
329
  }
469
- const actualRedirectUri = redirectUri ?? buildLocalRedirectUri(callbackHost, actualPort, callbackPath);
330
+ const actualRedirectUri = redirectUri ?? callbackUrl;
470
331
  const tokens = await exchangeCodeForTokens(tokenEndpoint, code, codeVerifier, clientId, actualRedirectUri);
471
332
  saveTokens(storageHost, {
472
333
  access_token: tokens.access_token,
@@ -476,16 +337,20 @@ async function authorize(params) {
476
337
  });
477
338
  settled = true;
478
339
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
479
- res.end(buildCallbackPage({
340
+ res.end((0, callback_pages_js_1.buildCallbackPage)(createCallbackPageModel({
480
341
  tone: "success",
481
342
  eyebrow: "Authorization Complete",
482
343
  title: "ScoreHub 已完成授权",
483
344
  message: "本地 token 已写入当前终端配置,你现在可以返回 MCP 客户端继续使用工具。",
345
+ summaryText: "ScoreHub MCP 已收到授权码并完成 token 交换,当前终端会直接复用这次新的本地授权。",
346
+ resultValue: "授权成功,本地 token 已更新",
484
347
  steps: [
485
- "回到终端继续执行刚才的操作。",
348
+ "切回终端继续执行刚才的操作。",
486
349
  "如果当前客户端没有自动继续,可直接重新调用目标 ScoreHub 工具。",
487
350
  ],
488
- }));
351
+ callbackUrl,
352
+ closeLabel: "关闭当前页面",
353
+ })));
489
354
  stopServer(server);
490
355
  resolve({
491
356
  accessToken: tokens.access_token,
@@ -499,19 +364,23 @@ async function authorize(params) {
499
364
  clearTokens(storageHost);
500
365
  const err = error instanceof CallbackError
501
366
  ? error
502
- : new CallbackError(error instanceof Error ? error.message : String(error), {
367
+ : new CallbackError(error instanceof Error ? error.message : String(error), createCallbackPageModel({
503
368
  tone: "error",
504
369
  eyebrow: "Token Exchange Failed",
505
370
  title: "授权结果未能保存",
506
371
  message: "浏览器已经完成跳转,但本地未能成功换取或保存 ScoreHub token。",
507
372
  detail: error instanceof Error ? error.message : String(error),
373
+ summaryText: "问题发生在本地 token 交换或保存阶段,而不是登录页或授权确认页本身。",
374
+ resultValue: "未保存新的 token,请回终端重试",
508
375
  steps: [
509
376
  "返回终端查看失败原因。",
510
377
  "确认网络和远端状态后,重新执行 authorize。",
511
378
  ],
512
- });
379
+ callbackUrl,
380
+ closeLabel: "关闭后回终端",
381
+ }));
513
382
  res.writeHead(err.statusCode, { "Content-Type": "text/html; charset=utf-8" });
514
- res.end(buildCallbackPage(err.page));
383
+ res.end((0, callback_pages_js_1.buildCallbackPage)(err.page));
515
384
  stopServer(server);
516
385
  reject(err);
517
386
  }
@@ -0,0 +1,17 @@
1
+ export interface CallbackPageModel {
2
+ tone: "success" | "error";
3
+ eyebrow: string;
4
+ title: string;
5
+ message: string;
6
+ detail?: string;
7
+ summaryLabel: string;
8
+ summaryText: string;
9
+ metaLabel: string;
10
+ metaValue: string;
11
+ resultLabel: string;
12
+ resultValue: string;
13
+ steps: string[];
14
+ closeLabel?: string;
15
+ helperText?: string;
16
+ }
17
+ export declare function buildCallbackPage(page: CallbackPageModel): string;
@@ -0,0 +1,426 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildCallbackPage = buildCallbackPage;
4
+ const SCOREHUB_CALLBACK_LOGO_DATA_URL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='8' y1='8' x2='56' y2='56' gradientUnits='userSpaceOnUse'%3E%3Cstop stop-color='%237C3AED'/%3E%3Cstop offset='1' stop-color='%23A78BFA'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect x='6' y='6' width='52' height='52' rx='16' fill='url(%23g)'/%3E%3Cpath d='M23 20h18c5 0 9 4 9 9 0 4-2 7-5 8 2 2 3 5 3 8v1H38v-2c0-4-3-7-7-7h-8V20Zm10 8h-4v9h4c4 0 7-2 7-5 0-3-3-4-7-4Z' fill='white'/%3E%3C/svg%3E";
5
+ const CALLBACK_BASE_STYLES = `
6
+ * { margin:0; padding:0; box-sizing:border-box; }
7
+ body {
8
+ font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
9
+ min-height:100vh;
10
+ padding:28px;
11
+ display:flex;
12
+ justify-content:center;
13
+ align-items:center;
14
+ color:#211827;
15
+ background:
16
+ radial-gradient(ellipse at 22% 18%, rgba(124, 58, 237, 0.18) 0 12%, transparent 38%),
17
+ radial-gradient(ellipse at 82% 78%, rgba(139, 92, 246, 0.14) 0 10%, transparent 36%),
18
+ radial-gradient(ellipse at 18% 82%, transparent 0 34%, rgba(124, 58, 237, 0.055) 35%, transparent 36%, transparent 48%, rgba(124, 58, 237, 0.04) 49%, transparent 50%),
19
+ radial-gradient(ellipse at 82% 16%, transparent 0 30%, rgba(139, 92, 246, 0.05) 31%, transparent 32%, transparent 45%, rgba(139, 92, 246, 0.035) 46%, transparent 47%),
20
+ radial-gradient(circle at 50% 50%, rgba(124, 58, 237, 0.055) 1px, transparent 1.4px),
21
+ linear-gradient(135deg, #FDFBFF 0%, #F7F0FF 52%, #F0E7FF 100%);
22
+ background-size:auto, auto, 100% 100%, 100% 100%, 28px 28px, 100% 100%;
23
+ background-position:center;
24
+ }
25
+ .card {
26
+ position:relative;
27
+ width:min(100%, 920px);
28
+ margin:0 auto;
29
+ background:rgba(255, 255, 255, 0.94);
30
+ border:1px solid rgba(216, 180, 254, 0.76);
31
+ border-radius:22px;
32
+ box-shadow:0 28px 80px rgba(93, 52, 146, 0.18);
33
+ overflow:hidden;
34
+ }
35
+ .card::before {
36
+ content:"";
37
+ position:absolute;
38
+ inset:0 0 auto 0;
39
+ height:3px;
40
+ background:linear-gradient(90deg, #7C3AED, #A78BFA);
41
+ }
42
+ .brand { display:flex; align-items:center; gap:14px; margin-bottom:22px; }
43
+ .brand-logo {
44
+ width:48px;
45
+ height:48px;
46
+ border-radius:15px;
47
+ padding:7px;
48
+ background:rgba(255, 255, 255, 0.86);
49
+ border:1px solid rgba(216, 180, 254, 0.88);
50
+ box-shadow:0 12px 28px rgba(124, 58, 237, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.9);
51
+ object-fit:contain;
52
+ }
53
+ .brand-name { font-size:13px; color:#7C3AED; font-weight:800; margin-bottom:3px; }
54
+ h2 { font-size:26px; line-height:1.2; font-weight:800; color:#211827; margin-bottom:8px; }
55
+ .sub, p { font-size:13px; line-height:1.7; color:#665476; }
56
+ .callback-grid { display:grid; grid-template-columns:minmax(290px, 0.92fr) minmax(380px, 1.08fr); min-height:min(560px, calc(100vh - 56px)); }
57
+ .callback-info {
58
+ padding:34px;
59
+ background:linear-gradient(180deg, rgba(252, 250, 255, 0.88), rgba(248, 243, 255, 0.92));
60
+ border-right:1px solid #F0E6FF;
61
+ }
62
+ .callback-panel {
63
+ display:flex;
64
+ flex-direction:column;
65
+ min-width:0;
66
+ padding:34px 34px 0;
67
+ }
68
+ .request-summary,
69
+ .panel-card,
70
+ .next-card,
71
+ .detail-card,
72
+ .trust-item {
73
+ border-radius:16px;
74
+ border:1px solid rgba(216, 180, 254, 0.78);
75
+ background:rgba(255, 255, 255, 0.72);
76
+ box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.78);
77
+ }
78
+ .request-summary,
79
+ .panel-card,
80
+ .detail-card,
81
+ .next-card { padding:15px 16px; }
82
+ .request-label,
83
+ .section-title {
84
+ display:block;
85
+ color:#7C3AED;
86
+ font-size:11px;
87
+ font-weight:800;
88
+ line-height:1.3;
89
+ margin-bottom:7px;
90
+ text-transform:uppercase;
91
+ letter-spacing:0;
92
+ }
93
+ .request-copy { color:#372A45; font-size:13px; line-height:1.65; margin:0; }
94
+ .request-copy strong { color:#5B21B6; font-weight:800; }
95
+ .request-copy code,
96
+ .trust-copy code,
97
+ .meta-value code,
98
+ .detail-copy code {
99
+ font-family:ui-monospace, SFMono-Regular, Menlo, monospace;
100
+ font-size:0.92em;
101
+ color:#5B21B6;
102
+ background:rgba(124, 58, 237, 0.08);
103
+ border-radius:6px;
104
+ padding:1px 5px;
105
+ }
106
+ .trust-list {
107
+ display:grid;
108
+ gap:8px;
109
+ margin-top:22px;
110
+ }
111
+ .trust-item {
112
+ display:grid;
113
+ grid-template-columns:auto minmax(0, 1fr);
114
+ gap:10px;
115
+ align-items:start;
116
+ padding:12px 13px;
117
+ }
118
+ .trust-icon {
119
+ width:22px;
120
+ height:22px;
121
+ border-radius:8px;
122
+ display:flex;
123
+ align-items:center;
124
+ justify-content:center;
125
+ background:#F3E8FF;
126
+ color:#6D28D9;
127
+ font-size:12px;
128
+ font-weight:900;
129
+ }
130
+ .trust-title {
131
+ display:block;
132
+ color:#372A45;
133
+ font-size:12px;
134
+ font-weight:800;
135
+ line-height:1.35;
136
+ margin-bottom:2px;
137
+ }
138
+ .trust-copy {
139
+ display:block;
140
+ color:#665476;
141
+ font-size:12px;
142
+ line-height:1.45;
143
+ }
144
+ .account-pill {
145
+ max-width:100%;
146
+ padding:9px 12px;
147
+ border:1px solid #E9D5FF;
148
+ border-radius:12px;
149
+ background:#FCFAFF;
150
+ color:#5E4C6D;
151
+ font-size:12px;
152
+ font-weight:700;
153
+ overflow:hidden;
154
+ text-overflow:ellipsis;
155
+ white-space:nowrap;
156
+ margin-bottom:18px;
157
+ }
158
+ .status-badge {
159
+ display:inline-flex;
160
+ align-items:center;
161
+ justify-content:center;
162
+ min-height:26px;
163
+ font-size:11px;
164
+ line-height:1.2;
165
+ padding:4px 10px;
166
+ border-radius:999px;
167
+ font-weight:800;
168
+ white-space:nowrap;
169
+ margin-bottom:14px;
170
+ }
171
+ .status-badge-success {
172
+ color:#166534;
173
+ background:#DCFCE7;
174
+ border:1px solid #BBF7D0;
175
+ }
176
+ .status-badge-error {
177
+ color:#B42318;
178
+ background:#FEF3F2;
179
+ border:1px solid #FEE4E2;
180
+ }
181
+ .summary-copy {
182
+ color:#372A45;
183
+ font-size:13px;
184
+ line-height:1.65;
185
+ margin:0;
186
+ }
187
+ .summary-copy strong { color:#5B21B6; font-weight:800; }
188
+ .detail-card {
189
+ margin-top:14px;
190
+ border-color:#FEE4E2;
191
+ background:#FEF3F2;
192
+ }
193
+ .detail-copy {
194
+ color:#B42318;
195
+ font-size:12px;
196
+ line-height:1.55;
197
+ margin:0;
198
+ white-space:pre-wrap;
199
+ word-break:break-word;
200
+ }
201
+ .meta-grid {
202
+ display:grid;
203
+ grid-template-columns:repeat(2, minmax(0, 1fr));
204
+ gap:12px;
205
+ margin-top:18px;
206
+ }
207
+ .meta-label {
208
+ display:block;
209
+ color:#7C6A88;
210
+ font-size:11px;
211
+ font-weight:700;
212
+ margin-bottom:6px;
213
+ text-transform:uppercase;
214
+ }
215
+ .meta-value {
216
+ color:#372A45;
217
+ font-size:13px;
218
+ line-height:1.55;
219
+ word-break:break-word;
220
+ }
221
+ .next-card { margin-top:18px; }
222
+ .step-list {
223
+ display:grid;
224
+ gap:10px;
225
+ list-style:none;
226
+ padding:0;
227
+ margin:0;
228
+ }
229
+ .step-item {
230
+ display:grid;
231
+ grid-template-columns:auto minmax(0, 1fr);
232
+ gap:10px;
233
+ align-items:start;
234
+ color:#372A45;
235
+ font-size:13px;
236
+ line-height:1.55;
237
+ }
238
+ .step-index {
239
+ width:22px;
240
+ height:22px;
241
+ border-radius:999px;
242
+ display:flex;
243
+ align-items:center;
244
+ justify-content:center;
245
+ background:#F3E8FF;
246
+ color:#6D28D9;
247
+ font-size:12px;
248
+ font-weight:900;
249
+ }
250
+ .panel-spacer { flex:1; min-height:18px; }
251
+ .actions {
252
+ position:sticky;
253
+ bottom:0;
254
+ display:flex;
255
+ gap:12px;
256
+ margin:22px -34px 0;
257
+ padding:16px 34px 18px;
258
+ background:rgba(255, 255, 255, 0.92);
259
+ border-top:1px solid #F0E6FF;
260
+ backdrop-filter:blur(12px);
261
+ }
262
+ .btn {
263
+ flex:1;
264
+ min-height:44px;
265
+ padding:10px 16px;
266
+ border-radius:12px;
267
+ font-size:14px;
268
+ font-weight:800;
269
+ cursor:pointer;
270
+ border:1px solid transparent;
271
+ transition:background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.15s;
272
+ }
273
+ .btn:hover { transform:translateY(-1px); }
274
+ .btn-primary {
275
+ background:#7C3AED;
276
+ color:#fff;
277
+ box-shadow:0 10px 24px rgba(124, 58, 237, 0.26);
278
+ }
279
+ .btn-primary:hover { background:#6D28D9; }
280
+ .btn-secondary {
281
+ background:#fff;
282
+ color:#5E4C6D;
283
+ border-color:#E9D5FF;
284
+ }
285
+ .btn-secondary:hover {
286
+ background:#FAF7FF;
287
+ border-color:#D8B4FE;
288
+ }
289
+ .helper-note {
290
+ margin-top:14px;
291
+ color:#7C6A88;
292
+ font-size:12px;
293
+ line-height:1.5;
294
+ text-align:center;
295
+ }
296
+ @media (max-width: 760px) {
297
+ body { min-height:100vh; padding:16px; align-items:flex-start; }
298
+ .card { width:100%; border-radius:18px; }
299
+ .callback-grid { display:block; min-height:0; }
300
+ .callback-info { padding:22px 18px; border-right:0; border-bottom:1px solid #F0E6FF; }
301
+ .callback-panel { padding:22px 18px 0; }
302
+ .brand { gap:12px; margin-bottom:18px; }
303
+ .brand-logo { width:44px; height:44px; border-radius:14px; }
304
+ h2 { font-size:22px; }
305
+ .meta-grid { grid-template-columns:1fr; }
306
+ .actions { position:sticky; bottom:0; margin:22px -18px 0; padding:14px 18px 16px; }
307
+ .btn { min-height:46px; }
308
+ }
309
+ `;
310
+ function escapeHtml(value) {
311
+ return value
312
+ .replaceAll("&", "&amp;")
313
+ .replaceAll("<", "&lt;")
314
+ .replaceAll(">", "&gt;")
315
+ .replaceAll("\"", "&quot;")
316
+ .replaceAll("'", "&#39;");
317
+ }
318
+ function buildCallbackPage(page) {
319
+ const statusClass = page.tone === "success" ? "status-badge-success" : "status-badge-error";
320
+ const detailBlock = page.detail
321
+ ? `<div class="detail-card">
322
+ <span class="section-title">错误详情</span>
323
+ <p class="detail-copy">${escapeHtml(page.detail)}</p>
324
+ </div>`
325
+ : "";
326
+ const stepItems = page.steps
327
+ .map((step, index) => `<li class="step-item"><span class="step-index">${index + 1}</span><span>${escapeHtml(step)}</span></li>`)
328
+ .join("");
329
+ return `<!DOCTYPE html>
330
+ <html lang="zh-CN">
331
+ <head>
332
+ <meta charset="UTF-8">
333
+ <meta name="viewport" content="width=device-width, initial-scale=1">
334
+ <title>${escapeHtml(page.title)} | ScoreHub</title>
335
+ <link rel="icon" href="${SCOREHUB_CALLBACK_LOGO_DATA_URL}">
336
+ <style>${CALLBACK_BASE_STYLES}</style>
337
+ </head>
338
+ <body>
339
+ <div class="card">
340
+ <div class="callback-grid">
341
+ <div class="callback-info">
342
+ <div class="brand">
343
+ <img class="brand-logo" src="${SCOREHUB_CALLBACK_LOGO_DATA_URL}" alt="ScoreHub AI">
344
+ <div>
345
+ <div class="brand-name">ScoreHub AI</div>
346
+ <h2>本地授权回调</h2>
347
+ </div>
348
+ </div>
349
+ <div class="request-summary">
350
+ <span class="request-label">当前步骤</span>
351
+ <p class="request-copy"><strong>ScoreHub MCP</strong> 正在完成本机 OAuth 收尾流程。这个页面只负责接收授权结果、更新本地 token,并把状态反馈给终端里的 <strong>Tiky / MCP 客户端</strong>。</p>
352
+ </div>
353
+ <div class="trust-list">
354
+ <div class="trust-item">
355
+ <span class="trust-icon">1</span>
356
+ <span>
357
+ <span class="trust-title">仅本机处理</span>
358
+ <span class="trust-copy">本页只处理 <code>127.0.0.1</code> 本地回调,不会再次请求你的账号密码。</span>
359
+ </span>
360
+ </div>
361
+ <div class="trust-item">
362
+ <span class="trust-icon">2</span>
363
+ <span>
364
+ <span class="trust-title">结果已同步终端</span>
365
+ <span class="trust-copy">无论成功还是失败,最终状态都会回到当前终端进程,由 <code>authorize</code> 工具接收。</span>
366
+ </span>
367
+ </div>
368
+ <div class="trust-item">
369
+ <span class="trust-icon">3</span>
370
+ <span>
371
+ <span class="trust-title">异常可重新发起</span>
372
+ <span class="trust-copy">如果本次失败,请返回终端重新执行 <code>authorize</code>,而不是刷新当前页面。</span>
373
+ </span>
374
+ </div>
375
+ </div>
376
+ </div>
377
+
378
+ <div class="callback-panel">
379
+ <div class="account-pill" title="${escapeHtml(`${page.metaLabel}: ${page.metaValue}`)}">${escapeHtml(`${page.metaLabel}: ${page.metaValue}`)}</div>
380
+ <div class="status-badge ${statusClass}">${escapeHtml(page.eyebrow)}</div>
381
+ <div class="panel-card">
382
+ <h2>${escapeHtml(page.title)}</h2>
383
+ <p class="sub">${escapeHtml(page.message)}</p>
384
+ ${detailBlock}
385
+ <div class="meta-grid">
386
+ <div>
387
+ <span class="meta-label">${escapeHtml(page.summaryLabel)}</span>
388
+ <div class="meta-value">${escapeHtml(page.summaryText)}</div>
389
+ </div>
390
+ <div>
391
+ <span class="meta-label">${escapeHtml(page.resultLabel)}</span>
392
+ <div class="meta-value">${escapeHtml(page.resultValue)}</div>
393
+ </div>
394
+ </div>
395
+ </div>
396
+ <div class="next-card">
397
+ <span class="section-title">下一步</span>
398
+ <ol class="step-list">${stepItems}</ol>
399
+ </div>
400
+ <div class="panel-spacer"></div>
401
+ <div class="actions">
402
+ <button class="btn btn-secondary" type="button" id="close-button">${escapeHtml(page.closeLabel ?? "关闭当前页面")}</button>
403
+ <button class="btn btn-primary" type="button" id="focus-button">切回终端继续</button>
404
+ </div>
405
+ <div class="helper-note">${escapeHtml(page.helperText ?? "如果浏览器无法自动关闭,请手动切回终端继续。")}</div>
406
+ </div>
407
+ </div>
408
+ </div>
409
+ <script>
410
+ const closeButton = document.getElementById('close-button');
411
+ const focusButton = document.getElementById('focus-button');
412
+
413
+ closeButton.addEventListener('click', function() {
414
+ window.close();
415
+ });
416
+
417
+ focusButton.addEventListener('click', function() {
418
+ window.close();
419
+ setTimeout(function() {
420
+ window.focus();
421
+ }, 60);
422
+ });
423
+ </script>
424
+ </body>
425
+ </html>`;
426
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scorehub/mcp-server",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
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": {