---
summary: "Upstream and transport failures from Crossref now classify by wire code instead of by accident, and every upstream reason carries a recovery hint on content[] as well as structuredContent"
breaking: false
security: false
---

# 0.3.1 — 2026-08-07

## Added

- **`src/services/crossref/upstream-errors.ts`** — one declared reason per upstream failure mode (`rate_limited`, `upstream_unavailable`, `malformed_response`, `request_timeout`), spread via `UPSTREAM_ERROR_CONTRACT` into `errors[]` on all seven tools. The service reads each entry's `recovery` text at the throw site and writes it to `data.recovery.hint`, which the framework mirrors into `content[]` — the only route by which a content-only client learns what to do next ([#37](https://github.com/cyanheads/crossref-mcp-server/issues/37)).
- **`tests/services/crossref/upstream-classification.test.ts`** — asserts each failure path at the wire (JSON-RPC code, retry cost, rendered `Recovery:` text) through `runToolContract`, with a real `fetch` fake and no stubbing of `withRetry` or `httpErrorFromResponse`.

## Fixed

- **`CrossrefService.request()`** — split into a retry boundary and a single `attempt()` that returns every failure as a classified `McpError`. Previously an unwrapped throw was doubly wrong: `withRetry`'s default predicate treats any non-`McpError` as transient, and the framework's classifier reads the throw's constructor name. Malformed JSON in a 2xx body was `ValidationError` after 4 wasted attempts, now `SerializationError` with `retryable: false` on the first. An empty 2xx body was the same misclassification, now `ServiceUnavailable` and retried. A connection reset or DNS failure was `InternalError`, now `ServiceUnavailable` naming the real `.cause` (e.g. `ECONNRESET`) instead of `fetch`'s generic "fetch failed". A 500/501 was `InternalError` and not retried, now `ServiceUnavailable` and retried ([#37](https://github.com/cyanheads/crossref-mcp-server/issues/37)).
- **Timeout detection** — the per-attempt `AbortController` now aborts with a `TimeoutError` `DOMException` matched by identity, instead of a code path that happened to classify as `Timeout` by matching "timed out" in a message the runtime owns ([#37](https://github.com/cyanheads/crossref-mcp-server/issues/37)).
- **408 / 504** — now map onto `request_timeout` instead of the generic 5xx handling.
- **`getWork` / `getMember` / `getPrefix`** — the 404→`null` check now compares against `JsonRpcErrorCode.NotFound` instead of the magic number `-32001`.

## Changed

- Adopted a raw `fetch` in place of the framework's `fetchWithTimeout` helper, which throws on non-2xx and hands back only a truncated body string — costing the 400 branch its parse of Crossref's `validation-failure` body and turning every routine 404 into a logged error. The two mechanisms worth keeping from it — identity-matched timeout aborts and a `.cause`-unwrapping transport catch — are replicated locally in `attempt()` and `transportError()`.
