@scorehub/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ export interface OAuthTokens {
2
+ access_token: string;
3
+ refresh_token: string;
4
+ expires_in: number;
5
+ scope?: string;
6
+ }
7
+ export interface TokenStore {
8
+ access_token: string;
9
+ refresh_token: string;
10
+ expires_at: number;
11
+ updated_at: string;
12
+ }
13
+ export interface AuthorizationCodeResult {
14
+ code: string;
15
+ state: string;
16
+ }
17
+ export interface AuthorizeParams {
18
+ authEndpoint: string;
19
+ tokenEndpoint: string;
20
+ clientId: string;
21
+ redirectUri: string;
22
+ port?: number;
23
+ scope?: string;
24
+ timeoutMs?: number;
25
+ host?: string;
26
+ }
27
+ export interface AuthorizeResult {
28
+ accessToken: string;
29
+ refreshToken: string;
30
+ expiresIn: number;
31
+ scope?: string;
32
+ }
33
+ export declare function loadTokens(host: string): TokenStore | null;
34
+ export declare function saveTokens(host: string, tokens: TokenStore): void;
35
+ export declare function clearTokens(host: string): void;
36
+ export declare function exchangeCodeForTokens(tokenEndpoint: string, code: string, codeVerifier: string, clientId: string, redirectUri: string): Promise<OAuthTokens>;
37
+ export declare function refreshAccessToken(tokenEndpoint: string, refreshToken: string, clientId: string): Promise<OAuthTokens>;
38
+ export declare function getValidAccessToken(host: string, tokenEndpoint: string, clientId: string): Promise<{
39
+ access_token: string;
40
+ source: "cache" | "refresh";
41
+ } | null>;
42
+ export declare function generateCodeVerifier(length?: number): string;
43
+ export declare function generateCodeChallenge(verifier: string): string;
44
+ export declare function generateState(length?: number): string;
45
+ export declare function startCallbackServer(port: number, timeoutMs?: number): Promise<AuthorizationCodeResult>;
46
+ export declare function openBrowser(url: string): void;
47
+ export declare function authorize(params: AuthorizeParams): Promise<AuthorizeResult>;
package/dist/auth.js ADDED
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadTokens = loadTokens;
4
+ exports.saveTokens = saveTokens;
5
+ exports.clearTokens = clearTokens;
6
+ exports.exchangeCodeForTokens = exchangeCodeForTokens;
7
+ exports.refreshAccessToken = refreshAccessToken;
8
+ exports.getValidAccessToken = getValidAccessToken;
9
+ exports.generateCodeVerifier = generateCodeVerifier;
10
+ exports.generateCodeChallenge = generateCodeChallenge;
11
+ exports.generateState = generateState;
12
+ exports.startCallbackServer = startCallbackServer;
13
+ exports.openBrowser = openBrowser;
14
+ exports.authorize = authorize;
15
+ const node_https_1 = require("node:https");
16
+ const node_http_1 = require("node:http");
17
+ const node_fs_1 = require("node:fs");
18
+ const node_path_1 = require("node:path");
19
+ const node_os_1 = require("node:os");
20
+ const node_http_2 = require("node:http");
21
+ const node_crypto_1 = require("node:crypto");
22
+ const node_child_process_1 = require("node:child_process");
23
+ // ── Token Storage ────────────────────────────────────────────
24
+ const SCOREHUB_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".scorehub");
25
+ const TOKEN_FILE = (host) => (0, node_path_1.join)(SCOREHUB_DIR, host, "oauth.json");
26
+ function ensureDir(filePath) {
27
+ const dir = (0, node_path_1.dirname)(filePath);
28
+ if (!(0, node_fs_1.existsSync)(dir)) {
29
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
30
+ }
31
+ }
32
+ function loadTokens(host) {
33
+ const file = TOKEN_FILE(host);
34
+ if (!(0, node_fs_1.existsSync)(file))
35
+ return null;
36
+ try {
37
+ return JSON.parse((0, node_fs_1.readFileSync)(file, "utf-8"));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function saveTokens(host, tokens) {
44
+ const file = TOKEN_FILE(host);
45
+ ensureDir(file);
46
+ (0, node_fs_1.writeFileSync)(file, JSON.stringify(tokens, null, 2), "utf-8");
47
+ }
48
+ function clearTokens(host) {
49
+ const file = TOKEN_FILE(host);
50
+ if ((0, node_fs_1.existsSync)(file)) {
51
+ (0, node_fs_1.writeFileSync)(file, JSON.stringify({}), "utf-8");
52
+ }
53
+ }
54
+ // ── Token Exchange ───────────────────────────────────────────
55
+ function tokenPostRequest(tokenEndpoint, body) {
56
+ const url = new URL(tokenEndpoint);
57
+ const transport = url.protocol === "http:" ? node_http_1.request : node_https_1.request;
58
+ return new Promise((resolve, reject) => {
59
+ const req = transport({
60
+ hostname: url.hostname,
61
+ port: url.port || (url.protocol === "http:" ? 80 : 443),
62
+ path: url.pathname + url.search,
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
65
+ rejectUnauthorized: false,
66
+ }, (res) => {
67
+ let data = "";
68
+ res.on("data", (chunk) => (data += chunk));
69
+ res.on("end", () => {
70
+ try {
71
+ resolve(JSON.parse(data));
72
+ }
73
+ catch {
74
+ reject(new Error(`Token endpoint returned non-JSON: ${data.substring(0, 200)}`));
75
+ }
76
+ });
77
+ });
78
+ req.on("error", reject);
79
+ req.write(body);
80
+ req.end();
81
+ });
82
+ }
83
+ async function exchangeCodeForTokens(tokenEndpoint, code, codeVerifier, clientId, redirectUri) {
84
+ const body = new URLSearchParams({
85
+ grant_type: "authorization_code",
86
+ code,
87
+ code_verifier: codeVerifier,
88
+ client_id: clientId,
89
+ redirect_uri: redirectUri,
90
+ }).toString();
91
+ return tokenPostRequest(tokenEndpoint, body);
92
+ }
93
+ async function refreshAccessToken(tokenEndpoint, refreshToken, clientId) {
94
+ const body = new URLSearchParams({
95
+ grant_type: "refresh_token",
96
+ refresh_token: refreshToken,
97
+ client_id: clientId,
98
+ }).toString();
99
+ return tokenPostRequest(tokenEndpoint, body);
100
+ }
101
+ async function getValidAccessToken(host, tokenEndpoint, clientId) {
102
+ const cached = loadTokens(host);
103
+ if (cached && cached.access_token && cached.expires_at > Date.now() + 5 * 60 * 1000) {
104
+ return { access_token: cached.access_token, source: "cache" };
105
+ }
106
+ if (cached?.refresh_token) {
107
+ try {
108
+ const result = await refreshAccessToken(tokenEndpoint, cached.refresh_token, clientId);
109
+ if (result.access_token) {
110
+ saveTokens(host, {
111
+ access_token: result.access_token,
112
+ refresh_token: result.refresh_token ?? cached.refresh_token,
113
+ expires_at: Date.now() + (result.expires_in ?? 3600) * 1000,
114
+ updated_at: new Date().toISOString(),
115
+ });
116
+ return { access_token: result.access_token, source: "refresh" };
117
+ }
118
+ }
119
+ catch {
120
+ // refresh failed, need re-auth
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+ // ── PKCE Utilities ───────────────────────────────────────────
126
+ function generateCodeVerifier(length = 64) {
127
+ return (0, node_crypto_1.randomBytes)(length).toString("base64url").substring(0, 128);
128
+ }
129
+ function generateCodeChallenge(verifier) {
130
+ return (0, node_crypto_1.createHash)("sha256").update(Buffer.from(verifier, "ascii")).digest("base64url");
131
+ }
132
+ function generateState(length = 16) {
133
+ return (0, node_crypto_1.randomBytes)(length).toString("hex");
134
+ }
135
+ // ── Local Callback Server ────────────────────────────────────
136
+ function startCallbackServer(port, timeoutMs = 120_000) {
137
+ return new Promise((resolve, reject) => {
138
+ let timeoutId;
139
+ const server = (0, node_http_2.createServer)((req, res) => {
140
+ const requestUrl = req.url ?? "/";
141
+ const url = new URL(requestUrl, `http://127.0.0.1:${port}`);
142
+ const code = url.searchParams.get("code");
143
+ const state = url.searchParams.get("state");
144
+ if (code) {
145
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
146
+ res.end("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Authorization Complete</title></head>" +
147
+ "<body style=\"font-family:sans-serif;text-align:center;padding-top:80px;\">" +
148
+ "<h2>✅ Authorization Successful</h2>" +
149
+ "<p>You may close this window and return to the terminal.</p>" +
150
+ "</body></html>");
151
+ }
152
+ else {
153
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
154
+ res.end("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Authorization Failed</title></head>" +
155
+ "<body style=\"font-family:sans-serif;text-align:center;padding-top:80px;\">" +
156
+ "<h2>❌ Authorization Failed</h2>" +
157
+ "<p>No authorization code received from the provider.</p>" +
158
+ "</body></html>");
159
+ }
160
+ if (timeoutId)
161
+ clearTimeout(timeoutId);
162
+ server.close();
163
+ if (code) {
164
+ resolve({ code, state: state ?? "" });
165
+ }
166
+ else {
167
+ reject(new Error("No authorization code in callback"));
168
+ }
169
+ });
170
+ server.on("error", (err) => {
171
+ if (timeoutId)
172
+ clearTimeout(timeoutId);
173
+ reject(err);
174
+ });
175
+ server.listen(port, "127.0.0.1", () => {
176
+ server.unref();
177
+ timeoutId = setTimeout(() => {
178
+ server.close();
179
+ reject(new Error(`Authorization timed out after ${timeoutMs / 1000}s. Please try again.`));
180
+ }, timeoutMs);
181
+ });
182
+ });
183
+ }
184
+ // ── Browser Launch ───────────────────────────────────────────
185
+ function openBrowser(url) {
186
+ try {
187
+ const platform = process.platform;
188
+ if (platform === "darwin") {
189
+ (0, node_child_process_1.execSync)(`open "${url}"`, { stdio: "ignore", timeout: 3000 });
190
+ }
191
+ else if (platform === "win32") {
192
+ (0, node_child_process_1.execSync)(`start "" "${url}"`, { shell: "cmd.exe", stdio: "ignore", timeout: 3000 });
193
+ }
194
+ else {
195
+ (0, node_child_process_1.execSync)(`xdg-open "${url}"`, { stdio: "ignore", timeout: 3000 });
196
+ }
197
+ }
198
+ catch {
199
+ console.error(`[scorehub] Could not open browser automatically.`);
200
+ console.error(`[scorehub] Please visit this URL manually:\n ${url}`);
201
+ }
202
+ }
203
+ // ── Full OAuth Flow ──────────────────────────────────────────
204
+ async function authorize(params) {
205
+ const { authEndpoint, tokenEndpoint, clientId, redirectUri, port = 19836, scope, timeoutMs = 120_000, host, } = params;
206
+ const storageHost = host ?? new URL(authEndpoint).host;
207
+ const codeVerifier = generateCodeVerifier();
208
+ const codeChallenge = generateCodeChallenge(codeVerifier);
209
+ const state = generateState();
210
+ const authUrl = new URL(authEndpoint);
211
+ authUrl.searchParams.set("client_id", clientId);
212
+ authUrl.searchParams.set("redirect_uri", redirectUri);
213
+ authUrl.searchParams.set("code_challenge", codeChallenge);
214
+ authUrl.searchParams.set("code_challenge_method", "S256");
215
+ authUrl.searchParams.set("state", state);
216
+ authUrl.searchParams.set("response_type", "code");
217
+ if (scope)
218
+ authUrl.searchParams.set("scope", scope);
219
+ openBrowser(authUrl.toString());
220
+ let returnedCode;
221
+ let returnedState;
222
+ try {
223
+ const result = await startCallbackServer(port, timeoutMs);
224
+ returnedCode = result.code;
225
+ returnedState = result.state;
226
+ }
227
+ catch (err) {
228
+ clearTokens(storageHost);
229
+ throw err;
230
+ }
231
+ if (returnedState !== state) {
232
+ clearTokens(storageHost);
233
+ throw new Error("OAuth state mismatch — possible CSRF attack. Please restart the authorization flow.");
234
+ }
235
+ let tokens;
236
+ try {
237
+ tokens = await exchangeCodeForTokens(tokenEndpoint, returnedCode, codeVerifier, clientId, redirectUri);
238
+ }
239
+ catch (err) {
240
+ clearTokens(storageHost);
241
+ throw new Error(`Token exchange failed: ${err instanceof Error ? err.message : String(err)}`);
242
+ }
243
+ saveTokens(storageHost, {
244
+ access_token: tokens.access_token,
245
+ refresh_token: tokens.refresh_token,
246
+ expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000,
247
+ updated_at: new Date().toISOString(),
248
+ });
249
+ return {
250
+ accessToken: tokens.access_token,
251
+ refreshToken: tokens.refresh_token,
252
+ expiresIn: tokens.expires_in,
253
+ scope: tokens.scope,
254
+ };
255
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * ScoreHub MCP Server — Local MCP Proxy
5
+ *
6
+ * Architecture (mirrors @lessie/mcp-server):
7
+ * Agent (WorkBuddy/Claude) ──stdio── Local MCP Server ──Streamable HTTP── Remote MCP Server
8
+ *
9
+ * The local server handles:
10
+ * 1. OAuth 2.1 Authorization Code + PKCE — browser-based login to ScoreHub
11
+ * 2. Tool forwarding — proxies all remote tools to the remote MCP endpoint
12
+ */
13
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
14
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
15
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
16
+ const REMOTE_MCP_URL = process.env.SCOREHUB_REMOTE_MCP_URL || "http://localhost:3100";
17
+ const TOKEN_ENDPOINT = `${REMOTE_MCP_URL}/token`;
18
+ const AUTHORIZE_URL = `${REMOTE_MCP_URL}/authorize`;
19
+ // ── Auth State ──────────────────────────────────────────────
20
+ const auth_js_1 = require("./auth.js");
21
+ const node_child_process_1 = require("node:child_process");
22
+ const HOST = new URL(REMOTE_MCP_URL).host;
23
+ let cachedToken = null;
24
+ function getStoredToken() {
25
+ if (cachedToken)
26
+ return cachedToken;
27
+ const t = (0, auth_js_1.loadTokens)(HOST);
28
+ if (t?.access_token && t.expires_at > Date.now() + 60_000) {
29
+ cachedToken = t.access_token;
30
+ return cachedToken;
31
+ }
32
+ return null;
33
+ }
34
+ // ── Remote Tool Proxy ───────────────────────────────────────
35
+ async function fetchRemoteTools() {
36
+ try {
37
+ const resp = await fetch(REMOTE_MCP_URL.replace(/\/$/, "") + "/mcp/tools/list");
38
+ if (!resp.ok)
39
+ throw new Error(`Remote tools fetch failed: ${resp.status}`);
40
+ const data = await resp.json();
41
+ return data.tools || [];
42
+ }
43
+ catch {
44
+ // Return offline message when remote is not available
45
+ return [];
46
+ }
47
+ }
48
+ async function callRemoteTool(name, args) {
49
+ // Map tool names to remote endpoints
50
+ const toolEndpoints = {
51
+ search_creators: "/mcp/tools/search-creators",
52
+ creator_performance: "/mcp/tools/creator-performance",
53
+ create_conversation: "/mcp/tools/create-conversation",
54
+ send_message: "/mcp/tools/send-message",
55
+ };
56
+ const endpoint = toolEndpoints[name];
57
+ if (!endpoint) {
58
+ return JSON.stringify({ error: `Unknown tool: ${name}` });
59
+ }
60
+ const resp = await fetch(`${REMOTE_MCP_URL}${endpoint}`, {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ ...(cachedToken ? { Authorization: `Bearer ${cachedToken}` } : {}),
65
+ },
66
+ body: JSON.stringify(args),
67
+ });
68
+ const data = await resp.json();
69
+ return JSON.stringify(data, null, 2);
70
+ }
71
+ // ── OAuth authorize helper ──────────────────────────────────
72
+ function generateAuthUrl() {
73
+ // Generate random state for CSRF protection
74
+ const state = Math.random().toString(36).substring(2);
75
+ return `${AUTHORIZE_URL}?client_id=scorehub-mcp&redirect_uri=http://127.0.0.1:19836/callback&state=${state}`;
76
+ }
77
+ // ── Fetch remote tools on startup ───────────────────────────
78
+ let remoteTools = [];
79
+ // ── MCP Server ──────────────────────────────────────────────
80
+ const server = new index_js_1.Server({ name: "scorehub-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
81
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
82
+ // Refresh remote tools on each list
83
+ try {
84
+ remoteTools = await fetchRemoteTools();
85
+ }
86
+ catch { /* keep stale */ }
87
+ const tools = [
88
+ {
89
+ name: "authorize",
90
+ description: "通过浏览器 OAuth 登录 ScoreHub。首次连接时自动调用,打开浏览器引导登录。已登录时可直接使用其他工具。",
91
+ inputSchema: {
92
+ type: "object",
93
+ properties: {},
94
+ required: [],
95
+ },
96
+ },
97
+ {
98
+ name: "status",
99
+ description: "检查 ScoreHub 连接状态和授权信息。",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {},
103
+ required: [],
104
+ },
105
+ },
106
+ ...remoteTools.map((t) => ({
107
+ name: t.name,
108
+ description: t.description,
109
+ inputSchema: t.inputSchema,
110
+ })),
111
+ ];
112
+ return { tools };
113
+ });
114
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
115
+ const { name, arguments: args = {} } = request.params;
116
+ switch (name) {
117
+ case "authorize": {
118
+ const existing = getStoredToken();
119
+ if (existing) {
120
+ return {
121
+ content: [{ type: "text", text: "✅ 已授权,可直接使用工具。" }],
122
+ };
123
+ }
124
+ const authUrl = generateAuthUrl();
125
+ const os = process.platform;
126
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
127
+ // Start callback server BEFORE opening browser
128
+ const callbackPromise = (0, auth_js_1.startCallbackServer)(19836);
129
+ try {
130
+ (0, node_child_process_1.execSync)(`${cmd} "${authUrl}"`);
131
+ }
132
+ catch { /* headless env: user opens manually */ }
133
+ try {
134
+ const { code } = await callbackPromise;
135
+ const baseUrl = REMOTE_MCP_URL.replace(/\/mcp.*$/, "");
136
+ const tokenResp = await fetch(`${baseUrl}/token`, {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({ grant_type: "authorization_code", code }),
140
+ });
141
+ const tokenData = await tokenResp.json();
142
+ if (!tokenData.access_token) {
143
+ return {
144
+ content: [{ type: "text", text: `❌ Token 获取失败: ${JSON.stringify(tokenData)}` }],
145
+ };
146
+ }
147
+ (0, auth_js_1.saveTokens)(HOST, {
148
+ access_token: tokenData.access_token,
149
+ refresh_token: tokenData.refresh_token ?? "",
150
+ expires_at: Date.now() + (tokenData.expires_in ?? 3600) * 1000,
151
+ updated_at: new Date().toISOString(),
152
+ });
153
+ cachedToken = tokenData.access_token;
154
+ return {
155
+ content: [{ type: "text", text: "✅ 授权成功!可以开始使用工具了。" }],
156
+ };
157
+ }
158
+ catch (e) {
159
+ return {
160
+ content: [{ type: "text", text: `❌ 授权失败: ${String(e)}` }],
161
+ };
162
+ }
163
+ }
164
+ case "status": {
165
+ const token = getStoredToken();
166
+ return {
167
+ content: [
168
+ {
169
+ type: "text",
170
+ text: JSON.stringify({
171
+ remote_url: REMOTE_MCP_URL,
172
+ authorized: !!token,
173
+ tools_count: remoteTools.length,
174
+ }, null, 2),
175
+ },
176
+ ],
177
+ };
178
+ }
179
+ default: {
180
+ // Check authorization for remote tools
181
+ const token = getStoredToken();
182
+ cachedToken = token;
183
+ if (!cachedToken) {
184
+ const authUrl = generateAuthUrl();
185
+ return {
186
+ content: [
187
+ {
188
+ type: "text",
189
+ text: `⚠️ 未授权。请先调用 "authorize" 工具登录 ScoreHub。\n授权地址: ${authUrl}`,
190
+ },
191
+ ],
192
+ };
193
+ }
194
+ try {
195
+ const result = await callRemoteTool(name, args);
196
+ return { content: [{ type: "text", text: result }] };
197
+ }
198
+ catch (e) {
199
+ return {
200
+ content: [{ type: "text", text: `❌ 工具调用失败: ${String(e)}` }],
201
+ };
202
+ }
203
+ }
204
+ }
205
+ });
206
+ // ── Start ───────────────────────────────────────────────────
207
+ async function main() {
208
+ try {
209
+ remoteTools = await fetchRemoteTools();
210
+ }
211
+ catch {
212
+ console.error("Warning: Could not fetch remote tools on startup. Retrying on first ListTools request.");
213
+ }
214
+ const transport = new stdio_js_1.StdioServerTransport();
215
+ await server.connect(transport);
216
+ console.error(`ScoreHub MCP Server started (remote: ${REMOTE_MCP_URL})`);
217
+ }
218
+ main().catch((err) => {
219
+ console.error("Fatal:", err);
220
+ process.exit(1);
221
+ });
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@scorehub/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "ScoreHub local MCP proxy — OAuth login, token caching, and tool forwarding to remote MCP endpoint",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "scorehub-mcp": "./dist/index.js"
8
+ },
9
+ "files": ["dist"],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsx src/index.ts",
13
+ "start": "node dist/index.js"
14
+ },
15
+ "keywords": ["mcp", "scorehub", "tiktok", "oauth"],
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.29.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.0.0",
22
+ "tsx": "^4.0.0",
23
+ "typescript": "^5.5.0"
24
+ }
25
+ }