fix: MCP-valid content type + swap /v2/search backend to OpenAlex (#4) - #5
Conversation
The tool handlers were returning `{ type: "json", json: ... }`, but the
MCP protocol does not define a "json" content type — valid types are
"text", "image", "audio", "resource", and "resource_link". Clients like
Claude Code reject the response with a schema validation error.
Switch all four successful returns to
`{ type: "text", text: JSON.stringify(data, null, 2) }`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 37 minutes and 54 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📝 WalkthroughWalkthroughThe PR corrects four tool response handlers in the MCP server to use the valid MCP content type Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Unpaywall's /v2/search endpoint has been returning HTTP 500 for every query since the May 2025 "Walden" rewrite — only the DOI lookup path appears to have been ported forward. Since Unpaywall now runs as a subroutine of OpenAlex, route title search through OpenAlex's /works endpoint (filter=title.search:<q>,is_oa:<b>, mailto=<email>, per-page=50) and map the response back into the Unpaywall search shape so the public tool contract is unchanged: results[].response is a DOI-style record with doi, title, is_oa, oa_status, best_oa_location, oa_locations; each result also has score and snippet (best-effort <b>-highlighted on query tokens). Adds a _source: "openalex" marker at the top level for transparency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a76e54ccb6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const openAccess = w?.open_access ?? {}; | ||
| const best = mapOpenAlexLocationToUnpaywall(w?.best_oa_location); | ||
| const locs: any[] = Array.isArray(w?.locations) ? w.locations : []; | ||
| const oa_locations = locs.map(mapOpenAlexLocationToUnpaywall).filter((l) => l && (l.url || l.url_for_pdf)); |
There was a problem hiding this comment.
Exclude closed locations from oa_locations mapping
This line maps every w.locations entry with a URL into oa_locations but never checks loc.is_oa, so closed-access publisher locations are returned in an OA-only field. For non-OA/hybrid works this mislabels paywalled links as open, which can cause downstream retrieval logic to select unusable links and produce incorrect OA results.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 79dbe22. Confirmed against a live is_oa:false work: OpenAlex returns closed publisher landing pages in work.locations with is_oa: false. Fix filters locations on loc?.is_oa === true before mapping, so Unpaywall's oa_locations stays OA-only as documented. Added a regression test that does an is_oa:false search and asserts response.oa_locations is empty and response.best_oa_location is null for the closed hit.
| const filterParts = [`title.search:${query}`]; | ||
| if (typeof is_oa === "boolean") filterParts.push(`is_oa:${is_oa}`); | ||
| const params = new URLSearchParams(); | ||
| params.set("query", query); | ||
| if (typeof is_oa === "boolean") params.set("is_oa", String(is_oa)); | ||
| params.set("filter", filterParts.join(",")); |
There was a problem hiding this comment.
Escape query text before composing OpenAlex filter
The query is interpolated directly into title.search:${query} and then joined with commas to build the filter expression. Any user query containing commas (common in article titles/phrases) becomes ambiguous with the filter delimiter, which can split the intended search term into unintended filter clauses and return incorrect results or request errors.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 79dbe22. Reproduced the break by hitting OpenAlex directly with a comma in the query — returns HTTP 400 "Invalid query parameter in hungry hippos" as expected. Fix collapses commas to spaces before interpolation (OpenAlex AND-joins words within a single title.search term, which matches Unpaywall's default semantics). Added a regression test that calls unpaywall_search_titles with "hungry, hungry hippos" and asserts the result array is populated.
…ations P2: Any comma in the user's query was being interpolated directly into the OpenAlex filter expression, which is comma-delimited — so a query like "hungry, hungry hippos" was parsed by OpenAlex as three filter clauses and returned HTTP 400 "Invalid query parameter". Collapse commas to spaces before composing the filter; OpenAlex AND-joins words within a single title.search term, matching Unpaywall's default semantics. Verified with `curl` against the OpenAlex API. P1: work.locations in OpenAlex contains every location on a work, including closed-access publisher landing pages. Unpaywall's oa_locations is OA-only by definition. Filter the source array on loc.is_oa === true before mapping so paywalled links never leak into the OA-only field (the downstream retrieval logic in unpaywall_get_fulltext_links would otherwise pick an unusable link and return an incorrect "OA" result). Smoke test extended with regression tests for both issues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 79dbe221e0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| license: loc.license ?? null, | ||
| version: loc.version ?? null, | ||
| host_type: host_type ?? null, | ||
| is_best: !!loc.is_oa && !!loc.pdf_url, |
There was a problem hiding this comment.
Derive is_best from OpenAlex best_oa_location
The remap currently sets is_best to true whenever a location is OA and has a PDF URL, which breaks Unpaywall semantics: papers with multiple OA PDFs will mark multiple oa_locations as best, and papers whose best_oa_location lacks pdf_url will mark the best location as not best. This can cause downstream link-selection logic that relies on oa_locations[].is_best to choose the wrong target or skip the actual best location.
Useful? React with 👍 / 👎.
Fixes #4. Two related fixes bundled because the smoke test surfaced both.
1. Content type
"json"→"text"(the reported bug)The tool handlers were returning
{ type: "json", json: ... }, which is not a content type defined by the MCP protocol — valid types are"text","image","audio","resource", and"resource_link". Clients like Claude Code reject the response with a schema validation error. Switched all four successful returns to{ type: "text", text: JSON.stringify(data, null, 2) }, matching the fix suggested in the issue. Error paths already usedtype: "text"and were left unchanged.2.
unpaywall_search_titlesbackend → OpenAlex/worksWhile verifying the content-type fix end-to-end, I found that Unpaywall's own
/v2/searchendpoint has been returning HTTP 500 for every query — verified againstdinosaur,open access,graphene,covid,biology,hungry hippos, with and without trailing slash, with and withoutis_oa. This appears to be an upstream regression from the May 2025 "Walden" rewrite in which Unpaywall was reimplemented on top of OpenAlex; the companion serviceourresearch/openalex-unpaywallonly implements/unpaywall/<doi>, with no search route. The DOI endpoint still works fine.Since Unpaywall now runs as a subroutine of OpenAlex, the tool now routes title search through OpenAlex's
/worksendpoint:https://api.openalex.org/works?filter=title.search:<query>[,is_oa:<bool>]&per-page=50&page=<n>&mailto=<email>results[].response— a DOI-style record withdoi,doi_url,title,is_oa,oa_status,best_oa_location,oa_locationsresults[].score— OpenAlexrelevance_scoreresults[].snippet— title with matched query tokens wrapped in<b>...</b>(best-effort, since OpenAlex doesn't return highlighted snippets)meta.count,meta.page,meta.per_pageare surfaced_source: "openalex"marker for transparencypdf_url → url_for_pdf,landing_page_url → url/url_for_landing_page,source.type → host_type(repository|publisher),license/versionpass throughVerification
npm run build— clean TypeScript compile. No remainingtype: "json"indist/index.js.initialize+tools/listlists all four tools.unpaywall_get_by_doi(10.1038/nphys1170) →type: "text", payload parses as JSON withdoi+is_oa.unpaywall_get_fulltext_links→type: "text", payload hasbest_pdf_url/best_open_url.unpaywall_search_titles(hungry hippos) →type: "text", payload hasresults[]withresponse.doi,response.title,response.is_oa(boolean),score,snippetcontaining<b>highlights,meta.count,_source: "openalex".unpaywall_search_titleswithis_oa: true→ every returned result hasresponse.is_oa === true.unpaywall_get_by_doiandunpaywall_get_fulltext_links: DOIs from OpenAlex resolve cleanly in Unpaywall's DOI endpoint, confirming the two tools still compose after the backend swap.Test plan
npm run buildsucceedsunpaywall_get_by_doi,unpaywall_get_fulltext_links,unpaywall_search_titles(both default andis_oa:true)unpaywall_fetch_pdf_textwas not exercised end-to-end (left out of the smoke test to avoid network flakiness on large PDF downloads). The content-type fix is the same one-line change applied to the other three tools; no PDF-parse logic changed.🤖 Generated with Claude Code