diff --git a/package-lock.json b/package-lock.json index fe484be..2e84e86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "a2amcp", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "a2amcp", - "version": "0.1.8", + "version": "0.1.9", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", diff --git a/package.json b/package.json index dbcae5a..8ae9dd8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "a2amcp", - "version": "0.1.8", + "version": "0.1.9", "description": "MCP gateway for a2acloud agents. Run as a local MCP server; expose any number of deployed A2A agents to Claude Code, Cursor, and other MCP clients.", "type": "module", "bin": { diff --git a/src/api.ts b/src/api.ts index 6cb4c21..064299c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -24,8 +24,11 @@ export class ControlPlaneClient { } private headers(): Record { - const h: Record = { "content-type": "application/json" }; - if (this.token) h["authorization"] = `bearer ${this.token}`; + const h: Record = { + accept: "application/json", + "content-type": "application/json", + }; + if (this.token) h["authorization"] = `Bearer ${this.token}`; return h; } @@ -35,18 +38,12 @@ export class ControlPlaneClient { headers: this.headers(), body: body !== undefined ? JSON.stringify(body) : undefined, }); + const text = await resp.text(); if (!resp.ok) { - let detail: string; - try { - const j: any = await resp.json(); - detail = j?.detail ?? JSON.stringify(j); - } catch { - detail = await resp.text(); - } - throw new ApiError(resp.status, detail); + throw new ApiError(resp.status, errorDetail(text, resp.statusText)); } if (resp.status === 204) return undefined as T; - return (await resp.json()) as T; + return (text ? JSON.parse(text) : undefined) as T; } me() { @@ -61,3 +58,16 @@ export class ControlPlaneClient { return this.request("GET", `/v1/agents/${encodeURIComponent(name)}`); } } + +function errorDetail(text: string, fallback: string): string { + if (!text.trim()) return fallback; + try { + const parsed = JSON.parse(text); + const detail = parsed?.detail; + if (typeof detail === "string") return detail; + if (detail !== undefined) return JSON.stringify(detail); + return JSON.stringify(parsed); + } catch { + return text; + } +} diff --git a/src/oauth.ts b/src/oauth.ts index 8862ead..ade371a 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -208,9 +208,10 @@ async function exchangeAuthorizationCode(opts: { } async function parseTokenResponse(resp: Response): Promise { - const data = (await resp.json().catch(() => ({}))) as TokenResponse; + const text = await resp.text(); + const data = parseJsonObject(text) as TokenResponse; if (!resp.ok || !data.access_token) { - const message = data.error_description || data.error || resp.statusText; + const message = data.error_description || data.error || text.trim() || resp.statusText; throw new Error(`Keycloak token exchange failed: ${message}`); } return data; @@ -336,3 +337,13 @@ function expiresAt(expiresIn: number | undefined): number | undefined { function nowSeconds(): number { return Math.floor(Date.now() / 1000); } + +function parseJsonObject(text: string): Record { + if (!text.trim()) return {}; + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 0000000..32a3380 --- /dev/null +++ b/tests/api.test.ts @@ -0,0 +1,50 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +import { ApiError, ControlPlaneClient } from "../src/api.js"; + +const originalFetch = globalThis.fetch; + +beforeEach(() => { + globalThis.fetch = originalFetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("ControlPlaneClient reports non-JSON error bodies without rereading them", async () => { + globalThis.fetch = (async () => + new Response("

upstream unavailable

", { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + })) as typeof fetch; + + await assert.rejects( + () => new ControlPlaneClient("https://api.example.test", "token").me(), + (err) => { + assert.ok(err instanceof ApiError); + assert.equal(err.status, 502); + assert.match(err.message, /upstream unavailable/); + assert.doesNotMatch(err.message, /Body is unusable/); + return true; + }, + ); +}); + +test("ControlPlaneClient forwards bearer tokens and extracts JSON error detail", async () => { + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = init?.headers as Record; + assert.equal(headers.authorization, "Bearer access-token"); + return new Response(JSON.stringify({ detail: "keycloak email is not verified" }), { + status: 403, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + await assert.rejects( + () => new ControlPlaneClient("https://api.example.test", "access-token").me(), + /API 403: keycloak email is not verified/, + ); +});