Skip to content

Architecture

Architecture overview for PriorisMCP

An MCP client's call enters through the middleware chain — StripUnknownArgumentsMiddleware, LiveResourceCacheBypassMiddleware, DecodeBinaryResourceContentMiddleware, ResponseCachingMiddleware, EncodeBinaryResourceContentMiddleware, then ResponseMetadataMiddleware, applied in that fixed order (see server.py's app()) — before reaching the tools, resources, and prompts registered on PriorisMCP via MCPMixin (see mixin.py). From there, an identifier that isn't already provider-native (a DOI, for instance) passes through the grouping-level identifier routing described below before a ResearchPublicationProvider implementation — ArxivProvider, EuropePmcProvider, or LocalFileProvider — is selected to service the call.

Each provider hands full text off to a ParserBackend for that format: LiteParsePdfBackend for PDF (arXiv and the local filesystem source), HtmlToMarkdownBackend for arXiv's HTML, and JatsXsltMarkdownBackend for Europe PMC's JATS XML. LiteParsePdfBackend OCRs scanned pages via a locally bundled, air-gapped Tesseract engine by default (tessdata_path, no network call); delegating OCR to an external server instead is an opt-in alternative, off by default, and is not on the path any v1 deployment takes unless explicitly configured — see parse_full_text below.

Parsed Markdown, and the document bytes it was parsed from, are persisted through FilesystemStorageBackend into the SQLite catalogue/manifest and indexed into SqliteFts5SearchIndex for full-text search — see Storage for both. Only ArxivProvider and EuropePmcProvider make outbound calls to their respective external APIs; LocalFileProvider has no external dependency of its own, since the caller supplies the document's bytes directly (see Local filesystem source below).

The diagram also shows two capabilities designed in their own chapters, not repeated here: OpenAlexClient and the fetch ladder that back research_discovery — see Discovery — and the EmbeddingScheduler/SqliteVecDocumentBackend/SqliteVecNoteBackend that back embedding-based vector search over document chunks and notes — see Search → Vector search.

Provider groupings

Prior-art sources are grouped by domain, not treated as one flat list of sources. Each grouping gets its own provider interface, because the operations and data shapes that make sense for one domain do not necessarily fit another:

  • ResearchPublicationProvider — research publications (arXiv, Europe PMC, and a local filesystem source in v1). Specified below.
  • PatentProvider (future, out of scope for v1) — patents. Expected to need claims, legal/family status, and citation-graph concerns that research publications don't have.

A source (e.g. arXiv) is one concrete implementation of the provider interface for its grouping. Adding a source means implementing the grouping's interface, not changing the interface itself.

Per-grouping storage isolation. SearchIndex, NotesBackend, StorageBackend, and VectorSearchBackend (see Storage and Search → Vector search) are, today, single, corpus-wide instances spanning every fetched document. That's fine while ResearchPublicationProvider is the only grouping that exists, but would stop holding the moment a second grouping (PatentProvider, or similar) ships: patent claims and research concepts sharing one search/vector index would collide on terminology neither grouping's data was ever meant to be interpreted against — the same reasoning that already justifies patents getting their own provider interface above, not folding into ResearchPublicationProvider.

The mechanical seam for this already exists: every backend takes its storage location as an explicit constructor argument rather than deriving it internally, so isolating a grouping is a matter of constructing it against a different directory, not an interface change. providers/grouping.py's grouping_dir(base, grouping) resolves this — returning base unchanged for DEFAULT_GROUPING = "research" (so today's on-disk layout needs no migration) and a base/<grouping> subtree for anything else — and server.py constructs StorageBackend/SearchIndex/NotesBackend/NotesSearchIndex through it, hardcoded to DEFAULT_GROUPING today.

What's still deliberately undesigned: the config/registry that would decide which groupings are active and route an incoming call to the right constructed instance — there's no second grouping yet to validate that design against, and PatentProvider itself doesn't exist. That part is deferred alongside PatentProvider, not solved by the seam above.

ResearchPublicationProvider

The capability names below (search, fetch_metadata, ...) name the shared interface every research-publication provider implements — they are not necessarily the public MCP tool names. See Functional requirements for how these map onto actual tools for arXiv and Europe PMC in v1.

Capabilities

