@scorehub/mcp-server 0.4.1 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +5 -0
- package/dist/auth.js +8 -3
- package/dist/cli.d.ts +32 -0
- package/dist/cli.js +110 -0
- package/dist/index.js +19 -89
- package/dist/proxy.d.ts +59 -0
- package/dist/proxy.js +155 -0
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -43,6 +43,11 @@ export interface AuthorizeParams {
|
|
|
43
43
|
callbackPath?: string;
|
|
44
44
|
preserveExistingTokensOnFailure?: boolean;
|
|
45
45
|
authorizationUrlFactory?: (request: AuthorizationRequest) => Promise<string>;
|
|
46
|
+
/**
|
|
47
|
+
* CLI 前置认证(preAuth: "cli")模式:把授权 URL 交给调用方打开浏览器,
|
|
48
|
+
* 本进程只保留本地回环回调监听。未传时保持原有自动开浏览器行为。
|
|
49
|
+
*/
|
|
50
|
+
onAuthorizationUrl?: (url: string) => void;
|
|
46
51
|
}
|
|
47
52
|
export interface AuthorizationRequest {
|
|
48
53
|
clientId: string;
|
package/dist/auth.js
CHANGED
|
@@ -285,7 +285,7 @@ function openBrowser(url) {
|
|
|
285
285
|
}
|
|
286
286
|
// ── Full OAuth Flow ──────────────────────────────────────────
|
|
287
287
|
async function authorize(params) {
|
|
288
|
-
const { authEndpoint, tokenEndpoint, clientId, redirectUri, scope, timeoutMs = 120_000, host, preserveExistingTokensOnFailure = false, authorizationUrlFactory, } = params;
|
|
288
|
+
const { authEndpoint, tokenEndpoint, clientId, redirectUri, scope, timeoutMs = 120_000, host, preserveExistingTokensOnFailure = false, authorizationUrlFactory, onAuthorizationUrl, } = params;
|
|
289
289
|
const redirectUrl = redirectUri ? new URL(redirectUri) : null;
|
|
290
290
|
const callbackHost = params.callbackHost ?? redirectUrl?.hostname ?? DEFAULT_CALLBACK_HOST;
|
|
291
291
|
const callbackPath = normalizeCallbackPath(params.callbackPath ?? redirectUrl?.pathname ?? DEFAULT_CALLBACK_PATH);
|
|
@@ -485,8 +485,13 @@ async function authorize(params) {
|
|
|
485
485
|
authUrl.searchParams.set("scope", scope);
|
|
486
486
|
authorizationUrl = authUrl.toString();
|
|
487
487
|
}
|
|
488
|
-
|
|
489
|
-
|
|
488
|
+
if (onAuthorizationUrl) {
|
|
489
|
+
onAuthorizationUrl(authorizationUrl);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
console.error(`[scorehub] Opening authorization URL: ${authorizationUrl}`);
|
|
493
|
+
openBrowser(authorizationUrl);
|
|
494
|
+
}
|
|
490
495
|
timeoutId = setTimeout(() => {
|
|
491
496
|
if (settled)
|
|
492
497
|
return;
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI authentication commands used by connector/cli.json (preAuth: "cli").
|
|
3
|
+
*
|
|
4
|
+
* WorkBuddy runs these before starting the stdio MCP server. The authorization
|
|
5
|
+
* URL is printed to stdout for WorkBuddy to open the browser; this process keeps
|
|
6
|
+
* the 127.0.0.1 callback listener alive until the flow completes.
|
|
7
|
+
*
|
|
8
|
+
* See packages/mcp-server/docs/PREAUTH_CLI.md for the full contract.
|
|
9
|
+
*/
|
|
10
|
+
import { type AuthorizeParams, type AuthorizeResult, type TokenStore } from "./auth.js";
|
|
11
|
+
export interface TokenStorage {
|
|
12
|
+
load(host: string): TokenStore | null;
|
|
13
|
+
clear(host: string): void;
|
|
14
|
+
}
|
|
15
|
+
export interface CliContext {
|
|
16
|
+
tokenStoreKey: string;
|
|
17
|
+
tokenEndpoint: string;
|
|
18
|
+
authorizeUrl: string;
|
|
19
|
+
clientId: string;
|
|
20
|
+
/** Injectable so tests never touch the real ~/.scorehub token file. */
|
|
21
|
+
storage?: TokenStorage;
|
|
22
|
+
authorizeFn?: (params: AuthorizeParams) => Promise<AuthorizeResult>;
|
|
23
|
+
}
|
|
24
|
+
export interface CliIO {
|
|
25
|
+
stdout(text: string): void;
|
|
26
|
+
stderr(text: string): void;
|
|
27
|
+
}
|
|
28
|
+
/** Only these argv forms are CLI commands; everything else keeps the MCP server behaviour. */
|
|
29
|
+
export declare function isCliInvocation(args: string[]): boolean;
|
|
30
|
+
/** Pure, side-effect-free authorization check for the status command. */
|
|
31
|
+
export declare function isAuthorized(tokens: TokenStore | null, now?: number): boolean;
|
|
32
|
+
export declare function runCli(args: string[], ctx: CliContext, io?: CliIO): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isCliInvocation = isCliInvocation;
|
|
4
|
+
exports.isAuthorized = isAuthorized;
|
|
5
|
+
exports.runCli = runCli;
|
|
6
|
+
/**
|
|
7
|
+
* CLI authentication commands used by connector/cli.json (preAuth: "cli").
|
|
8
|
+
*
|
|
9
|
+
* WorkBuddy runs these before starting the stdio MCP server. The authorization
|
|
10
|
+
* URL is printed to stdout for WorkBuddy to open the browser; this process keeps
|
|
11
|
+
* the 127.0.0.1 callback listener alive until the flow completes.
|
|
12
|
+
*
|
|
13
|
+
* See packages/mcp-server/docs/PREAUTH_CLI.md for the full contract.
|
|
14
|
+
*/
|
|
15
|
+
const auth_js_1 = require("./auth.js");
|
|
16
|
+
/** Matches authWaitForExit: WorkBuddy waits up to 5 minutes for the CLI to exit. */
|
|
17
|
+
const AUTH_TIMEOUT_MS = 300_000;
|
|
18
|
+
/** Treat a token expiring within a minute as unauthenticated. */
|
|
19
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
20
|
+
const defaultStorage = { load: auth_js_1.loadTokens, clear: auth_js_1.clearTokens };
|
|
21
|
+
const defaultIO = {
|
|
22
|
+
stdout: (text) => { process.stdout.write(text); },
|
|
23
|
+
stderr: (text) => { process.stderr.write(text); },
|
|
24
|
+
};
|
|
25
|
+
const USAGE = `ScoreHub MCP Server - CLI authentication commands
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
scorehub-mcp auth login Start browser authorization (prints URL, waits for callback)
|
|
29
|
+
scorehub-mcp auth status Check persisted authorization (exit 0 = logged in)
|
|
30
|
+
scorehub-mcp auth status --json Machine-readable status
|
|
31
|
+
scorehub-mcp auth logout Clear persisted authorization
|
|
32
|
+
scorehub-mcp --help Show this help
|
|
33
|
+
|
|
34
|
+
With no recognized command the process starts the stdio MCP server as usual.
|
|
35
|
+
`;
|
|
36
|
+
/** Only these argv forms are CLI commands; everything else keeps the MCP server behaviour. */
|
|
37
|
+
function isCliInvocation(args) {
|
|
38
|
+
const first = args[0];
|
|
39
|
+
return first === "auth" || first === "--help" || first === "-h";
|
|
40
|
+
}
|
|
41
|
+
/** Pure, side-effect-free authorization check for the status command. */
|
|
42
|
+
function isAuthorized(tokens, now = Date.now()) {
|
|
43
|
+
if (!tokens || !tokens.access_token)
|
|
44
|
+
return false;
|
|
45
|
+
return typeof tokens.expires_at === "number" && tokens.expires_at > now + EXPIRY_SKEW_MS;
|
|
46
|
+
}
|
|
47
|
+
async function runCli(args, ctx, io = defaultIO) {
|
|
48
|
+
const [group, sub, ...rest] = args;
|
|
49
|
+
if (group === "--help" || group === "-h" || sub === "--help" || sub === "-h") {
|
|
50
|
+
io.stdout(USAGE);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
const storage = ctx.storage ?? defaultStorage;
|
|
54
|
+
switch (sub) {
|
|
55
|
+
case "login": {
|
|
56
|
+
const authorizeFn = ctx.authorizeFn ?? auth_js_1.authorize;
|
|
57
|
+
try {
|
|
58
|
+
const result = await authorizeFn({
|
|
59
|
+
authEndpoint: ctx.authorizeUrl,
|
|
60
|
+
tokenEndpoint: ctx.tokenEndpoint,
|
|
61
|
+
clientId: ctx.clientId,
|
|
62
|
+
host: ctx.tokenStoreKey,
|
|
63
|
+
port: 0,
|
|
64
|
+
timeoutMs: AUTH_TIMEOUT_MS,
|
|
65
|
+
// WorkBuddy extracts this URL from stdout and opens the browser itself.
|
|
66
|
+
// Must be a bare URL line: no quotes, no angle brackets, whitespace-delimited.
|
|
67
|
+
onAuthorizationUrl: (url) => { io.stdout(`${url}\n`); },
|
|
68
|
+
});
|
|
69
|
+
io.stderr(`[scorehub] Authorization complete\n${(0, auth_js_1.formatAuthorizationDetails)(result.account, result.selectedShop)}\n`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
io.stderr(`[scorehub] Authorization failed: ${message}\n`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
case "status": {
|
|
79
|
+
// Deliberately read-only: never call getValidAccessToken (it refreshes tokens).
|
|
80
|
+
const tokens = storage.load(ctx.tokenStoreKey);
|
|
81
|
+
const ok = isAuthorized(tokens);
|
|
82
|
+
if (rest.includes("--json")) {
|
|
83
|
+
io.stdout(`${JSON.stringify({
|
|
84
|
+
authenticated: ok,
|
|
85
|
+
account: tokens?.account?.phone ?? null,
|
|
86
|
+
shop: tokens?.selected_shop?.name ?? null,
|
|
87
|
+
expires_at: tokens?.expires_at ?? null,
|
|
88
|
+
})}\n`);
|
|
89
|
+
}
|
|
90
|
+
else if (ok && tokens) {
|
|
91
|
+
io.stdout(`Logged in as ${tokens.account?.phone ?? "ScoreHub account"} ` +
|
|
92
|
+
`(shop: ${tokens.selected_shop?.name ?? "unknown"})\n`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
io.stdout("Not logged in\n");
|
|
96
|
+
}
|
|
97
|
+
return ok ? 0 : 1;
|
|
98
|
+
}
|
|
99
|
+
case "logout": {
|
|
100
|
+
storage.clear(ctx.tokenStoreKey);
|
|
101
|
+
io.stdout("Logged out\n");
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
default: {
|
|
105
|
+
io.stderr(`[scorehub] Unknown auth command: ${sub ?? "(none)"}\n`);
|
|
106
|
+
io.stdout(USAGE);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -14,11 +14,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
14
14
|
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
15
15
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
16
16
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
17
|
-
const index_js_2 = require("@modelcontextprotocol/sdk/client/index.js");
|
|
18
|
-
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
19
17
|
const node_fs_1 = require("node:fs");
|
|
20
18
|
const node_path_1 = require("node:path");
|
|
21
19
|
const self_check_js_1 = require("./self-check.js");
|
|
20
|
+
const proxy_js_1 = require("./proxy.js");
|
|
22
21
|
function readPackageMetadata() {
|
|
23
22
|
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "../package.json"), "utf-8"));
|
|
24
23
|
}
|
|
@@ -47,72 +46,16 @@ function endpointTokenKey(baseUrl) {
|
|
|
47
46
|
// ── Auth State ──────────────────────────────────────────────
|
|
48
47
|
const auth_js_1 = require("./auth.js");
|
|
49
48
|
const status_js_1 = require("./status.js");
|
|
49
|
+
const cli_js_1 = require("./cli.js");
|
|
50
50
|
const TOKEN_STORE_KEY = endpointTokenKey(SCOREHUB_BASE_URL);
|
|
51
51
|
const CLIENT_ID = "scorehub-mcp";
|
|
52
52
|
// Proactively refresh token every 4 hours to prevent expiry during long sessions
|
|
53
53
|
const TOKEN_KEEPALIVE_INTERVAL = 4 * 60 * 60 * 1000;
|
|
54
|
-
function isRemoteToolErrorType(value) {
|
|
55
|
-
return value === "oauth_invalid"
|
|
56
|
-
|| value === "shop_auth_invalid"
|
|
57
|
-
|| value === "rate_limited"
|
|
58
|
-
|| value === "quota_exhausted"
|
|
59
|
-
|| value === "invalid_input"
|
|
60
|
-
|| value === "permission_denied"
|
|
61
|
-
|| value === "upstream_error";
|
|
62
|
-
}
|
|
63
|
-
function parseRemoteToolEnvelope(text) {
|
|
64
|
-
try {
|
|
65
|
-
const parsed = JSON.parse(text);
|
|
66
|
-
if (parsed.ok === true && Object.prototype.hasOwnProperty.call(parsed, "data")) {
|
|
67
|
-
return { ok: true, data: parsed.data };
|
|
68
|
-
}
|
|
69
|
-
if (parsed.ok === false
|
|
70
|
-
&& isRemoteToolErrorType(parsed.error_type)
|
|
71
|
-
&& typeof parsed.user_message === "string"
|
|
72
|
-
&& typeof parsed.retryable === "boolean") {
|
|
73
|
-
return {
|
|
74
|
-
ok: false,
|
|
75
|
-
error_type: parsed.error_type,
|
|
76
|
-
user_message: parsed.user_message,
|
|
77
|
-
retryable: parsed.retryable,
|
|
78
|
-
...(Object.prototype.hasOwnProperty.call(parsed, "upstream") ? { upstream: parsed.upstream } : {}),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
function extractRemoteTextContent(content) {
|
|
88
|
-
if (!Array.isArray(content)) {
|
|
89
|
-
return [];
|
|
90
|
-
}
|
|
91
|
-
return content.flatMap((item) => {
|
|
92
|
-
if (item
|
|
93
|
-
&& typeof item === "object"
|
|
94
|
-
&& item.type === "text"
|
|
95
|
-
&& typeof item.text === "string") {
|
|
96
|
-
return [item.text];
|
|
97
|
-
}
|
|
98
|
-
return [];
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
54
|
// ── Remote Tool Proxy (MCP Streamable HTTP) ─────────────────
|
|
55
|
+
const remoteProxy = new proxy_js_1.RemoteToolProxy(new URL(REMOTE_MCP_URL), SERVER_VERSION);
|
|
102
56
|
async function fetchRemoteTools(token) {
|
|
103
57
|
try {
|
|
104
|
-
|
|
105
|
-
requestInit: token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
|
|
106
|
-
});
|
|
107
|
-
const client = new index_js_2.Client({ name: "scorehub-mcp", version: SERVER_VERSION }, { capabilities: {} });
|
|
108
|
-
try {
|
|
109
|
-
await client.connect(transport);
|
|
110
|
-
const result = await client.listTools();
|
|
111
|
-
return result.tools;
|
|
112
|
-
}
|
|
113
|
-
finally {
|
|
114
|
-
await client.close();
|
|
115
|
-
}
|
|
58
|
+
return await remoteProxy.listTools(token);
|
|
116
59
|
}
|
|
117
60
|
catch (e) {
|
|
118
61
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -121,27 +64,8 @@ async function fetchRemoteTools(token) {
|
|
|
121
64
|
return [];
|
|
122
65
|
}
|
|
123
66
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
|
127
|
-
});
|
|
128
|
-
const client = new index_js_2.Client({ name: "scorehub-mcp", version: SERVER_VERSION }, { capabilities: {} });
|
|
129
|
-
try {
|
|
130
|
-
await client.connect(transport);
|
|
131
|
-
const result = await client.callTool({ name, arguments: args });
|
|
132
|
-
const textBlocks = extractRemoteTextContent(result.content);
|
|
133
|
-
const rawText = textBlocks.length > 0
|
|
134
|
-
? textBlocks.join("\n\n")
|
|
135
|
-
: JSON.stringify(result.content, null, 2);
|
|
136
|
-
const envelope = textBlocks
|
|
137
|
-
.map((text) => parseRemoteToolEnvelope(text))
|
|
138
|
-
.find((candidate) => candidate !== null)
|
|
139
|
-
?? parseRemoteToolEnvelope(rawText);
|
|
140
|
-
return { rawText, envelope };
|
|
141
|
-
}
|
|
142
|
-
finally {
|
|
143
|
-
await client.close();
|
|
144
|
-
}
|
|
67
|
+
function callRemoteTool(name, args, token) {
|
|
68
|
+
return remoteProxy.callTool(name, args, token);
|
|
145
69
|
}
|
|
146
70
|
async function startShopSwitch(accessToken, request) {
|
|
147
71
|
const response = await fetch(SHOP_SWITCH_ENDPOINT, {
|
|
@@ -182,7 +106,7 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
|
182
106
|
}
|
|
183
107
|
}
|
|
184
108
|
catch { /* keep stale tools and lastError */ }
|
|
185
|
-
const
|
|
109
|
+
const localTools = [
|
|
186
110
|
{
|
|
187
111
|
name: "authorize",
|
|
188
112
|
description: "检查 ScoreHub OAuth 授权。force=true 重新登录以切换账号;switch_shop=true 打开当前账号的店铺选择页,无需重新登录。",
|
|
@@ -210,13 +134,8 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
|
210
134
|
required: [],
|
|
211
135
|
},
|
|
212
136
|
},
|
|
213
|
-
...remoteTools.map((t) => ({
|
|
214
|
-
name: t.name,
|
|
215
|
-
description: t.description,
|
|
216
|
-
inputSchema: t.inputSchema,
|
|
217
|
-
})),
|
|
218
137
|
];
|
|
219
|
-
return { tools };
|
|
138
|
+
return { tools: (0, proxy_js_1.mergeRemoteTools)(localTools, remoteTools) };
|
|
220
139
|
});
|
|
221
140
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
222
141
|
const { name, arguments: args = {} } = request.params;
|
|
@@ -396,6 +315,17 @@ async function main() {
|
|
|
396
315
|
process.exitCode = 1;
|
|
397
316
|
return;
|
|
398
317
|
}
|
|
318
|
+
// CLI 前置认证命令(cli.json 的 auth / status / unAuth)。未命中时仍是原 stdio MCP 服务。
|
|
319
|
+
const cliArgs = process.argv.slice(2);
|
|
320
|
+
if ((0, cli_js_1.isCliInvocation)(cliArgs)) {
|
|
321
|
+
process.exitCode = await (0, cli_js_1.runCli)(cliArgs, {
|
|
322
|
+
tokenStoreKey: TOKEN_STORE_KEY,
|
|
323
|
+
tokenEndpoint: TOKEN_ENDPOINT,
|
|
324
|
+
authorizeUrl: AUTHORIZE_URL,
|
|
325
|
+
clientId: CLIENT_ID,
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
399
329
|
// Set process title for easy identification in ps/task manager
|
|
400
330
|
process.title = 'scorehub-mcp';
|
|
401
331
|
// Gracefully exit when parent AI agent closes stdin (normal shutdown or crash).
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface RemoteToolDescriptor {
|
|
2
|
+
name: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
inputSchema: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export type RemoteToolErrorType = "oauth_invalid" | "shop_auth_invalid" | "rate_limited" | "quota_exhausted" | "invalid_input" | "permission_denied" | "upstream_error";
|
|
7
|
+
export type RemoteToolEnvelope = {
|
|
8
|
+
ok: true;
|
|
9
|
+
data: unknown;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
error_type: RemoteToolErrorType;
|
|
13
|
+
user_message: string;
|
|
14
|
+
retryable: boolean;
|
|
15
|
+
upstream?: unknown;
|
|
16
|
+
};
|
|
17
|
+
export interface RemoteToolCallResult {
|
|
18
|
+
rawText: string;
|
|
19
|
+
envelope: RemoteToolEnvelope | null;
|
|
20
|
+
}
|
|
21
|
+
export interface RemoteClientLike {
|
|
22
|
+
connect(): Promise<void>;
|
|
23
|
+
listTools(): Promise<{
|
|
24
|
+
tools: RemoteToolDescriptor[];
|
|
25
|
+
}>;
|
|
26
|
+
callTool(request: {
|
|
27
|
+
name: string;
|
|
28
|
+
arguments: Record<string, unknown>;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
content: unknown;
|
|
31
|
+
}>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export type RemoteClientFactory = (endpoint: URL, token: string | undefined, serverVersion: string) => RemoteClientLike;
|
|
35
|
+
/** Parse the public QueryData/error envelope without interpreting business data. */
|
|
36
|
+
export declare function parseRemoteToolEnvelope(text: string): RemoteToolEnvelope | null;
|
|
37
|
+
/** Extract text blocks in remote order; non-text blocks are left untouched. */
|
|
38
|
+
export declare function extractRemoteTextContent(content: unknown): string[];
|
|
39
|
+
/** Build the same raw text and envelope the proxy returns to the MCP client. */
|
|
40
|
+
export declare function buildRemoteToolCallResult(content: unknown): RemoteToolCallResult;
|
|
41
|
+
/**
|
|
42
|
+
* Merge local tools with the remote list without filtering, renaming or
|
|
43
|
+
* rewriting schemas. Remote order is preserved after the two local tools.
|
|
44
|
+
*/
|
|
45
|
+
export declare function mergeRemoteTools(localTools: readonly RemoteToolDescriptor[], remoteTools: readonly RemoteToolDescriptor[]): RemoteToolDescriptor[];
|
|
46
|
+
/** Default SDK-backed client factory. Tests inject a fake factory instead. */
|
|
47
|
+
export declare function createSdkRemoteClient(endpoint: URL, token: string | undefined, serverVersion: string): RemoteClientLike;
|
|
48
|
+
/**
|
|
49
|
+
* One short-lived remote client per list/call, matching the existing proxy
|
|
50
|
+
* lifecycle. The factory seam is the only test-specific difference.
|
|
51
|
+
*/
|
|
52
|
+
export declare class RemoteToolProxy {
|
|
53
|
+
private readonly endpoint;
|
|
54
|
+
private readonly serverVersion;
|
|
55
|
+
private readonly factory;
|
|
56
|
+
constructor(endpoint: URL, serverVersion: string, factory?: RemoteClientFactory);
|
|
57
|
+
listTools(token?: string): Promise<RemoteToolDescriptor[]>;
|
|
58
|
+
callTool(name: string, args: Record<string, unknown>, token: string): Promise<RemoteToolCallResult>;
|
|
59
|
+
}
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RemoteToolProxy = void 0;
|
|
4
|
+
exports.parseRemoteToolEnvelope = parseRemoteToolEnvelope;
|
|
5
|
+
exports.extractRemoteTextContent = extractRemoteTextContent;
|
|
6
|
+
exports.buildRemoteToolCallResult = buildRemoteToolCallResult;
|
|
7
|
+
exports.mergeRemoteTools = mergeRemoteTools;
|
|
8
|
+
exports.createSdkRemoteClient = createSdkRemoteClient;
|
|
9
|
+
/**
|
|
10
|
+
* mcp-server remote-tool proxy.
|
|
11
|
+
*
|
|
12
|
+
* The local MCP server is intentionally a transparent proxy: it discovers every
|
|
13
|
+
* tool from the remote /mcp endpoint and forwards calls without a local
|
|
14
|
+
* allowlist. This module keeps that behavior testable without starting stdio,
|
|
15
|
+
* connecting to the network, or touching OAuth state.
|
|
16
|
+
*/
|
|
17
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
|
|
18
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
19
|
+
function isRemoteToolErrorType(value) {
|
|
20
|
+
return value === "oauth_invalid"
|
|
21
|
+
|| value === "shop_auth_invalid"
|
|
22
|
+
|| value === "rate_limited"
|
|
23
|
+
|| value === "quota_exhausted"
|
|
24
|
+
|| value === "invalid_input"
|
|
25
|
+
|| value === "permission_denied"
|
|
26
|
+
|| value === "upstream_error";
|
|
27
|
+
}
|
|
28
|
+
/** Parse the public QueryData/error envelope without interpreting business data. */
|
|
29
|
+
function parseRemoteToolEnvelope(text) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(text);
|
|
32
|
+
if (parsed.ok === true && Object.prototype.hasOwnProperty.call(parsed, "data")) {
|
|
33
|
+
return { ok: true, data: parsed.data };
|
|
34
|
+
}
|
|
35
|
+
if (parsed.ok === false
|
|
36
|
+
&& isRemoteToolErrorType(parsed.error_type)
|
|
37
|
+
&& typeof parsed.user_message === "string"
|
|
38
|
+
&& typeof parsed.retryable === "boolean") {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
error_type: parsed.error_type,
|
|
42
|
+
user_message: parsed.user_message,
|
|
43
|
+
retryable: parsed.retryable,
|
|
44
|
+
...(Object.prototype.hasOwnProperty.call(parsed, "upstream") ? { upstream: parsed.upstream } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Extract text blocks in remote order; non-text blocks are left untouched. */
|
|
54
|
+
function extractRemoteTextContent(content) {
|
|
55
|
+
if (!Array.isArray(content))
|
|
56
|
+
return [];
|
|
57
|
+
return content.flatMap((item) => {
|
|
58
|
+
if (item
|
|
59
|
+
&& typeof item === "object"
|
|
60
|
+
&& item.type === "text"
|
|
61
|
+
&& typeof item.text === "string") {
|
|
62
|
+
return [item.text];
|
|
63
|
+
}
|
|
64
|
+
return [];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Build the same raw text and envelope the proxy returns to the MCP client. */
|
|
68
|
+
function buildRemoteToolCallResult(content) {
|
|
69
|
+
const textBlocks = extractRemoteTextContent(content);
|
|
70
|
+
const rawText = textBlocks.length > 0
|
|
71
|
+
? textBlocks.join("\n\n")
|
|
72
|
+
: JSON.stringify(content, null, 2);
|
|
73
|
+
const envelope = textBlocks
|
|
74
|
+
.map((text) => parseRemoteToolEnvelope(text))
|
|
75
|
+
.find((candidate) => candidate !== null)
|
|
76
|
+
?? parseRemoteToolEnvelope(rawText);
|
|
77
|
+
return { rawText, envelope };
|
|
78
|
+
}
|
|
79
|
+
function toDescriptor(tool) {
|
|
80
|
+
return {
|
|
81
|
+
name: tool.name,
|
|
82
|
+
...(tool.description !== undefined ? { description: tool.description } : {}),
|
|
83
|
+
inputSchema: tool.inputSchema,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Merge local tools with the remote list without filtering, renaming or
|
|
88
|
+
* rewriting schemas. Remote order is preserved after the two local tools.
|
|
89
|
+
*/
|
|
90
|
+
function mergeRemoteTools(localTools, remoteTools) {
|
|
91
|
+
return [
|
|
92
|
+
...localTools.map((tool) => ({ ...tool })),
|
|
93
|
+
...remoteTools.map((tool) => ({ ...tool })),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
/** Default SDK-backed client factory. Tests inject a fake factory instead. */
|
|
97
|
+
function createSdkRemoteClient(endpoint, token, serverVersion) {
|
|
98
|
+
const transport = new streamableHttp_js_1.StreamableHTTPClientTransport(endpoint, {
|
|
99
|
+
requestInit: token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
|
|
100
|
+
});
|
|
101
|
+
const client = new index_js_1.Client({ name: "scorehub-mcp", version: serverVersion }, { capabilities: {} });
|
|
102
|
+
return {
|
|
103
|
+
connect: () => client.connect(transport),
|
|
104
|
+
listTools: async () => {
|
|
105
|
+
const result = await client.listTools();
|
|
106
|
+
return { tools: result.tools.map(toDescriptor) };
|
|
107
|
+
},
|
|
108
|
+
callTool: async (request) => {
|
|
109
|
+
const result = await client.callTool(request);
|
|
110
|
+
return { content: "content" in result ? result.content : result };
|
|
111
|
+
},
|
|
112
|
+
close: () => client.close(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* One short-lived remote client per list/call, matching the existing proxy
|
|
117
|
+
* lifecycle. The factory seam is the only test-specific difference.
|
|
118
|
+
*/
|
|
119
|
+
class RemoteToolProxy {
|
|
120
|
+
endpoint;
|
|
121
|
+
serverVersion;
|
|
122
|
+
factory;
|
|
123
|
+
constructor(endpoint, serverVersion, factory = createSdkRemoteClient) {
|
|
124
|
+
this.endpoint = endpoint;
|
|
125
|
+
this.serverVersion = serverVersion;
|
|
126
|
+
this.factory = factory;
|
|
127
|
+
}
|
|
128
|
+
async listTools(token) {
|
|
129
|
+
const client = this.factory(this.endpoint, token, this.serverVersion);
|
|
130
|
+
try {
|
|
131
|
+
await client.connect();
|
|
132
|
+
const result = await client.listTools();
|
|
133
|
+
return result.tools.map((tool) => ({
|
|
134
|
+
name: tool.name,
|
|
135
|
+
...(tool.description !== undefined ? { description: tool.description } : {}),
|
|
136
|
+
inputSchema: tool.inputSchema,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await client.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async callTool(name, args, token) {
|
|
144
|
+
const client = this.factory(this.endpoint, token, this.serverVersion);
|
|
145
|
+
try {
|
|
146
|
+
await client.connect();
|
|
147
|
+
const result = await client.callTool({ name, arguments: args });
|
|
148
|
+
return buildRemoteToolCallResult(result.content);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await client.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
exports.RemoteToolProxy = RemoteToolProxy;
|