/** * @fileoverview CrossrefService wraps the Crossref REST API with polite-pool User-Agent injection, * per-request timeout, retry with exponential backoff, and pagination helpers. Offset paging is * honored on the name-search and works sub-resource routes, whose ceilings differ by an order of * magnitude — see NAME_SEARCH_OFFSET_CAP and WORKS_OFFSET_CAP. Cursor paging has no ceiling and * is available on `/works` and on both works sub-resources. One request leaves Crossref: the DOI * registry's agency lookup, made only after a Crossref 404 (`getRegistrationAgency`), to * `https://doi.org` whatever `CROSSREF_BASE_URL` says and without the polite-pool `mailto`. * * Also home to the text normalization every tool projects free-text values through — * `normalizeText` for the baseline pass, `normalizeMarkupText` for the JATS-deposited fields, * and `normalizeReferenceText` for the deposited citation strings. The two markup passes are * one rule over one implementation (`stripMarkup`): a bracket is removed only when it is * recognizable as markup, by its shape, by sitting inside a markup region, or by its element * name — and they differ in exactly one thing, what an unrecognized element name means. A link * element is the one class settled per occurrence instead of per name, since its tags may come * out only where its own text already carries the address its `href` holds; an `` * wrapper is the one region that selects rather than strips, keeping the first of the encodings * it holds so one expression does not reach the reader twice and leaving the block boundary a * formula leaves, whichever encoding it kept; a MathML formula is the one region that is read * rather than emptied — see `mathml` — so the operators its tree encodes as structure survive * the loss of its tags. Every region is found by a scan linear in the field, however many of * its openers the deposit leaves unclosed. All three passes end in the same * baseline: character references decoded against the HTML5 named set in `html-entities`, then * whitespace collapsed. * * Home too to the publication date every work-projecting tool resolves through — `resolveWorkDate` * for a full record and `resolveWorkSummaryDate` for a work summary. Both take the record rather * than a chain of its date fields, so the one thing that could differ between call sites — when * the chain falls through to the next source — is settled here for all of them at once. * * What these passes produce is what `structuredContent` carries. The same values are escaped * again on their way into the Markdown of `content[]` — see `mdText` in `mcp-server/tools` — * so a bracket kept here is not consumed by the client's renderer instead. * @module services/crossref/crossref-service */ import { type Context } from '@cyanheads/mcp-ts-core'; import type { CrossrefDateParts, RawCrossrefFunder, RawCrossrefJournal, RawCrossrefMember, RawCrossrefPrefix, RawCrossrefWork } from './types.js'; /** * Normalize a human-readable upstream string for output: decode character references, * collapse whitespace, trim. * * This is the baseline every free-text value this server projects from a Crossref record * passes through — work and journal titles, publisher and funder and member names, * affiliations, subjects, reference text. Identifiers and machine-format values (DOIs, URIs, * ISSNs, prefixes, dates, work types, coverage categories) are projected byte-exact and never * come through here. * * The decode runs before the collapse, so a reference naming a whitespace character — * ` `, ` ` — folds into the surrounding run rather than reaching `content[]` as a * line break that splits the Markdown around it. */ export declare function normalizeText(raw: string): string; /** * What removing an element does to the text around it — or, for `keep`, that the bracket does * not come out on the element name alone. An unrecognized name is `keep` on the reference * surface because it is presumed to be content; a link element is `keep` until its own text is * checked for the address its `href` carries. */ type Verdict = 'tight' | 'inline' | 'block' | 'keep'; /** * Every element name this server classifies, and what removing it does to the text around it. * Both markup passes read this one map, which is what keeps them from drifting apart; it is * exported so a test can drive the caller-visible outcome of every name in it, rather than * asserting that a name is present and leaving the behavior unpinned. */ export declare const ELEMENT_VERDICTS: Map; /** * Strip the markup a JATS-deposited field carries — work titles, subtitles, container titles, * and abstracts. An element name on none of the shared classes is structure, and comes out as a * block boundary. */ export declare function stripJats(raw: string): string; /** * Strip the markup a reference free-text field carries. An element name on none of the shared * classes is presumed to be content a publisher typed, and stays. */ export declare function stripReferenceMarkup(raw: string): string; /** * Normalize a field publishers deposit as JATS/XML markup — work titles, subtitles, container * titles, and abstracts — by stripping markup before the baseline pass. * * Order matters: tags come out before entities are decoded, so a deposited `<i>` stays * literal text instead of decoding into a tag the strip pass would then eat. */ export declare function normalizeMarkupText(raw: string): string; /** * Normalize a reference entry's free text: strip markup, then the baseline pass. Same order, * same reason — a deposited `<i>` stays literal. */ export declare function normalizeReferenceText(raw: string): string; /** Format a year/month/day object as an ISO-style date string (e.g. "2023-04-15" or "2023"). */ export declare function formatDateParts(d: { year?: number | undefined; month?: number | undefined; day?: number | undefined; }): string; /** * Extract year/month/day from a Crossref date-parts array. * * A component Crossref does not know is deposited as `null` in the tuple rather than left off it: * a dissertation with no registered year arrives as `[[null]]`, and 8% of a random works draw * carries one somewhere in `issued`. Absent and null are the same fact for a caller — the * component is unknown — so both come back as an omitted field, which is what the output schemas * declare each of the three to be. * * The read stops at the first unknown component rather than skipping past it, because the tuple * is positional and the parts below one carry no meaning without it. `[[2020, null, 15]]` is a * year and a day-of-a-month nobody named, and rendering it through `formatDateParts` as `2020-15` * would state a date the deposit does not. */ export declare function parseDateParts(raw: { 'date-parts'?: Array>; } | undefined): { year?: number; month?: number; day?: number; } | undefined; /** * The date fields a work record answers "when was this published?" with, in preference order. * * `published` leads because it tracks the earliest of the two publication events, though Crossref * documents no definition for it; each event then answers for itself. `issued` is the one Crossref * does define that way, and it is the one of the four the API marks required — so a record with no * publication date at all carries its `[[null]]` placeholder there, with the other three absent. */ declare const WORK_DATE_SOURCES: readonly ["published", "published-print", "published-online", "issued"]; /** The date-bearing part of a work record — every source in the chain, each of them optional. */ type WorkDateSources = Partial>; /** * The publication date of a full work record, `issued` included. Takes the whole record rather * than a chain of fields, so the order is settled here for every caller instead of being spelled * out — and re-spelled differently — at each one. */ export declare function resolveWorkDate(raw: WorkDateSources): { year?: number; month?: number; day?: number; } | undefined; /** The publication date of a work summary — the same rule over the three published-* sources. */ export declare function resolveWorkSummaryDate(raw: WorkDateSources): { year?: number; month?: number; day?: number; } | undefined; /** * Unwrap a DOI from the resolver it was copied with — `https://doi.org/…`, the older * `dx.doi.org` host, or the `doi:` URI scheme — leaving the bare DOI Crossref's paths take. * * Every one of those forms names exactly one DOI, so the rewrite has a single reading and * costs the caller nothing: the argument is answerable as sent instead of being handed back * for the caller to edit. It is prefix removal and nothing more — a host that merely contains * `doi.org`, and any string that carries no recognized wrapper, comes back byte-exact, so the * tool schemas still reject what is not a DOI rather than hunting for one inside the argument. * * Shared by the DOI tools and the funder path, where the same wrappers arrive around a Funder * Registry DOI (`10.13039/100000001`) and a bare registry ID passes straight through. */ export declare function normalizeDoi(raw: string): string; /** Crossref works search options. */ export type WorksSearchOptions = { query?: string; queryBibliographic?: string; queryTitle?: string; queryAuthor?: string; queryContainerTitle?: string; filter?: Record; fields?: string[]; rows?: number; offset?: number; cursor?: string; sort?: string; order?: string; }; /** Crossref journals search options. `offset` applies to the title-query path only. */ export type JournalsSearchOptions = { query?: string; issn?: string; rows?: number; offset?: number; }; /** Crossref funders search options. `offset` applies to the name-query path only. */ export type FundersSearchOptions = { query?: string; funderDoi?: string; rows?: number; offset?: number; }; /** * Paging options for the `/journals/{issn}/works` and `/funders/{id}/works` sub-resources. * `cursor` and `offset` are alternatives, not a pair — Crossref rejects the combination with * `cursor-with-offset-or-sample`, so `cursor` wins here and callers validate ahead of the call. * The two modes also order the list differently: an offset page is newest-published first, a * cursor walk newest-registered first — see `subResourcePageParams`. */ export type SubResourceWorksOptions = { rows: number; offset?: number; cursor?: string; }; /** Result of a works search, including pagination metadata. */ export type WorksSearchResult = { totalResults: number; itemsPerPage: number; nextCursor?: string | undefined; items: RawCrossrefWork[]; }; /** Result of a journal/funder name search, carrying the upstream total so callers can page. */ export type ListSearchResult = { totalResults: number; items: T[]; }; /** * Crossref caps offset paging on the `/journals` and `/funders` name-search routes at * `offset + rows <= 100000`; past that it answers HTTP 400 `integer-not-valid`. */ export declare const NAME_SEARCH_OFFSET_CAP = 100000; /** * The `/journals/{issn}/works` and `/funders/{id}/works` sub-resources cap ten times lower — * `offset + rows <= 10000` — and their rejection body directs callers to cursor paging. It is a * ceiling on the `offset` input alone: both routes accept `cursor=*` and return a `next-cursor` * token, which the tools thread as `works_cursor` / `nextWorksCursor` to read past it. */ export declare const WORKS_OFFSET_CAP = 10000; /** * Where the page after this one lives. `end` means the list is exhausted; `ceiling` means further * records exist upstream but the route's offset ceiling puts them out of reach through this input. * Those are different facts for a caller, so they are separate variants rather than one absent * offset — a page that stops at the ceiling has to say so or it reads as the end of the list. */ export type PageContinuation = { kind: 'next'; offset: number; } | { kind: 'end'; } | { kind: 'ceiling'; }; /** Classify the continuation for a page against its route's offset ceiling. */ export declare function nextPageOffset(args: { offset: number; returned: number; total: number; rows: number; cap: number; }): PageContinuation; /** * What the DOI registry says about an identifier Crossref has no record for: an agency holds * it (Crossref itself included), no agency has registered it, or no readable answer came back. */ export type RegistrationLookup = { kind: 'registered'; agency: string; } | { kind: 'unregistered'; } | { kind: 'unknown'; }; export declare class CrossrefService { private readonly baseUrl; private readonly timeoutMs; private readonly userAgent; /** * What the DOI registry is told. The `mailto` is the operator's contact, given for Crossref's * polite pool; doi.org is a second party with no pool to admit anyone to, so it hears the * server and version and nothing else. */ private readonly registryUserAgent; constructor(); /** * Retry boundary for a Crossref call. The default transient predicate is left in * place deliberately — the fix for "retries burned on a failure that can never * succeed" belongs one layer down, in `attempt()`, which returns every failure as an * `McpError` so the predicate classifies by error code. A custom predicate would only * re-sort the same unclassified exceptions by shape at the wrong layer. The same holds * for retry *cost*: the deadline-expiry opt-out sets `retryable: false` at its throw * site, which the stock predicate honors, so no budget arithmetic lives here. * * It is also where the request URL is recorded, which is why the whole retried call is * wrapped rather than returned straight through. The URL is both the field a Crossref * failure cannot be debugged without and the field that must not reach the caller: * `error.data` is forwarded as `structuredContent.error.data`, and `CROSSREF_BASE_URL` * is operator-configurable, so a deployment pointed at a private mirror would hand that * hostname — plus every query string this server builds — to every caller on every * failure. `ctx.log` is no refuge either, being dual-sink: a line written there ships to * the client as `notifications/message`. Only the Pino-only `logger` keeps the URL where * an operator can read it and a caller cannot. * * One line per failed call, after the retries rather than inside them, and none at all * for a caller cancellation — the caller hung up, which is nobody's upstream fault. */ private request; /** * One attempt at a Crossref request. Every failure leaves here as an `McpError` with a * classified code and a recovery hint. `withRetry`'s default predicate treats any * non-`McpError` throw as transient and the framework's classifier reads the outer * error's constructor name, so an unwrapped throw is wrong twice over: a `SyntaxError` * from `JSON.parse` is retried to exhaustion and then surfaces as a caller * `ValidationError`, and a `TypeError` from `fetch` surfaces as an `InternalError` * — an upstream outage reported as a bug in this server. * * `userAgent` is passed rather than read off the instance because the one request that * leaves Crossref must not carry the polite-pool `mailto` — see `registryUserAgent`. */ private attempt; /** * Classify a raw transport rejection. `timedOut` is decided by which signal fired, not * by the rejection value: `fetch` rejects with the abort *reason*, which may be any * value. The network path rejects with a `TypeError` whose message ("fetch failed") * says nothing — the real reason sits on `.cause`, which the framework's classifier * never reads. */ private transportError; /** * Convert a non-2xx response into a classified error carrying recovery. * * Crossref rejecting the request is the caller's to fix, and becomes a declared rejection * reason keyed on Crossref's own `type`: every 400, and the one 404 that is a rejection * rather than a missing record — a `cursor-invalid` body. Any other 404 passes through as * `httpErrorFromResponse` classified it, for the tool handlers to turn into their own typed * not-found reasons; the upstream statuses are re-classified onto the upstream contract. */ private responseError; /** * Fetch a single work by DOI. Returns null when the DOI is not found (404). * Lets the caller (tool handler) throw the appropriate typed error. */ getWork(doi: string, ctx: Context): Promise; /** * Fetch a Crossref member (publisher/organization) by numeric ID. Returns null on 404, * letting the caller throw the appropriate typed error. Mirrors getWork()'s 404→null pattern. */ getMember(id: number, ctx: Context): Promise; /** * Resolve a DOI prefix (e.g. "10.1038") to its owning member. Returns null on 404. * Same 404→null pattern as getWork()/getMember(). */ getPrefix(prefix: string, ctx: Context): Promise; /** * Ask the DOI registry which registration agency holds a DOI or a bare prefix — read only * after Crossref has answered 404, to tell a DOI another agency registered from one nobody * did. Best effort by design: one attempt with no retry, bounded by the same per-request * timeout and caller signal as every Crossref call, and any failure to get a readable answer * is `unknown`, which leaves the caller's not-found text as it was. Caller cancellation is * the one failure that is not absorbed, and it is reworded here: `attempt` reports an abort as * a cancelled Crossref request, and this request did not go to Crossref. */ getRegistrationAgency(id: string, ctx: Context): Promise; /** Search works with filter, field selection, and cursor/offset pagination. */ searchWorks(opts: WorksSearchOptions, ctx: Context): Promise; /** * Search journals by query, or fetch one by ISSN. The ISSN path is a single-record lookup, * so it reports a total of 1 and ignores `offset`. */ searchJournals(opts: JournalsSearchOptions, ctx: Context): Promise>; /** * Fetch a page of works for a specific journal by ISSN, most recent first — by publication * date on an offset page, by registration date on a cursor walk. */ getJournalWorks(issn: string, opts: SubResourceWorksOptions, ctx: Context): Promise; /** * Search funders by query, or fetch one by funder DOI. The DOI path is a single-record * lookup, so it reports a total of 1 and ignores `offset`. */ searchFunders(opts: FundersSearchOptions, ctx: Context): Promise>; /** * Fetch a page of works for a specific funder by funder DOI/ID, most recent first — by * publication date on an offset page, by registration date on a cursor walk. Ordered the * same way as `getJournalWorks`, so the two works lists agree. */ getFunderWorks(funderId: string, opts: SubResourceWorksOptions, ctx: Context): Promise; } export declare function initCrossrefService(): void; export declare function getCrossrefService(): CrossrefService; export {}; //# sourceMappingURL=crossref-service.d.ts.map