diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index d0813d9b0e..2733791183 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -2767,7 +2767,15 @@ async function apiFetch(path, init, options = {}) { }, }).finally(() => clearTimeout(timeout)); const text = await response.text(); - const payload = text ? JSON.parse(text) : {}; + let payload = {}; + if (text) { + try { + payload = JSON.parse(text); + } catch (error) { + if (response.ok) throw error; + payload = { error: "non_json_response", body: text.slice(0, 500) }; + } + } if (!response.ok) { const retry = response.headers.get("retry-after"); const error = new Error(`Gittensory API ${response.status}${retry ? ` retry-after=${retry}s` : ""}: ${JSON.stringify(payload).slice(0, 500)}`); diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 1d464780c2..72e5401808 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -768,6 +768,35 @@ describe("gittensory-mcp CLI", () => { await expect(runAsync(["decision-pack", "--login", "JSONbored", "--json"], env)).rejects.toThrow(/Gittensory API 403/); }); + it("does not use stale decision-pack cache for non-JSON authorization failures", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const fixtureOptions: { + decisionPackStatus?: number; + decisionPackErrorBody?: string; + decisionPackErrorContentType?: string; + repoDecisionStatus?: number; + repoDecisionErrorBody?: string; + repoDecisionErrorContentType?: string; + } = {}; + const url = await startFixtureServer(fixtureOptions); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }; + + await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env); + fixtureOptions.decisionPackStatus = 403; + fixtureOptions.decisionPackErrorBody = "forbidden"; + fixtureOptions.decisionPackErrorContentType = "text/html"; + fixtureOptions.repoDecisionStatus = 403; + fixtureOptions.repoDecisionErrorBody = "forbidden"; + fixtureOptions.repoDecisionErrorContentType = "text/html"; + + await expect(runAsync(["decision-pack", "--login", "JSONbored", "--json"], env)).rejects.toThrow(/Gittensory API 403/); + await expect(runAsync(["repo-decision", "--login", "JSONbored", "--repo", "JSONbored/gittensory", "--json"], env)).rejects.toThrow(/Gittensory API 403/); + }); + it("does not use stale decision-pack cache when local credentials are missing", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const url = await startFixtureServer(); @@ -1336,6 +1365,11 @@ async function startFixtureServer( compatibilityStatus?: number; npmStatus?: number; decisionPackStatus?: number; + decisionPackErrorBody?: string; + decisionPackErrorContentType?: string; + repoDecisionStatus?: number; + repoDecisionErrorBody?: string; + repoDecisionErrorContentType?: string; packetMarkdown?: string; onPacketRequest?: (body: unknown) => void; onApiRequest?: (request: IncomingMessage) => void; @@ -1420,12 +1454,23 @@ async function startFixtureServer( if (request.url === "/v1/contributors/JSONbored/decision-pack" && request.method === "GET") { if (options.decisionPackStatus && options.decisionPackStatus >= 400) { response.statusCode = options.decisionPackStatus; - response.end(JSON.stringify({ error: "decision_pack_unavailable" })); + if (options.decisionPackErrorContentType) response.setHeader("content-type", options.decisionPackErrorContentType); + response.end(options.decisionPackErrorBody ?? JSON.stringify({ error: "decision_pack_unavailable" })); return; } response.end(JSON.stringify(decisionPackFixture())); return; } + if (request.url === "/v1/contributors/JSONbored/repos/JSONbored/gittensory/decision" && request.method === "GET") { + if (options.repoDecisionStatus && options.repoDecisionStatus >= 400) { + response.statusCode = options.repoDecisionStatus; + if (options.repoDecisionErrorContentType) response.setHeader("content-type", options.repoDecisionErrorContentType); + response.end(options.repoDecisionErrorBody ?? JSON.stringify({ error: "repo_decision_unavailable" })); + return; + } + response.end(JSON.stringify({ status: "ready", login: "JSONbored", repoFullName: "JSONbored/gittensory", decision: decisionPackFixture().repoDecisions[0] })); + return; + } if (request.url === "/v1/agent/plan-next-work" && request.method === "POST") { await readJsonRequest(request); response.end(JSON.stringify(agentFixture()));