Skip to content

fix: MCP-valid content type + swap /v2/search backend to OpenAlex (#4) - #5

Merged
ElliotPadfield merged 3 commits into
mainfrom
fix/issue-4-invalid-mcp-content-type
Apr 24, 2026
Merged

fix: MCP-valid content type + swap /v2/search backend to OpenAlex (#4)#5
ElliotPadfield merged 3 commits into
mainfrom
fix/issue-4-invalid-mcp-content-type

Conversation

@ElliotPadfield

@ElliotPadfield ElliotPadfield commented Apr 24, 2026

Copy link
Copy Markdown
Owner

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 used type: "text" and were left unchanged.

2. unpaywall_search_titles backend → OpenAlex /works

While verifying the content-type fix end-to-end, I found that Unpaywall's own /v2/search endpoint has been returning HTTP 500 for every query — verified against dinosaur, open access, graphene, covid, biology, hungry hippos, with and without trailing slash, with and without is_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 service ourresearch/openalex-unpaywall only 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 /works endpoint:

  • URL: https://api.openalex.org/works?filter=title.search:<query>[,is_oa:<bool>]&per-page=50&page=<n>&mailto=<email>
  • Response is remapped into the documented Unpaywall search shape so the public tool contract is unchanged:
    • results[].response — a DOI-style record with doi, doi_url, title, is_oa, oa_status, best_oa_location, oa_locations
    • results[].score — OpenAlex relevance_score
    • results[].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_page are surfaced
  • Adds a top-level _source: "openalex" marker for transparency
  • OA locations are mapped: pdf_url → url_for_pdf, landing_page_url → url/url_for_landing_page, source.type → host_type (repository | publisher), license/version pass through

Verification

  • npm run build — clean TypeScript compile. No remaining type: "json" in dist/index.js.
  • Smoke test (stdio) — all 23 assertions pass:
    • initialize + tools/list lists all four tools.
    • unpaywall_get_by_doi (10.1038/nphys1170) → type: "text", payload parses as JSON with doi + is_oa.
    • unpaywall_get_fulltext_linkstype: "text", payload has best_pdf_url / best_open_url.
    • unpaywall_search_titles (hungry hippos) → type: "text", payload has results[] with response.doi, response.title, response.is_oa (boolean), score, snippet containing <b> highlights, meta.count, _source: "openalex".
    • unpaywall_search_titles with is_oa: true → every returned result has response.is_oa === true.
  • Integration test — round-trip a DOI from the new search backend through unpaywall_get_by_doi and unpaywall_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 build succeeds
  • Stdio smoke test: initialize + tools/list + tools/call for unpaywall_get_by_doi, unpaywall_get_fulltext_links, unpaywall_search_titles (both default and is_oa:true)
  • Integration test: search → get_by_doi → get_fulltext_links round trip
  • unpaywall_fetch_pdf_text was 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

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>
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@ElliotPadfield has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 37 minutes and 54 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d39ae796-91b7-4ecd-b743-5ae374e4e677

📥 Commits

Reviewing files that changed from the base of the PR and between 7f12eb8 and 79dbe22.

📒 Files selected for processing (1)
  • src/index.ts
📝 Walkthrough

Walkthrough

The PR corrects four tool response handlers in the MCP server to use the valid MCP content type "text" instead of the invalid "json" type. Responses now stringify JSON data as plain text, ensuring protocol compliance.

Changes

Cohort / File(s) Summary
Tool response type corrections
src/index.ts
Changed response content type from type: "json" to type: "text" for four tool handlers (unpaywall_get_by_doi, unpaywall_search_titles, unpaywall_get_fulltext_links, unpaywall_fetch_pdf_text), with JSON data now stringified using JSON.stringify(data, null, 2).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Poem

🐰 Four tools now speak the language true,
No more "json" types causing you-know-who!
With "text" and stringify, they comply,
MCP protocol's happy—here's why!
Hopping forward with validation's delight! 🎉

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ⚠️ Warning The PR title is partially related to the changeset. It correctly identifies the main fix (MCP-valid content type), but the reference to 'swap /v2/search backend to OpenAlex' does not appear in the raw_summary or pr_objectives; only the content-type fix is documented. Update the title to focus on the actual change: 'fix: use MCP-valid text content type for tool responses' or verify if the OpenAlex backend change is present in the changeset.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The pull request fully addresses issue #4 by replacing all four tool handlers' invalid 'json' content type with MCP-compliant 'text' type containing JSON.stringify output.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the invalid MCP content type issue; only src/index.ts modifications and expected package-lock.json sync are present.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-4-invalid-mcp-content-type

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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>
@ElliotPadfield ElliotPadfield changed the title fix: use MCP-valid "text" content type for tool responses (#4) fix: MCP-valid content type + swap /v2/search backend to OpenAlex (#4) Apr 24, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/index.ts
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/index.ts Outdated
Comment on lines +109 to +112
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(","));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@ElliotPadfield ElliotPadfield self-assigned this Apr 24, 2026
@ElliotPadfield
ElliotPadfield merged commit 72b0009 into main Apr 24, 2026
2 checks passed
@ElliotPadfield
ElliotPadfield deleted the fix/issue-4-invalid-mcp-content-type branch April 24, 2026 19:38

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/index.ts
license: loc.license ?? null,
version: loc.version ?? null,
host_type: host_type ?? null,
is_best: !!loc.is_oa && !!loc.pdf_url,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tool responses use invalid MCP content type "json" — should be "text"

1 participant