Capability Description Relative cost
search Search items by keyword/query. Light
list_top_n List the top-N items across one or more provider-defined categories to include (and optionally exclude), where "category" is a provider-defined grouping (e.g. an arXiv subject class). v1 implements this for arXiv only — see below. Light
fetch_metadata Fetch metadata (title, authors, abstract, identifiers, category, dates, links, ...) for one or more items in a single call — batching is worthwhile precisely because rate limiting (see below) makes N separate single-item calls costlier than one call for N identifiers. Light
resolve_identifier Given an identifier already known to belong to this provider, resolve it to a fetchable URL in the target format (e.g. HTML, PDF), pinning a canonical version where the provider's identifiers are mutable. This is an internal, provider-native capability — see below. Light
fetch_full_text Fetch the full text of a single item, in a given format, from its resolved URL. Heavy (network I/O)
parse_full_text Convert previously-fetched full text into Markdown. Accepts an optional, PDF-only page param to page by physical PDF page rather than raw character offset — see below. Heavy (CPU-bound)
list_fetched Enumerate previously-persisted (provider, identifier, format, artefact) entries in the storage abstraction, optionally filtered by provider/format. Not a per-provider capability — see below. Light
delete_fetched Remove one or more previously-persisted (provider, identifier, format, artefact) entries from storage. Not a per-provider capability — see below. Light
search_fetched Full-text search over previously-persisted chunks (or leaves, as a fallback for a document with none), optionally scoped to one provider/identifier/format — see SearchIndex and Storage → Full-text search below. Not a per-provider capability — see below. Light

fetch_metadata and fetch_full_text are deliberately separate operations, not two modes of one call. Metadata is small and cheap; full text means downloading and persisting a document (through the storage abstraction), which is a materially heavier operation. Keeping them separate lets a client (or the MCP tool surface) request metadata without paying the cost of a full-text fetch it doesn't need.

Not every provider necessarily implements every capability at full strength — a provider that can't sensibly support a capability should say so rather than fake it. list_top_n is the concrete v1 example of a partial gap: it's provider-defined ("category" means an arXiv subject class), and Europe PMC has no equivalent single classification field, only several parallel multi-valued tagging schemes (see Functional requirements → Europe PMC tools), so v1 simply doesn't implement list_top_n for Europe PMC rather than force a mismatched mapping. The local filesystem source below is the more extreme case of the same principle: it implements only fetch_full_text and parse_full_text, and says so explicitly, rather than faking search, list_top_n, fetch_metadata, or resolve_identifier against a source that has no query interface, no structured metadata authority, and no identifier scheme to route.

resolve_identifier

Converts an identifier a caller already has (DOI, arXiv ID, ...) into a URL that can actually be fetched over HTTP, given a requested target format (e.g. HTML, PDF). This is what fetch_full_text uses internally to know what to download — separating "which URL" from "go get it" keeps identifier/format resolution logic (which varies per provider and can change independently, e.g. if a source changes its URL scheme) out of the download path itself.

For providers whose identifiers don't have a fixed meaning over time — arXiv's unversioned IDs mean "whatever is currently latest" — resolve_identifier is also responsible for resolving to the current concrete, canonical (version-pinned) identifier, not just a URL. That canonical identifier, not the caller's original one, is what fetch_full_text and the storage layer use from that point on; see storage's identifier canonicalisation section for why this matters.

As described above, this is a provider-native capability: it assumes the caller already knows which provider owns the identifier (an arXiv ID is only ever an arXiv ID). It is not itself exposed as an MCP tool — it is invoked internally, by fetch_full_text and by the grouping-level routing below.

Identifier routing (grouping-level)

Not every identifier a caller has is provider-native. An arXiv ID is self-identifying — its shape alone says which provider owns it, no lookup required. A DOI is not: its prefix doesn't reveal whether the item is on arXiv, indexed by Europe PMC, or published somewhere PriorisMCP has no provider for at all. Routing an arbitrary identifier to the right provider is therefore a capability of the ResearchPublicationProvider grouping, sitting above any single provider, not something one provider can answer on another's behalf.

This is exposed as a single MCP tool, research_resolve_identifier (see Functional requirements), which routes as follows:

  • Self-identifying schemes (an arXiv ID, or Europe PMC's own identifier schemes) route directly to that provider's native resolve_identifier — no network round-trip is needed to know who owns them.
  • DOIs always resolve via the DOI system (a doi.org/Crossref redirect) first, before any provider-specific logic runs. If the resulting landing domain belongs to a v1 provider (arxiv.org, europepmc.org/NCBI PMC), routing hands off to that provider's native resolve_identifier from there.
  • If a DOI resolves to a domain that isn't a supported provider (e.g. a publisher's own site), routing fails with an unsupported provider error rather than attempting to scrape the landing page — see ADR-00003: DOI routing to an unsupported provider fails, rather than scraping for why.

