@scorehub/mcp-server 0.2.8 → 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 +10 -5
- package/dist/auth.d.ts +3 -7
- package/dist/auth.js +265 -103
- package/dist/index.js +19 -104
- package/dist/pages/callback-pages.d.ts +17 -0
- package/dist/pages/callback-pages.js +426 -0
- package/package.json +1 -1
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 `
|
|
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
|
-
├─
|
|
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
|
|
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 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
|
+
|
|
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
|
|
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
|
|
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,24 @@ 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
|
+
const callback_pages_js_1 = require("./pages/callback-pages.js");
|
|
22
|
+
class CallbackError extends Error {
|
|
23
|
+
page;
|
|
24
|
+
statusCode;
|
|
25
|
+
constructor(message, page, statusCode = 400) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "CallbackError";
|
|
28
|
+
this.page = page;
|
|
29
|
+
this.statusCode = statusCode;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
23
32
|
// ── Token Storage ────────────────────────────────────────────
|
|
24
33
|
const SCOREHUB_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".scorehub");
|
|
25
34
|
const TOKEN_FILE = (host) => (0, node_path_1.join)(SCOREHUB_DIR, host, "oauth.json");
|
|
35
|
+
const DEFAULT_CALLBACK_HOST = "127.0.0.1";
|
|
36
|
+
const DEFAULT_CALLBACK_PATH = "/callback";
|
|
26
37
|
function ensureDir(filePath) {
|
|
27
38
|
const dir = (0, node_path_1.dirname)(filePath);
|
|
28
39
|
if (!(0, node_fs_1.existsSync)(dir)) {
|
|
@@ -54,6 +65,62 @@ function clearTokens(host) {
|
|
|
54
65
|
function hasSignedTokenShape(token) {
|
|
55
66
|
return token.split(".").length === 3;
|
|
56
67
|
}
|
|
68
|
+
function normalizeCallbackPath(path) {
|
|
69
|
+
if (!path || path === "/")
|
|
70
|
+
return DEFAULT_CALLBACK_PATH;
|
|
71
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
72
|
+
}
|
|
73
|
+
function buildLocalRedirectUri(host, port, path) {
|
|
74
|
+
const url = new URL(`http://${host}:${port}`);
|
|
75
|
+
url.pathname = normalizeCallbackPath(path);
|
|
76
|
+
return url.toString();
|
|
77
|
+
}
|
|
78
|
+
function getTokenErrorMessage(payload) {
|
|
79
|
+
if (payload && typeof payload === "object") {
|
|
80
|
+
const record = payload;
|
|
81
|
+
if (typeof record.error_description === "string" && record.error_description) {
|
|
82
|
+
return record.error_description;
|
|
83
|
+
}
|
|
84
|
+
if (typeof record.error === "string" && record.error) {
|
|
85
|
+
return record.error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return JSON.stringify(payload);
|
|
89
|
+
}
|
|
90
|
+
function parseOAuthTokens(payload) {
|
|
91
|
+
if (payload && typeof payload === "object") {
|
|
92
|
+
const record = payload;
|
|
93
|
+
if (typeof record.access_token === "string"
|
|
94
|
+
&& typeof record.refresh_token === "string"
|
|
95
|
+
&& typeof record.expires_in === "number") {
|
|
96
|
+
return {
|
|
97
|
+
access_token: record.access_token,
|
|
98
|
+
refresh_token: record.refresh_token,
|
|
99
|
+
expires_in: record.expires_in,
|
|
100
|
+
...(typeof record.scope === "string" ? { scope: record.scope } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw new Error(getTokenErrorMessage(payload));
|
|
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
|
+
}
|
|
57
124
|
// ── Token Exchange ───────────────────────────────────────────
|
|
58
125
|
function tokenPostRequest(tokenEndpoint, body) {
|
|
59
126
|
const url = new URL(tokenEndpoint);
|
|
@@ -91,7 +158,7 @@ async function exchangeCodeForTokens(tokenEndpoint, code, codeVerifier, clientId
|
|
|
91
158
|
client_id: clientId,
|
|
92
159
|
redirect_uri: redirectUri,
|
|
93
160
|
}).toString();
|
|
94
|
-
return tokenPostRequest(tokenEndpoint, body);
|
|
161
|
+
return parseOAuthTokens(await tokenPostRequest(tokenEndpoint, body));
|
|
95
162
|
}
|
|
96
163
|
async function refreshAccessToken(tokenEndpoint, refreshToken, clientId) {
|
|
97
164
|
const body = new URLSearchParams({
|
|
@@ -99,7 +166,7 @@ async function refreshAccessToken(tokenEndpoint, refreshToken, clientId) {
|
|
|
99
166
|
refresh_token: refreshToken,
|
|
100
167
|
client_id: clientId,
|
|
101
168
|
}).toString();
|
|
102
|
-
return tokenPostRequest(tokenEndpoint, body);
|
|
169
|
+
return parseOAuthTokens(await tokenPostRequest(tokenEndpoint, body));
|
|
103
170
|
}
|
|
104
171
|
async function getValidAccessToken(host, tokenEndpoint, clientId) {
|
|
105
172
|
const cached = loadTokens(host);
|
|
@@ -135,125 +202,220 @@ function generateCodeChallenge(verifier) {
|
|
|
135
202
|
function generateState(length = 16) {
|
|
136
203
|
return (0, node_crypto_1.randomBytes)(length).toString("hex");
|
|
137
204
|
}
|
|
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
205
|
// ── Browser Launch ───────────────────────────────────────────
|
|
189
206
|
function openBrowser(url) {
|
|
190
207
|
try {
|
|
191
208
|
const platform = process.platform;
|
|
192
209
|
if (platform === "darwin") {
|
|
193
|
-
(0, node_child_process_1.
|
|
210
|
+
(0, node_child_process_1.execFileSync)("open", [url], { stdio: "ignore", timeout: 3000 });
|
|
194
211
|
}
|
|
195
212
|
else if (platform === "win32") {
|
|
196
|
-
(0, node_child_process_1.
|
|
213
|
+
(0, node_child_process_1.execFileSync)("cmd.exe", ["/c", "start", "", url], { stdio: "ignore", timeout: 3000 });
|
|
197
214
|
}
|
|
198
215
|
else {
|
|
199
|
-
(0, node_child_process_1.
|
|
216
|
+
(0, node_child_process_1.execFileSync)("xdg-open", [url], { stdio: "ignore", timeout: 3000 });
|
|
200
217
|
}
|
|
201
218
|
}
|
|
202
219
|
catch {
|
|
203
|
-
console.error(
|
|
220
|
+
console.error("[scorehub] Could not open browser automatically.");
|
|
204
221
|
console.error(`[scorehub] Please visit this URL manually:\n ${url}`);
|
|
205
222
|
}
|
|
206
223
|
}
|
|
207
224
|
// ── Full OAuth Flow ──────────────────────────────────────────
|
|
208
225
|
async function authorize(params) {
|
|
209
|
-
const { authEndpoint, tokenEndpoint, clientId, redirectUri,
|
|
226
|
+
const { authEndpoint, tokenEndpoint, clientId, redirectUri, scope, timeoutMs = 120_000, host, } = params;
|
|
227
|
+
const redirectUrl = redirectUri ? new URL(redirectUri) : null;
|
|
228
|
+
const callbackHost = params.callbackHost ?? redirectUrl?.hostname ?? DEFAULT_CALLBACK_HOST;
|
|
229
|
+
const callbackPath = normalizeCallbackPath(params.callbackPath ?? redirectUrl?.pathname ?? DEFAULT_CALLBACK_PATH);
|
|
230
|
+
const listenPort = params.port ?? (redirectUrl?.port ? Number(redirectUrl.port) : 0);
|
|
210
231
|
const storageHost = host ?? new URL(authEndpoint).host;
|
|
211
232
|
const codeVerifier = generateCodeVerifier();
|
|
212
233
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
213
234
|
const state = generateState();
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
let timeoutId;
|
|
237
|
+
let settled = false;
|
|
238
|
+
const stopServer = (server) => {
|
|
239
|
+
if (timeoutId) {
|
|
240
|
+
clearTimeout(timeoutId);
|
|
241
|
+
timeoutId = undefined;
|
|
242
|
+
}
|
|
243
|
+
server.close();
|
|
244
|
+
};
|
|
245
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
246
|
+
const actualPort = server.address()?.port ?? listenPort;
|
|
247
|
+
const requestUrl = req.url ?? "/";
|
|
248
|
+
const url = new URL(requestUrl, `http://${callbackHost}:${actualPort}`);
|
|
249
|
+
const callbackUrl = buildLocalRedirectUri(callbackHost, actualPort, callbackPath);
|
|
250
|
+
if (url.pathname !== callbackPath) {
|
|
251
|
+
res.writeHead(204);
|
|
252
|
+
res.end();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (settled) {
|
|
256
|
+
res.writeHead(409, { "Content-Type": "text/html; charset=utf-8" });
|
|
257
|
+
res.end((0, callback_pages_js_1.buildCallbackPage)(createCallbackPageModel({
|
|
258
|
+
tone: "error",
|
|
259
|
+
eyebrow: "Request Closed",
|
|
260
|
+
title: "授权请求已结束",
|
|
261
|
+
message: "当前回调监听已经完成,不能再用这个页面继续授权流程。",
|
|
262
|
+
summaryText: "这通常表示你刷新了已经处理过的 callback 页面,或者在授权完成后再次访问了同一地址。",
|
|
263
|
+
resultValue: "本次请求已关闭,请回终端查看最终结果",
|
|
264
|
+
steps: [
|
|
265
|
+
"回到 MCP 客户端终端查看最近一次 authorize 的执行结果。",
|
|
266
|
+
"如果仍需重新登录,请在终端重新执行 authorize,而不是刷新当前页面。",
|
|
267
|
+
],
|
|
268
|
+
callbackUrl,
|
|
269
|
+
closeLabel: "关闭当前页面",
|
|
270
|
+
})));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
void (async () => {
|
|
274
|
+
try {
|
|
275
|
+
const oauthError = url.searchParams.get("error");
|
|
276
|
+
const oauthErrorDescription = url.searchParams.get("error_description") ?? undefined;
|
|
277
|
+
if (oauthError) {
|
|
278
|
+
throw new CallbackError(oauthError === "access_denied" ? "User denied authorization" : `OAuth callback returned error: ${oauthError}`, createCallbackPageModel({
|
|
279
|
+
tone: "error",
|
|
280
|
+
eyebrow: "Authorization Canceled",
|
|
281
|
+
title: oauthError === "access_denied" ? "你已取消授权" : "授权未完成",
|
|
282
|
+
message: "ScoreHub 没有返回可用的授权结果,本次登录不会写入本地 token。",
|
|
283
|
+
detail: oauthErrorDescription ?? oauthError,
|
|
284
|
+
summaryText: oauthError === "access_denied"
|
|
285
|
+
? "你在 OAuth 授权页拒绝了本次请求,ScoreHub MCP 不会继续交换 token。"
|
|
286
|
+
: "远端授权流程返回了错误状态,本地不会保存任何新的授权信息。",
|
|
287
|
+
resultValue: "未写入本地 token,可重新发起授权",
|
|
288
|
+
steps: [
|
|
289
|
+
"返回终端查看本次授权失败提示。",
|
|
290
|
+
"如仍需使用 ScoreHub,请重新执行 authorize。",
|
|
291
|
+
],
|
|
292
|
+
callbackUrl,
|
|
293
|
+
closeLabel: "关闭后回终端",
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
const code = url.searchParams.get("code");
|
|
297
|
+
if (!code) {
|
|
298
|
+
throw new CallbackError("No authorization code in callback", createCallbackPageModel({
|
|
299
|
+
tone: "error",
|
|
300
|
+
eyebrow: "Missing Code",
|
|
301
|
+
title: "没有收到授权码",
|
|
302
|
+
message: "本地回调页已打开,但远端没有带回可交换 token 的授权码。",
|
|
303
|
+
summaryText: "浏览器已经回到本地 callback,但缺少完成 token 交换必需的 authorization code。",
|
|
304
|
+
resultValue: "授权结果不完整,本地未保存任何 token",
|
|
305
|
+
steps: [
|
|
306
|
+
"返回终端查看详细错误信息。",
|
|
307
|
+
"重新执行 authorize,并确认浏览器完成了全部授权步骤。",
|
|
308
|
+
],
|
|
309
|
+
callbackUrl,
|
|
310
|
+
closeLabel: "关闭后回终端",
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
const returnedState = url.searchParams.get("state") ?? "";
|
|
314
|
+
if (returnedState !== state) {
|
|
315
|
+
throw new CallbackError("OAuth state mismatch — possible CSRF attack. Please restart the authorization flow.", createCallbackPageModel({
|
|
316
|
+
tone: "error",
|
|
317
|
+
eyebrow: "State Mismatch",
|
|
318
|
+
title: "本地状态校验失败",
|
|
319
|
+
message: "回调中的 state 与本地发起的请求不一致,本次授权已被拒绝写入。",
|
|
320
|
+
summaryText: "为了避免把错误来源的授权结果写入本机,ScoreHub MCP 已主动丢弃这次回调。",
|
|
321
|
+
resultValue: "安全校验未通过,需要重新发起授权",
|
|
322
|
+
steps: [
|
|
323
|
+
"关闭当前页面并返回终端。",
|
|
324
|
+
"重新执行 authorize 发起一次新的授权流程。",
|
|
325
|
+
],
|
|
326
|
+
callbackUrl,
|
|
327
|
+
closeLabel: "关闭后回终端",
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
const actualRedirectUri = redirectUri ?? callbackUrl;
|
|
331
|
+
const tokens = await exchangeCodeForTokens(tokenEndpoint, code, codeVerifier, clientId, actualRedirectUri);
|
|
332
|
+
saveTokens(storageHost, {
|
|
333
|
+
access_token: tokens.access_token,
|
|
334
|
+
refresh_token: tokens.refresh_token,
|
|
335
|
+
expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
336
|
+
updated_at: new Date().toISOString(),
|
|
337
|
+
});
|
|
338
|
+
settled = true;
|
|
339
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
340
|
+
res.end((0, callback_pages_js_1.buildCallbackPage)(createCallbackPageModel({
|
|
341
|
+
tone: "success",
|
|
342
|
+
eyebrow: "Authorization Complete",
|
|
343
|
+
title: "ScoreHub 已完成授权",
|
|
344
|
+
message: "本地 token 已写入当前终端配置,你现在可以返回 MCP 客户端继续使用工具。",
|
|
345
|
+
summaryText: "ScoreHub MCP 已收到授权码并完成 token 交换,当前终端会直接复用这次新的本地授权。",
|
|
346
|
+
resultValue: "授权成功,本地 token 已更新",
|
|
347
|
+
steps: [
|
|
348
|
+
"切回终端继续执行刚才的操作。",
|
|
349
|
+
"如果当前客户端没有自动继续,可直接重新调用目标 ScoreHub 工具。",
|
|
350
|
+
],
|
|
351
|
+
callbackUrl,
|
|
352
|
+
closeLabel: "关闭当前页面",
|
|
353
|
+
})));
|
|
354
|
+
stopServer(server);
|
|
355
|
+
resolve({
|
|
356
|
+
accessToken: tokens.access_token,
|
|
357
|
+
refreshToken: tokens.refresh_token,
|
|
358
|
+
expiresIn: tokens.expires_in,
|
|
359
|
+
scope: tokens.scope,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
settled = true;
|
|
364
|
+
clearTokens(storageHost);
|
|
365
|
+
const err = error instanceof CallbackError
|
|
366
|
+
? error
|
|
367
|
+
: new CallbackError(error instanceof Error ? error.message : String(error), createCallbackPageModel({
|
|
368
|
+
tone: "error",
|
|
369
|
+
eyebrow: "Token Exchange Failed",
|
|
370
|
+
title: "授权结果未能保存",
|
|
371
|
+
message: "浏览器已经完成跳转,但本地未能成功换取或保存 ScoreHub token。",
|
|
372
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
373
|
+
summaryText: "问题发生在本地 token 交换或保存阶段,而不是登录页或授权确认页本身。",
|
|
374
|
+
resultValue: "未保存新的 token,请回终端重试",
|
|
375
|
+
steps: [
|
|
376
|
+
"返回终端查看失败原因。",
|
|
377
|
+
"确认网络和远端状态后,重新执行 authorize。",
|
|
378
|
+
],
|
|
379
|
+
callbackUrl,
|
|
380
|
+
closeLabel: "关闭后回终端",
|
|
381
|
+
}));
|
|
382
|
+
res.writeHead(err.statusCode, { "Content-Type": "text/html; charset=utf-8" });
|
|
383
|
+
res.end((0, callback_pages_js_1.buildCallbackPage)(err.page));
|
|
384
|
+
stopServer(server);
|
|
385
|
+
reject(err);
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
});
|
|
389
|
+
server.on("error", (err) => {
|
|
390
|
+
if (settled)
|
|
391
|
+
return;
|
|
392
|
+
settled = true;
|
|
393
|
+
clearTokens(storageHost);
|
|
394
|
+
stopServer(server);
|
|
395
|
+
reject(err);
|
|
396
|
+
});
|
|
397
|
+
server.listen(listenPort, callbackHost, () => {
|
|
398
|
+
server.unref();
|
|
399
|
+
const actualPort = server.address().port;
|
|
400
|
+
const actualRedirectUri = redirectUri ?? buildLocalRedirectUri(callbackHost, actualPort, callbackPath);
|
|
401
|
+
const authUrl = new URL(authEndpoint);
|
|
402
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
403
|
+
authUrl.searchParams.set("redirect_uri", actualRedirectUri);
|
|
404
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
405
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
406
|
+
authUrl.searchParams.set("state", state);
|
|
407
|
+
authUrl.searchParams.set("response_type", "code");
|
|
408
|
+
if (scope)
|
|
409
|
+
authUrl.searchParams.set("scope", scope);
|
|
410
|
+
openBrowser(authUrl.toString());
|
|
411
|
+
timeoutId = setTimeout(() => {
|
|
412
|
+
if (settled)
|
|
413
|
+
return;
|
|
414
|
+
settled = true;
|
|
415
|
+
clearTokens(storageHost);
|
|
416
|
+
stopServer(server);
|
|
417
|
+
reject(new Error(`Authorization timed out after ${timeoutMs / 1000}s. Please try again.`));
|
|
418
|
+
}, timeoutMs);
|
|
419
|
+
});
|
|
252
420
|
});
|
|
253
|
-
return {
|
|
254
|
-
accessToken: tokens.access_token,
|
|
255
|
-
refreshToken: tokens.refresh_token,
|
|
256
|
-
expiresIn: tokens.expires_in,
|
|
257
|
-
scope: tokens.scope,
|
|
258
|
-
};
|
|
259
421
|
}
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
270
|
+
text: "⚠️ 授权已失效,已清除本地缓存。请调用 \"authorize\" 工具重新登录 ScoreHub。若浏览器未自动打开,请复制终端输出的授权链接到浏览器完成登录。",
|
|
356
271
|
},
|
|
357
272
|
],
|
|
358
273
|
};
|
|
@@ -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("&", "&")
|
|
313
|
+
.replaceAll("<", "<")
|
|
314
|
+
.replaceAll(">", ">")
|
|
315
|
+
.replaceAll("\"", """)
|
|
316
|
+
.replaceAll("'", "'");
|
|
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
|
+
}
|