diff --git a/.changeset/tall-buses-wonder.md b/.changeset/tall-buses-wonder.md index b0c11ae7..51522a89 100644 --- a/.changeset/tall-buses-wonder.md +++ b/.changeset/tall-buses-wonder.md @@ -2,4 +2,4 @@ "@upstash/context7-mcp": patch --- -Failed tool calls now set `isError: true` on their results, as the MCP spec requires for tool execution errors. `query-docs` flags API failures, network errors, and empty documentation bodies; `resolve-library-id` flags search API failures. Previously these returned error text as a successful result, so clients that branch on `isError` treated messages like "Library not found" as documentation. An empty search result set is still a success. The error text itself is unchanged. +Failed tool calls now set `isError: true` on their results, as the MCP spec requires for tool execution errors. `query-docs` flags API failures, network errors, and empty documentation bodies; `resolve-library-id` flags search API failures. Previously these returned error text as a successful result, so clients that branch on `isError` treated messages like "Library not found" as documentation. A lookup that simply matched nothing stays a success: the API reports those as a 404 carrying `no_libraries_found` or `no_relevant_snippets`, which is a completed search with no results. The error text itself is unchanged. diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 512524ae..cfc1f5b0 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -201,9 +201,9 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f text, }, ], - // An API failure is a tool execution error; an empty result set is a - // legitimate search outcome, so it stays a success. - ...(searchResponse.error ? { isError: true } : {}), + // An API failure is a tool execution error; a search that matched + // nothing is a legitimate outcome, so it stays a success. + ...(searchResponse.isError ? { isError: true } : {}), }; } diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index c0fa7306..11333902 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -5,24 +5,37 @@ import { CONTEXT7_API_BASE_URL } from "./constants.js"; import { readFileSync } from "fs"; import tls from "tls"; +/** + * Codes the API returns on a 404 when the lookup ran but matched nothing. + * The call did not fail, so these are not tool execution errors. + */ +const EMPTY_RESULT_CODES = new Set(["no_libraries_found", "no_relevant_snippets"]); + /** * Parses error response from the Context7 API * Extracts the server's error message, falling back to status-based messages if parsing fails * @param response The fetch Response object * @param apiKey Optional API key (used for fallback messages) - * @returns Error message string + * @returns The message to show, and whether it represents a real failure */ -async function parseErrorResponse(response: Response, apiKey?: string): Promise { +async function parseErrorResponse( + response: Response, + apiKey?: string +): Promise<{ message: string; isError: boolean }> { try { - const json = (await response.json()) as { message?: string }; + const json = (await response.json()) as { message?: string; error?: string }; if (json.message) { - return json.message; + return { message: json.message, isError: !EMPTY_RESULT_CODES.has(json.error ?? "") }; } } catch { // JSON parsing failed, fall through to default } - const status = response.status; + // An unparseable body is always a real failure. + return { message: statusMessage(response.status, apiKey), isError: true }; +} + +function statusMessage(status: number, apiKey?: string): string { if (status === 429) { return apiKey ? "Rate limited or quota exceeded. Upgrade your plan at https://context7.com/plans for higher limits." @@ -122,16 +135,16 @@ export async function searchLibraries( const response = await fetch(url, { headers }); readPromptSignal(response, context); if (!response.ok) { - const errorMessage = await parseErrorResponse(response, context.apiKey); - console.error(errorMessage); - return { results: [], error: errorMessage }; + const { message, isError } = await parseErrorResponse(response, context.apiKey); + if (isError) console.error(message); + return { results: [], error: message, isError }; } const searchData = await response.json(); return searchData as SearchResponse; } catch (error) { const errorMessage = `Error searching libraries: ${error}`; console.error(errorMessage); - return { results: [], error: errorMessage }; + return { results: [], error: errorMessage, isError: true }; } } @@ -155,9 +168,9 @@ export async function fetchLibraryContext( const response = await fetch(url, { headers }); readPromptSignal(response, context); if (!response.ok) { - const errorMessage = await parseErrorResponse(response, context.apiKey); - console.error(errorMessage); - return { data: errorMessage, isError: true }; + const { message, isError } = await parseErrorResponse(response, context.apiKey); + if (isError) console.error(message); + return { data: message, isError }; } const text = await response.text(); diff --git a/packages/mcp/src/lib/types.ts b/packages/mcp/src/lib/types.ts index 4407e1ae..356f2d95 100644 --- a/packages/mcp/src/lib/types.ts +++ b/packages/mcp/src/lib/types.ts @@ -16,6 +16,8 @@ export interface SearchResult { export interface SearchResponse { error?: string; + /** True when `error` is a real failure rather than a search that matched nothing. */ + isError?: boolean; results: SearchResult[]; searchFilterApplied?: boolean; } diff --git a/packages/mcp/test/api.test.ts b/packages/mcp/test/api.test.ts index 1695a264..ea401e5c 100644 --- a/packages/mcp/test/api.test.ts +++ b/packages/mcp/test/api.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { fetchLibraryContext } from "../src/lib/api.js"; +import { fetchLibraryContext, searchLibraries } from "../src/lib/api.js"; -// fetchLibraryContext calls global fetch; stub it per case. +// The api layer calls global fetch; stub it per case. afterEach(() => { vi.unstubAllGlobals(); }); @@ -69,4 +69,55 @@ describe("fetchLibraryContext", () => { expect(result.isError).toBeUndefined(); expect(result.data).toBe("# Some real documentation"); }); + + // Three different outcomes arrive as HTTP 404; only the code separates them. + test("does not flag a query that matched no snippets", async () => { + stubFetch({ + ok: false, + status: 404, + json: async () => ({ error: "no_relevant_snippets", message: "No documentation matched." }), + }); + + const result = await fetchLibraryContext({ query: "q", libraryId: "/vercel/next.js" }); + expect(result.isError).toBe(false); + expect(result.data).toBe("No documentation matched."); + }); + + test("flags library_not_found despite the shared 404 status", async () => { + stubFetch({ + ok: false, + status: 404, + json: async () => ({ error: "library_not_found", message: 'Library "/no/such" not found.' }), + }); + + const result = await fetchLibraryContext({ query: "q", libraryId: "/no/such" }); + expect(result.isError).toBe(true); + }); +}); + +describe("searchLibraries", () => { + test("does not flag a search that matched no libraries", async () => { + stubFetch({ + ok: false, + status: 404, + json: async () => ({ + error: "no_libraries_found", + message: 'No libraries found for "zzzz". Try a different search term.', + }), + }); + + const result = await searchLibraries("q", "zzzz"); + expect(result.isError).toBe(false); + expect(result.results).toEqual([]); + // The API's guidance still reaches the model. + expect(result.error).toContain("Try a different search term"); + }); + + test("flags a real search API failure", async () => { + stubFetch({ ok: false, status: 401, json: async () => ({ message: "Invalid API key." }) }); + + const result = await searchLibraries("q", "Next.js"); + expect(result.isError).toBe(true); + expect(result.error).toBe("Invalid API key."); + }); });