From 86d302e8bd2a3b7e0c1352a0396320fa73d3f71f Mon Sep 17 00:00:00 2001 From: NotXf1le <89696340+NotXf1le@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:46:07 +0100 Subject: [PATCH] fix: expose backend HTTP status in MCP errors --- packages/choosekit-mcp/package-lock.json | 12 +++--- packages/choosekit-mcp/package.json | 4 +- packages/choosekit-mcp/src/server.ts | 6 ++- packages/choosekit-mcp/tests/cli.test.mjs | 30 ++++++++++++++ packages/choosekit-mcp/tests/server.test.mjs | 43 ++++++++++++++++++++ 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/packages/choosekit-mcp/package-lock.json b/packages/choosekit-mcp/package-lock.json index 72b6ef2..b975b71 100644 --- a/packages/choosekit-mcp/package-lock.json +++ b/packages/choosekit-mcp/package-lock.json @@ -1,16 +1,16 @@ { "name": "choosekit-mcp", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "choosekit-mcp", - "version": "0.6.0", + "version": "0.6.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/server": "2.0.0", - "choosekit": "^0.9.0", + "choosekit": "^0.9.1", "zod": "^4.6.4" }, "bin": { @@ -60,9 +60,9 @@ } }, "node_modules/choosekit": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/choosekit/-/choosekit-0.9.0.tgz", - "integrity": "sha512-wN+/xegOfJPFFzso+Zz6xqUF6ICSqJWTNpkI3WUcY/9PA7dLdQWI8kV5ALbZzniJNfFDjGmYSo27QRRgLin/ag==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/choosekit/-/choosekit-0.9.1.tgz", + "integrity": "sha512-np9gY0YjWD72nlYK/ZEaK4tCq01SmFBcq8RFUXwBHZ0jNs93MRjE5p/UKPNHyy3BlYFg6GuJw3v4z3ATlRk7fA==", "license": "Apache-2.0", "engines": { "node": ">=20" diff --git a/packages/choosekit-mcp/package.json b/packages/choosekit-mcp/package.json index fddd0fd..65e9154 100644 --- a/packages/choosekit-mcp/package.json +++ b/packages/choosekit-mcp/package.json @@ -1,6 +1,6 @@ { "name": "choosekit-mcp", - "version": "0.6.0", + "version": "0.6.1", "description": "Use choosekit as an MCP server.", "license": "Apache-2.0", "author": { @@ -37,7 +37,7 @@ }, "dependencies": { "@modelcontextprotocol/server": "2.0.0", - "choosekit": "^0.9.0", + "choosekit": "^0.9.1", "zod": "^4.6.4" }, "devDependencies": { diff --git a/packages/choosekit-mcp/src/server.ts b/packages/choosekit-mcp/src/server.ts index e9f203f..cedb456 100644 --- a/packages/choosekit-mcp/src/server.ts +++ b/packages/choosekit-mcp/src/server.ts @@ -91,9 +91,13 @@ function errorResult(error: unknown, signal: AbortSignal) { throw error; } if (error instanceof ScoringError) { + const status = error.httpStatus; + const text = typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 + ? `The scoring backend returned HTTP ${status}.` + : "The model could not score the supplied choices."; return { isError: true as const, - content: [{ type: "text" as const, text: "The model could not score the supplied choices." }], + content: [{ type: "text" as const, text }], }; } if (error instanceof ImageLoadError) { diff --git a/packages/choosekit-mcp/tests/cli.test.mjs b/packages/choosekit-mcp/tests/cli.test.mjs index 5b03ce7..46ab893 100644 --- a/packages/choosekit-mcp/tests/cli.test.mjs +++ b/packages/choosekit-mcp/tests/cli.test.mjs @@ -357,3 +357,33 @@ test("serves a minimal-prefix choice using the configured llama.cpp endpoint", a assert.equal(many.result.structuredContent.followup.choice, "billing"); for (const line of process_.lines) assert.doesNotThrow(() => JSON.parse(line)); }); + +test("CLI preserves adapter HTTP 429 in both tool errors", async (t) => { + const preload = `globalThis.fetch = async () => Response.json( + { error: "secret upstream details" }, { status: 429 });`; + const fixture = `data:text/javascript;base64,${Buffer.from(preload).toString("base64")}`; + const process_ = startCli(cliEnvironment({ + CHOOSEKIT_BACKEND: "openrouter", + CHOOSEKIT_BASE_URL: "", + CHOOSEKIT_MODEL: "fixture/model", + OPENROUTER_API_KEY: "secret-api-key", + }), ["--import", fixture]); + t.after(() => process_.close()); + await initialize(process_); + + for (const [name, arguments_] of [ + ["choose", request], + ["chooseMany", { + context: request.context, + questions: { route: { question: request.question, choices: request.choices } }, + }], + ]) { + const response = await process_.rpc("tools/call", { name, arguments: arguments_ }); + assert.equal(response.error, undefined); + assert.equal(response.result.isError, true); + assert.deepEqual(response.result.content, + [{ type: "text", text: "The scoring backend returned HTTP 429." }]); + assert.equal(response.result.structuredContent, undefined); + assert.doesNotMatch(JSON.stringify(response), /secret/); + } +}); diff --git a/packages/choosekit-mcp/tests/server.test.mjs b/packages/choosekit-mcp/tests/server.test.mjs index 44e87c3..3502692 100644 --- a/packages/choosekit-mcp/tests/server.test.mjs +++ b/packages/choosekit-mcp/tests/server.test.mjs @@ -484,3 +484,46 @@ test("preserves constructor as an ordinary choice key", async (t) => { assert.equal(response.result.structuredContent.choice, "constructor"); assert.deepEqual(receivedChoices, arguments_.choices); }); + +for (const [tool, arguments_] of [["choose", request], ["chooseMany", manyRequest]]) { + test(`${tool} reports only validated HTTP metadata and recovers after failure`, async (t) => { + const choose = createChooser(() => ({ logprobs: [-0.1, -2], boundaryTokens: 1 })); + let failure; + const connection = await connect(async (input) => { + if (failure) throw failure; + return choose(input); + }); + t.after(() => connection.close()); + + const cases = [ + ...[100, 200, 302, 401, 403, 429, 503, 599].map((httpStatus) => [ + new ScoringError("secret upstream details", { + httpStatus, cause: new Error("secret cause"), + }), + `The scoring backend returned HTTP ${httpStatus}.`, + ]), + ...[undefined, null, "401", 99, 600, 401.5, NaN, Infinity, -Infinity, + { toString() { throw new Error("must not coerce"); } }].map((httpStatus) => [ + new ScoringError("secret upstream details", { httpStatus }), + "The model could not score the supplied choices.", + ]), + [Object.assign(new Error("secret implementation detail"), { httpStatus: 401 }), + "The choice request failed unexpectedly."], + ]; + + for (const [error, text] of cases) { + failure = error; + const response = await connection.rpc("tools/call", { name: tool, arguments: arguments_ }); + assert.equal(response.error, undefined); + assert.equal(response.result.isError, true); + assert.deepEqual(response.result.content, [{ type: "text", text }]); + assert.equal(response.result.structuredContent, undefined); + assert.doesNotMatch(JSON.stringify(response), /secret/); + } + + failure = undefined; + const response = await connection.rpc("tools/call", { name: tool, arguments: arguments_ }); + assert.equal(response.error, undefined); + assert.notEqual(response.result.isError, true); + }); +}