@layr-labs/benchmaxx-arena-mcp 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/errors.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Error types and the mapping from an API failure to one actionable message.
3
+ *
4
+ * Two failure kinds reach a tool:
5
+ * - `ApiError`: a non-2xx HTTP response. It carries the status and the
6
+ * server's `rpcStatus.message` (mirrors frontend/src/api/http.ts).
7
+ * - `NetworkError`: the fetch itself rejected (DNS, connection refused, TLS).
8
+ *
9
+ * `toActionableMessage` turns either into a single message per issue #166 §5.4.
10
+ * It never includes a stack trace, a raw response body, or the bearer token.
11
+ */
12
+ // credentialStore.ts owns CredentialsFileError and imports nothing from here, so
13
+ // this dependency is one-way and introduces no cycle.
14
+ import { CredentialsFileError } from './credentialStore.js';
15
+ /** A failed HTTP call. `message` is the server's message; `status` is the code. */
16
+ export class ApiError extends Error {
17
+ status;
18
+ constructor(status, message) {
19
+ super(message);
20
+ this.name = 'ApiError';
21
+ this.status = status;
22
+ }
23
+ }
24
+ /** The fetch rejected before any HTTP status was seen. */
25
+ export class NetworkError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = 'NetworkError';
29
+ }
30
+ }
31
+ /**
32
+ * Raised when the caller cancels a login.
33
+ *
34
+ * This lives here rather than in the flow that throws it, so both the client and
35
+ * the loopback listener raise the one type `toActionableMessage` recognizes. A
36
+ * plain `Error` would fall through to the generic copy and lose the friendly
37
+ * message.
38
+ */
39
+ export class LoginCancelled extends Error {
40
+ constructor() {
41
+ super('the login was cancelled.');
42
+ this.name = 'LoginCancelled';
43
+ }
44
+ }
45
+ /** Raised when the loopback listener's deadline passes before a code arrives. */
46
+ export class LoginTimedOut extends Error {
47
+ constructor() {
48
+ super('the login timed out before the browser returned a code.');
49
+ this.name = 'LoginTimedOut';
50
+ }
51
+ }
52
+ /**
53
+ * Raised when POST /v1/auth/cli/redeem rejects the code or the verifier.
54
+ *
55
+ * A redeem 401 is NOT a revoked stored key — no stored key is involved — so it
56
+ * must not reach the generic 401 mapping, which would tell the user to log in
57
+ * again with no hint that their code is simply spent.
58
+ */
59
+ export class CliRedeemFailed extends Error {
60
+ constructor() {
61
+ super('the login code was not accepted.');
62
+ this.name = 'CliRedeemFailed';
63
+ }
64
+ }
65
+ const CONCURRENCY_PER_USER = 'per-user concurrency cap reached — you already have the maximum number of runs in flight. Wait for a run to finish, then try again.';
66
+ const CONCURRENCY_BENCHMARK = 'benchmark-wide concurrency cap reached — the benchmark is at its global run limit right now. Try again shortly.';
67
+ const SPEND_CAP = 'monthly spend cap reached — your rolling 30-day spend is at its limit. Spend frees up as the window rolls forward.';
68
+ /**
69
+ * Detect a caps rejection from the server message and name the cap. Returns
70
+ * undefined when the message is not a caps rejection.
71
+ *
72
+ * Matches only explicit spend vocabulary. An earlier version also treated the
73
+ * bare token `30` as spend (for "rolling 30-day"), which misfired on any message
74
+ * carrying an unrelated number containing 30 — a prompt-size rejection quoting a
75
+ * byte count, for instance, was reported as a spend cap. A digit is not
76
+ * evidence of a spend cap.
77
+ */
78
+ function capMessage(serverMessage) {
79
+ const m = serverMessage.toLowerCase();
80
+ const mentionsCap = m.includes('cap') || m.includes('limit') || m.includes('exceed');
81
+ if (!mentionsCap) {
82
+ return undefined;
83
+ }
84
+ if (m.includes('spend') ||
85
+ m.includes('budget') ||
86
+ m.includes('rolling 30') ||
87
+ m.includes('30-day') ||
88
+ m.includes('30 day')) {
89
+ return SPEND_CAP;
90
+ }
91
+ if (m.includes('per-user') || m.includes('per user') || m.includes('your')) {
92
+ return CONCURRENCY_PER_USER;
93
+ }
94
+ if (m.includes('concurren') || m.includes('in flight') || m.includes('run')) {
95
+ return CONCURRENCY_BENCHMARK;
96
+ }
97
+ return undefined;
98
+ }
99
+ /**
100
+ * Detect a model-not-allowed rejection. The benchmark accepts a fixed set of
101
+ * models; a prompt submission with any other model is rejected.
102
+ */
103
+ function modelNotAllowed(serverMessage) {
104
+ const m = serverMessage.toLowerCase();
105
+ return (m.includes('model') &&
106
+ (m.includes('allow') || m.includes('not permitted') || m.includes('not accepted')));
107
+ }
108
+ /**
109
+ * Map any thrown failure to one actionable message. Leaks no internals: an
110
+ * unknown error becomes a generic message, never a raw string or stack.
111
+ */
112
+ export function toActionableMessage(error) {
113
+ if (error instanceof NetworkError) {
114
+ return `could not reach the arena — check ${'BENCHMAXX_ARENA_URL'} and try again.`;
115
+ }
116
+ // A cancelled login is not a failure to diagnose.
117
+ if (error instanceof LoginCancelled) {
118
+ return 'the login was cancelled — run the login tool again when you are ready.';
119
+ }
120
+ if (error instanceof LoginTimedOut) {
121
+ return 'the login timed out waiting for the browser — run the login tool again, and approve the page it opens.';
122
+ }
123
+ // The code, not a stored key, is what the server rejected.
124
+ if (error instanceof CliRedeemFailed) {
125
+ return 'that code is not valid any more — codes are single-use and expire after five minutes. Run the login tool again.';
126
+ }
127
+ // The credential file is broken or unsafe. Its message already names the file
128
+ // and says to fix or delete it, and it contains no secret — surfacing it is
129
+ // the whole point, since the user cannot act on "an unexpected error".
130
+ if (error instanceof CredentialsFileError) {
131
+ return error.message;
132
+ }
133
+ if (error instanceof ApiError) {
134
+ const server = error.message ?? '';
135
+ switch (error.status) {
136
+ case 401:
137
+ return 'key revoked or invalid — run the login tool first.';
138
+ case 404:
139
+ return 'resource not found.';
140
+ default: {
141
+ // Both heuristics read the server's prose, and a 5xx's prose is about a
142
+ // server fault, not the caller's input. Applying them to a 500 told the
143
+ // user to change their model or wait out a cap when the real cause was
144
+ // an arena failure they cannot act on. Gate on 4xx: only a client error
145
+ // is the caller's to fix.
146
+ if (error.status >= 400 && error.status < 500) {
147
+ if (modelNotAllowed(server)) {
148
+ return "that model is not on the benchmark's allowed list — call list_benchmarks or get_benchmark to see the allowed models for this variant.";
149
+ }
150
+ const cap = capMessage(server);
151
+ if (cap) {
152
+ return cap;
153
+ }
154
+ // A 4xx with a server message is safe to surface: it is the server's
155
+ // own human-readable text, not an internal detail.
156
+ if (server) {
157
+ return server;
158
+ }
159
+ }
160
+ return 'the request failed — try again, and if it persists the arena may be having trouble.';
161
+ }
162
+ }
163
+ }
164
+ // Unknown error kind: never surface its content.
165
+ return 'an unexpected error occurred.';
166
+ }
167
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point for the benchmaxx-arena MCP server.
4
+ *
5
+ * Wires the config, the credential store, the API client, and the tools, then
6
+ * connects the stdio transport. stdout carries the MCP protocol only; operator
7
+ * text (the login instructions, including the authorize URL when no browser can
8
+ * be opened) goes to stderr through the client's operator sink.
9
+ */
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ export declare function buildServer(): McpServer;
package/dist/index.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point for the benchmaxx-arena MCP server.
4
+ *
5
+ * Wires the config, the credential store, the API client, and the tools, then
6
+ * connects the stdio transport. stdout carries the MCP protocol only; operator
7
+ * text (the login instructions, including the authorize URL when no browser can
8
+ * be opened) goes to stderr through the client's operator sink.
9
+ */
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { ArenaClient } from './client.js';
16
+ import { loadConfig } from './config.js';
17
+ import { CredentialStore } from './credentialStore.js';
18
+ import { registerTools } from './tools.js';
19
+ const VERSION = '0.1.0';
20
+ // The global fetch returns a Response, which structurally satisfies the
21
+ // client's FetchResponse (ok, status, text, body). Adapt the init shape.
22
+ const nodeFetch = (input, init) => fetch(input, init);
23
+ export function buildServer() {
24
+ const config = loadConfig();
25
+ const store = new CredentialStore({
26
+ fs: { mkdir, chmod, readFile, writeFile, rename, rm, lstat },
27
+ homeDir: homedir(),
28
+ join,
29
+ });
30
+ const client = new ArenaClient({
31
+ origin: config.origin,
32
+ fetch: nodeFetch,
33
+ store,
34
+ operatorLog: (line) => process.stderr.write(`${line}\n`),
35
+ });
36
+ const server = new McpServer({ name: 'benchmaxx-arena', version: VERSION });
37
+ registerTools(server, client);
38
+ return server;
39
+ }
40
+ async function main() {
41
+ const server = buildServer();
42
+ const transport = new StdioServerTransport();
43
+ await server.connect(transport);
44
+ }
45
+ main().catch((error) => {
46
+ process.stderr.write(`fatal: ${error instanceof Error ? error.message : String(error)}\n`);
47
+ process.exit(1);
48
+ });
49
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;GAOG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AACnE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAA;AAChF,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACvF,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AACjC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,WAAW,EAAkB,MAAM,aAAa,CAAA;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE1C,MAAM,OAAO,GAAG,OAAO,CAAA;AAEvB,wEAAwE;AACxE,yEAAyE;AACzE,MAAM,SAAS,GAAc,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAC3C,KAAK,CAAC,KAAK,EAAE,IAAmB,CAAqC,CAAA;AAEvE,MAAM,UAAU,WAAW;IACzB,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;IAE3B,MAAM,KAAK,GAAG,IAAI,eAAe,CAAC;QAChC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE;QAC5D,OAAO,EAAE,OAAO,EAAE;QAClB,IAAI;KACL,CAAC,CAAA;IAEF,MAAM,MAAM,GAAG,IAAI,WAAW,CAAC;QAC7B,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAE,SAAS;QAChB,KAAK;QACL,WAAW,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC;KACzD,CAAC,CAAA;IAEF,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAA;IAC3E,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,OAAO,MAAM,CAAA;AACf,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,WAAW,EAAE,CAAA;IAC5B,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAA;IAC5C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;AACjC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;IACrB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IAC1F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CAAC,CAAA"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The single-shot loopback listener that receives the authorization code.
3
+ *
4
+ * The server binds 127.0.0.1 ONLY, never 0.0.0.0: the callback must be
5
+ * unreachable from the network. It serves the one callback request and then
6
+ * stops. The state nonce ties the callback to the flow that started it, and the
7
+ * comparison is constant-time, so a wrong state cannot be probed byte by byte.
8
+ *
9
+ * The code arrives in a query parameter, so nothing here logs the request line
10
+ * or the URL.
11
+ */
12
+ /** How long the listener waits for the browser by default: the code's own TTL. */
13
+ export declare const DEFAULT_LOOPBACK_TIMEOUT_MS: number;
14
+ export interface WaitOptions {
15
+ signal?: AbortSignal;
16
+ timeoutMs?: number;
17
+ }
18
+ export interface LoopbackListener {
19
+ /** The bound ephemeral port. */
20
+ port: number;
21
+ /** The full callback URL to hand to the frontend. */
22
+ callbackUrl: string;
23
+ /** Resolve with the code once a callback carrying the matching state arrives. */
24
+ waitForCode(state: string, options?: WaitOptions): Promise<string>;
25
+ /** Stop listening. Safe to call more than once. */
26
+ close(): Promise<void>;
27
+ }
28
+ /** Bind an ephemeral loopback port and return the listener handle. */
29
+ export declare function startLoopbackListener(): Promise<LoopbackListener>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The single-shot loopback listener that receives the authorization code.
3
+ *
4
+ * The server binds 127.0.0.1 ONLY, never 0.0.0.0: the callback must be
5
+ * unreachable from the network. It serves the one callback request and then
6
+ * stops. The state nonce ties the callback to the flow that started it, and the
7
+ * comparison is constant-time, so a wrong state cannot be probed byte by byte.
8
+ *
9
+ * The code arrives in a query parameter, so nothing here logs the request line
10
+ * or the URL.
11
+ */
12
+ import { createServer } from 'node:http';
13
+ import { timingSafeEqual } from 'node:crypto';
14
+ // errors.ts imports only credentialStore.js, so this edge is one-way: the
15
+ // listener's rejections carry the types toActionableMessage recognizes, instead
16
+ // of plain Errors that would fall through to its generic copy.
17
+ import { LoginCancelled, LoginTimedOut } from './errors.js';
18
+ /** How long the listener waits for the browser by default: the code's own TTL. */
19
+ export const DEFAULT_LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
20
+ const CALLBACK_PATH = '/cb';
21
+ const SUCCESS_PAGE = `<!doctype html>
22
+ <html lang="en"><head><meta charset="utf-8"><title>Signed in</title></head>
23
+ <body style="font-family:system-ui;padding:2rem">
24
+ <h1>You are signed in</h1>
25
+ <p>Close this tab and return to your terminal.</p>
26
+ </body></html>
27
+ `;
28
+ /** Constant-time string compare that tolerates unequal lengths. */
29
+ function safeEqual(a, b) {
30
+ const bufA = Buffer.from(a);
31
+ const bufB = Buffer.from(b);
32
+ if (bufA.length !== bufB.length) {
33
+ return false;
34
+ }
35
+ return timingSafeEqual(bufA, bufB);
36
+ }
37
+ function respond(res, status, body, contentType) {
38
+ res.writeHead(status, { 'Content-Type': contentType, 'Cache-Control': 'no-store' });
39
+ res.end(body);
40
+ }
41
+ /** Bind an ephemeral loopback port and return the listener handle. */
42
+ export async function startLoopbackListener() {
43
+ let onCallback;
44
+ let onFailure;
45
+ let expectedState;
46
+ const server = createServer((req, res) => {
47
+ // Parse against a fixed base: req.url is a path, not an absolute URL.
48
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
49
+ if (url.pathname !== CALLBACK_PATH) {
50
+ respond(res, 404, 'not found', 'text/plain; charset=utf-8');
51
+ return;
52
+ }
53
+ const code = url.searchParams.get('code');
54
+ const state = url.searchParams.get('state');
55
+ if (!code || !state || !expectedState || !safeEqual(state, expectedState)) {
56
+ respond(res, 400, 'this callback does not match a login in progress', 'text/plain; charset=utf-8');
57
+ return;
58
+ }
59
+ respond(res, 200, SUCCESS_PAGE, 'text/html; charset=utf-8');
60
+ onCallback?.(code);
61
+ });
62
+ await new Promise((resolve, reject) => {
63
+ server.once('error', reject);
64
+ // 127.0.0.1 only. Binding 0.0.0.0 would expose the callback to the network.
65
+ server.listen(0, '127.0.0.1', () => {
66
+ server.removeListener('error', reject);
67
+ resolve();
68
+ });
69
+ });
70
+ const address = server.address();
71
+ const port = address.port;
72
+ let closed = false;
73
+ const close = async () => {
74
+ if (closed)
75
+ return;
76
+ closed = true;
77
+ await new Promise((resolve) => server.close(() => resolve()));
78
+ // A closed listener can never deliver a code, so an in-flight wait is
79
+ // failed here rather than left pending until its timeout.
80
+ const fail = onFailure;
81
+ onFailure = undefined;
82
+ onCallback = undefined;
83
+ fail?.(new Error('the login listener closed before the browser returned a code.'));
84
+ };
85
+ const waitForCode = (state, options = {}) => {
86
+ expectedState = state;
87
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOOPBACK_TIMEOUT_MS;
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(() => {
90
+ cleanup();
91
+ reject(new LoginTimedOut());
92
+ }, timeoutMs);
93
+ const onAbort = () => {
94
+ cleanup();
95
+ reject(new LoginCancelled());
96
+ };
97
+ const cleanup = () => {
98
+ clearTimeout(timer);
99
+ options.signal?.removeEventListener('abort', onAbort);
100
+ onCallback = undefined;
101
+ onFailure = undefined;
102
+ };
103
+ if (options.signal?.aborted) {
104
+ onAbort();
105
+ return;
106
+ }
107
+ options.signal?.addEventListener('abort', onAbort, { once: true });
108
+ onCallback = (code) => {
109
+ cleanup();
110
+ resolve(code);
111
+ };
112
+ onFailure = (error) => {
113
+ cleanup();
114
+ reject(error);
115
+ };
116
+ });
117
+ };
118
+ return { port, callbackUrl: `http://127.0.0.1:${port}${CALLBACK_PATH}`, waitForCode, close };
119
+ }
120
+ //# sourceMappingURL=loopback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loopback.js","sourceRoot":"","sources":["../src/loopback.ts"],"names":[],"mappings":"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"}
package/dist/pkce.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * PKCE (RFC 7636) values for the loopback login.
3
+ *
4
+ * The verifier never leaves this process until redemption. The challenge is the
5
+ * unpadded base64url SHA-256 of the verifier, and it travels through the
6
+ * browser in a query parameter. A code observed in browser history is therefore
7
+ * useless to anything that does not hold the verifier.
8
+ */
9
+ /** A verifier and its S256 challenge. */
10
+ export interface PkcePair {
11
+ verifier: string;
12
+ challenge: string;
13
+ }
14
+ /**
15
+ * Create a verifier and its challenge. 32 random bytes encode to 43 base64url
16
+ * characters, which is the RFC's minimum verifier length and the exact length of
17
+ * an S256 challenge.
18
+ */
19
+ export declare function createPkcePair(): PkcePair;
20
+ /**
21
+ * Create the state nonce that ties a callback to the flow that started it. The
22
+ * listener refuses a callback whose state does not match.
23
+ */
24
+ export declare function createStateNonce(): string;
package/dist/pkce.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * PKCE (RFC 7636) values for the loopback login.
3
+ *
4
+ * The verifier never leaves this process until redemption. The challenge is the
5
+ * unpadded base64url SHA-256 of the verifier, and it travels through the
6
+ * browser in a query parameter. A code observed in browser history is therefore
7
+ * useless to anything that does not hold the verifier.
8
+ */
9
+ import { createHash, randomBytes } from 'node:crypto';
10
+ /**
11
+ * Create a verifier and its challenge. 32 random bytes encode to 43 base64url
12
+ * characters, which is the RFC's minimum verifier length and the exact length of
13
+ * an S256 challenge.
14
+ */
15
+ export function createPkcePair() {
16
+ const verifier = randomBytes(32).toString('base64url');
17
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
18
+ return { verifier, challenge };
19
+ }
20
+ /**
21
+ * Create the state nonce that ties a callback to the flow that started it. The
22
+ * listener refuses a callback whose state does not match.
23
+ */
24
+ export function createStateNonce() {
25
+ return randomBytes(16).toString('hex');
26
+ }
27
+ //# sourceMappingURL=pkce.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pkce.js","sourceRoot":"","sources":["../src/pkce.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAQrD;;;;GAIG;AACH,MAAM,UAAU,cAAc;IAC5B,MAAM,QAAQ,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;IACtD,MAAM,SAAS,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;IAC3E,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAA;AAChC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB;IAC9B,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AACxC,CAAC"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * MCP tool registration.
3
+ *
4
+ * Each tool validates its input with a Zod schema (the SDK builds the advertised
5
+ * JSON Schema from it and rejects bad input before the handler runs), calls the
6
+ * client, and returns pretty-printed JSON text plus `structuredContent`. A
7
+ * failure returns `isError: true` with one actionable message from
8
+ * `toActionableMessage`. No tool prints to stdout, which carries the MCP
9
+ * protocol only: login instructions — the authorize URL on the no-browser path,
10
+ * and liveness while a human approves — go to stderr through the client's
11
+ * operator sink and to the host as an MCP progress notification.
12
+ */
13
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import type { ArenaClient } from './client.js';
15
+ import type { LeaderboardResponse } from './apiTypes.js';
16
+ /**
17
+ * Human explanation for an empty leaderboard, using the epoch and baseline
18
+ * signals to distinguish the causes of zero rows.
19
+ *
20
+ * The baseline test must be `== null`, not `=== undefined`. `baseline` is a
21
+ * message field and the gateway sets `EmitUnpopulated: true`, so an unset
22
+ * baseline arrives as an explicit `null` and an `=== undefined` test never
23
+ * fires. (The field it replaced, the scalar `baselinePass`, arrived as `0` when
24
+ * unset for the same reason, so that test never fired either.) `== null`
25
+ * catches both null and a genuinely absent key.
26
+ */
27
+ export declare function explainEmptyLeaderboard(board: LeaderboardResponse): string;
28
+ export declare function registerTools(server: McpServer, client: ArenaClient): void;