list_fetched / delete_fetched (grouping-level)

Unlike search, fetch_metadata, fetch_full_text, and parse_full_text, enumerating or removing previously-persisted content has no provider-specific shape to validate. Those four capabilities went per-provider (see Functional requirements → Tool surface) specifically because identifier patterns and format enums genuinely differ per provider — a tight schema needs to know which provider it's validating against. list_fetched and delete_fetched don't validate an identifier's shape at all: they operate on catalogue entries the storage abstraction already recorded at fetch/parse time (provider, identifier, format, artefact, fetch/parse timestamp, size — see Storage → The catalogue), which are structurally identical regardless of which provider produced them. This is the same reasoning that already put resolve_identifier's routing above any single provider — a capability that doesn't need provider-specific validation logic shouldn't be duplicated once per provider.

Exposed as three grouping-level MCP tools, research_list_fetched, research_delete_fetched, and research_search_fetched (see Functional requirements):

  • research_list_fetched accepts optional provider/format filters and returns matching catalogue entries — this is how a caller discovers what's already persisted, which matters in particular for the local filesystem source below, whose identifiers are server-assigned rather than caller-chosen (an arXiv or Europe PMC identifier, by contrast, is one the caller already typed to fetch it, so listing is a convenience there rather than the only way to recover it).
  • research_delete_fetched accepts one or more (provider, identifier, format, artefact) entries and removes them from storage — artefact is document, markdown, or all (see Storage → Deletion is per-artefact, not per-format) — tolerating a request that names an entry no longer present (see Functional requirements → research_delete_fetched) rather than failing the whole batch, the same partial-failure tolerance fetch_metadata already has for unrecognised identifiers.
  • research_search_fetched accepts a free-text query plus optional provider/identifier/format filters, and returns ranked matches against previously-persisted chunks (or, for a document with none, its leaves) via the SearchIndex abstraction below (see Storage → Full-text search) — this is the caller-facing surface for that index; without it, the index would exist in storage with no way for a caller to reach it. A result's reported offset is the matched entry's own position in the document's coordinate space, not an index-internal offset (e.g. FTS5's own offsets(), in v1's particular implementation), so a caller can re-fetch surrounding context via parse_full_text's own offset/limit parameters.
  • None of the three tools triggers a fetch, a parse, or any outbound network request — they operate purely on the storage abstraction's own catalogue/index.
  • This addresses a correctness need distinct from the deferred retention/eviction work: eviction is a disk-management policy for content that's still valid but old; research_delete_fetched is for a caller-driven mistake (e.g. the wrong arXiv ID was fetched) that has nothing to do with age or disk pressure. v1's local filesystem backend has no other way to do this without bypassing the storage abstraction entirely (e.g. rm on the underlying files), which stops working the moment a non-filesystem backend (see Storage → Future: S3) is in use.

SearchIndex

Full-text search is a separate abstraction from StorageBackend, not one of its operations — see ADR-00004: SearchIndex as a separate interface from StorageBackend for why. Its interface, v1's SQLite + FTS5 implementation, and the embedding-based/graph-based mechanisms it grows alongside are covered in full in Search; this section only establishes where full-text search sits in the architecture.

parse_full_text

Converts full text already retrieved by fetch_full_text into Markdown. It is a distinct, third operation alongside fetch_full_text — not a mode or flag on it — for the same reason fetch_metadata and fetch_full_text are kept apart: fetch_full_text is network-bound, parse_full_text is CPU-bound, and collapsing them would mean every parse implicitly pays for (and depends on the availability of) a network fetch.

parse_full_text operates only on content the storage abstraction already has; it must not silently trigger a fetch_full_text call. If the requested item/format has not been fetched yet, or was fetched but is no longer present in storage, parse_full_text fails with a single "not found" error that names the missing item and format, telling the caller to fetch_full_text it first — v1 does not distinguish "never fetched" from "fetched then evicted" as separate error cases. This keeps each capability's cost and side effects predictable and explicit rather than having a "just parse this" call unexpectedly perform a network fetch — parse_full_text is never a superset of fetch_full_text. It also gives the calling LLM a decision point: on seeing the error, it can call fetch_full_text itself or defer to the user for consent before doing so, rather than a fetch happening as an invisible side effect of what looked like a parse request.

Character-offset pagination (offset/limit, see Non-functional requirements → Response size) applies to every format, but gives no stable, citable locator back to the source — a character offset isn't something a human can independently verify the way "page 7" is, and it isn't stable across parser-output changes. For PDF full text specifically (arXiv format="pdf" and the local filesystem source — both parsed via the same PDF backend), parse_full_text additionally accepts an optional, 1-indexed page param: when given, it resolves to that page's starting offset via the per-document manifest, and the existing offset param becomes relative to that page's start (default 0) rather than the document's. Passing page against a non-PDF format (arXiv format="html", Europe PMC) is a hard invalid_request error, not silently ignored — those formats have no native page concept to page by. The response always reports total_pages and the page_range the returned slice actually spans, computed against that manifest from whatever absolute offset/limit was actually used, so citability works even for a caller driving by raw offset and never touching page.

Converting full text to Markdown is implemented via a pluggable ParserBackend interface, one per source format (PDF, HTML/JATS), mirroring StorageBackend's interface-plus-swappable-implementation shape rather than being hardcoded to one library per format. ParserBackend.to_markdown() returns {"markdown": str, "leaf_spans": list[{"start", "length"}]}, not a bare string: leaf-level structure (PDF pages; a single trivial span for whole-document formats with no native page concept) is source-format-specific and unrecoverable once a format's content is joined into one blob, so it has to come from the backend itself, at the point the blob is assembled, rather than being inferred from the blob afterwards. Chunk-level structure (heading-bounded sections) is deliberately not part of this return value: it's computed by a single downstream, format-agnostic Markdown-heading-walker that runs uniformly over the assembled blob for every format, once rendered content reaches genuine ATX headings — see Storage → Per-document structure for the algorithm.

v1 ships exactly one backend per format: LiteParsePdfBackend (via liteparse) for arXiv's and the local filesystem source's PDF full text — see ADR-00001: PDF parsing backend for why over docling — and a shared HTML-to-Markdown backend (html-to-markdown) for arXiv's HTML full text, reused for Europe PMC's JATS XML via an XSLT transform to HTML first (JatsXsltMarkdownBackend, a vendored NCBI stylesheet carrying two purpose-built fixes for heading-depth calculation and MathML-to-LaTeX conversion — see ADR-00002: HTML/JATS parsing pipeline). A different backend for a format (e.g. a slower but more sophisticated general-purpose document parser for PDF, or a boilerplate-removal-oriented converter for messier HTML once a future non-arXiv source needs it) can be swapped in later by adding an implementation of the interface and selecting it via configuration, without changing the interface itself — the same pattern Storage already uses for its own future S3 backend. See Non-functional requirements → Dependency selection for the criteria v1's backend choices are held to.

Extracted PDF images (see Storage → Future: extracted PDF images) are an optional, off-by-default additional output of LiteParsePdfBackend — not part of parse_full_text's own return value, since they're persisted as separate artefacts and exposed as their own MCP resources rather than inlined into the Markdown result.

Local filesystem source

The local filesystem source parses a PDF the caller already has — typically obtained through the user's own out-of-band, authenticated access (e.g. a paywalled paper downloaded through an institutional subscription) — rather than retrieving anything itself over the network. The caller sends the PDF's content directly (base64-encoded), not a path: fetch_full_text accepts a server-side path only when the MCP client and server process share a filesystem, which holds for stdio transport but not streamable-http/http, where "local" would otherwise mean local to wherever the server happens to run rather than local to the caller — see Security → Local filesystem access means access to the caller's own content, not the server's disk. It reuses the existing PDF parser backend from parse_full_text above unchanged; what's new is how fetch_full_text and identity work for a source with no external API, no search/metadata authority, and no identifier scheme of its own.

Only fetch_full_text and parse_full_text are implemented, per the capability table above: there is nothing to search or list_top_n against (no query interface exists), no authoritative fetch_metadata (PDF-internal metadata, where present at all, is a caller-supplied document's own unverified claims about itself, not a structured record from a source PriorisMCP trusts the way arXiv's or Europe PMC's APIs are) — and no resolve_identifier, for the more fundamental reason below.

No canonical identifier exists to resolve. resolve_identifier's job for arXiv and Europe PMC is pinning a possibly-mutable identifier to an immutable version before it's used as a storage key (see resolve_identifier above) — this works because each provider is itself the authority on what "the current version" means. Caller-sent content has no such authority, so fetch_full_text substitutes content hashing for version pinning instead — see ADR-00005: Local-filesystem source identity via content hashing for why a filename/path can't play that role. Every call decodes the caller-sent bytes and hashes them (SHA-256), and that hash — not any path or filename — becomes the canonical identity used for the storage key, exactly the role a version-pinned arXiv ID plays for that provider (see Storage → Content-hash canonicalisation for the local filesystem source). This is cheap (a local base64 decode, not a network round-trip), so it runs unconditionally on every fetch_full_text call rather than being gated behind an explicit force-refetch flag: a hash match skips the storage write; a hash mismatch persists the new content under its own new identity, leaving any identifier a caller was previously given for the old content valid and unaffected — the same non-destructive-update guarantee arXiv's own versioning already provides.

Cost is light, not heavy, unlike arXiv/Europe PMC's fetch_full_text — decoding caller-sent base64 is cheap, not a rate-limited network request, so the local filesystem source's fetch_full_text is not subject to Caching and rate limiting below at all: there is no outbound request to rate-limit, and nothing for ResponseCachingMiddleware to usefully cache that the storage abstraction's own exists check doesn't already cover.

For large files at risk of hitting transport/relay size ceilings (the mcp SDK's ~4 MiB HTTP body cap on streamable-http/http, or client-orchestration truncation on any transport), a session-based chunked upload flow (research_localfile_begin_upload / research_localfile_upload_chunk / research_localfile_finalize_upload) is available alongside the single-call research_localfile_fetch_full_text — see Interface specification → Local filesystem. Chunks are buffered in memory per session (not disk-spooled) and reassembled only at finalize_upload, which runs the exact same validation/persistence path as the single-call tool.

Content model

fetch_full_text returns a typed content result, not a bare blob — at minimum a format (e.g. PDF, HTML; ePub or others as providers require) alongside the content/location. arXiv, for instance, exposes both PDF and HTML full text for the same item, so the interface treats format as a first-class, provider/item-dependent property rather than assuming one fixed format across all sources.

Metadata results are similarly typed, but as structured fields (title, authors, abstract, identifiers, etc.) rather than a format-tagged document.

resolve_identifier returns a URL alongside the format it resolves to (since the same identifier can resolve to different URLs for different formats); the grouping-level research_resolve_identifier tool additionally returns which provider will service subsequent calls for that identifier. parse_full_text's result is always Markdown — it does not carry a format tag, since converting to Markdown is the point of the operation.

Caching and rate limiting

Response caching is not a provider concern. The server already applies ResponseCachingMiddleware (see server.py) to tool/resource calls; search, list_top_n, and fetch_metadata results are cached there like any other tool response. Providers should not implement their own duplicate caching layer.

Rate limiting is a provider concern, and a distinct one from caching: it exists to satisfy each source's terms of use (e.g. arXiv's API rate limits), not to avoid redundant work, and applies per outbound request to the source regardless of whether the response ends up cached. See Non-functional requirements for how rate limiting behaves under concurrent tool invocations.

NotesBackend

v2, not v1 — see SRS overview → Scope. NotesBackend is a new abstraction, a sibling to StorageBackend/SearchIndex rather than an extension of either: it persists user-authored notes (issue #13), which are identity-addressed and mutable, the opposite of StorageBackend's content-addressed, write-once fetched content. Its interface, data model, and storage layout are covered in full in Notes storage; this section covers the architectural decisions specific to it.

Anchoring: document-level, not span-level

A note is keyed by (provider, canonical_identifier, format), not anchored to a specific span inside a document's manifest.sqlite — a deliberate departure from manifest.sqlite's own leaf/chunk span model. See ADR-00006: Notes anchoring — document-level, not span-level for the four independent reasons span-level anchoring was rejected.

Positional information isn't dropped, though: a note may carry anchors, unresolved and unenforced positional hints (page/heading/paragraph plus a W3C Web Annotation-style text-quote selector — see Notes storage → Data model) that a human (or an agent acting on explicit user input) can use to relocate a passage by eye, never validated or resolved against manifest.sqlite.

Listing is search with no filters, not a separate tool

Unlike list_fetched/search_fetched above — genuinely separate StorageBackend tools because list_fetched is structurally simpler, with no FTS5 involvement at all — NotesBackend.search already collapses to plain structural filtering, with no ranking and no FTS5 query, whenever no keyword is given: the same code path as a full keyword search, not a genuinely separate, simpler one. A second list tool would duplicate that query logic for no reason, so "list everything" is research_notes_search with no filters, paginated.

Export is a resource, not a tool

notes://{note_id}/export is a resource, not a tool, and takes only a note's id — not search's filter set. See ADR-00007: Notes export as a resource, not a tool for why, including the security-boundary angle.