Skip to main content
Glama

mcp-books

npm CI license MCP Registry Glama M8ven LobeHub Install in Cursor Install in VS Code

Three great archives hold the scanned record of what was published, and each describes it in its own words. The Internet Archive keeps books, films, recordings and software deposited by anyone, and has run millions of them through optical character recognition. The Library of Congress publishes the national collections of the United States, one catalogue per kind of material. data.bnf.fr publishes the authority records of the Bibliothèque nationale de France, which describe works and the people who wrote them rather than copies.

This server reads all three with one question. You can search the words inside the scanned documents, search the catalogues, and read one record in a single shape whichever archive holds it. It needs no API key and no account.

Version française


Install

One-click install

Install in Cursor Install in VS Code

Claude Code

claude mcp add books -- npx -y mcp-books

Claude Desktop, Cursor, and any client using the standard config format

{
  "mcpServers": {
    "books": {
      "command": "npx",
      "args": ["-y", "mcp-books"]
    }
  }
}

Node 24 or later is required, and no environment variable has to be set.

With Docker

{
  "mcpServers": {
    "books": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "ghcr.io/smeet666/mcp-books:2.0.1"]
    }
  }
}

-i keeps stdin open, which is where the protocol travels, and -t is left out because a TTY rewrites the stream. The container needs outbound HTTPS to archive.org, openlibrary.org, www.loc.gov and data.bnf.fr, and nothing else: no volume, no port, no credential.

Bundle, without npm

Download mcp-books-2.0.1.mcpb from the latest release and open it. A client that supports MCP bundles installs it on its own, with no npm and no configuration file to edit. The bundle carries its dependencies, so nothing is fetched at install time.

Related MCP server: anna-book-search

What you can ask

  • "Which books mention the Beaumont light-house?"

  • "Find me anything on the 1906 San Francisco earthquake."

  • "Read me that record and tell me who holds the original."

  • "What does the BnF have on that author?"

  • "Search the photographs rather than the books."

An answer takes several seconds: three archives are asked, each at its own pace.

The three sources

Source

Archive

What it describes

archive

the Internet Archive

deposited copies, of every kind

loc

the Library of Congress

the national collections, one catalogue per kind

bnf

the Bibliothèque nationale de France

works and the people who wrote them

A row's id names its archive, so an identifier read from one answer goes back to the right one. Counts are never added across archives, and an archive that failed is reported as having failed rather than as having found nothing.

Tools

Tool

What it does

search_inside

Searches the words inside the scanned documents.

search_items

Searches the catalogues by title, creator, subject or plain words.

get_item

Reads one record in a single shape, whichever archive holds it.

search_inside

Searches the text inside the scanned documents, which came off the page through optical character recognition.

Argument

Type

Required

What it does

query

string, 2 to 300 characters

yes

The phrase to look for inside the documents.

limit

integer, 1 to 25, default 3

no

Matches to keep from each archive.

page

integer, 1 to 100, default 1

no

Which page of matches.

max_excerpt_chars

integer, 80 to 1200, default 300

no

How much of a passage to serve.

max_excerpts_per_match

integer, 1 to 10, default 2

no

Passages served per matching document.

fan_out

boolean, default true

no

Ask every archive rather than stopping at the first that answers.

sources

array of source ids

no

Ask these archives alone.

In return: hits, each carrying id, which get_item takes and which names its archive; source and source_name; the archive's own identifier without the prefix; title, creator and year; page_number where the archive states one; excerpts; and excerpt_kind.

excerpt_kind decides what an excerpt is worth. A passage is the text around the words that matched. A page_opening is the start of the page, sent because the machine-read text the archive returned stops before those words appear: it does not carry the match, so quoting it quotes something else. All the excerpts of one match are of one kind.

search_items

Searches the catalogues.

Argument

Type

Required

What it does

query

string, 1 to 300 characters

yes

A title, a creator, a subject, or plain words.

media_type

a kind one of the archives holds

no

Which kind of material to search.

year_from

integer, 1000 to 2100

no

Earliest year.

year_to

integer, 1000 to 2100

no

Latest year.

sort

relevance, newest, oldest or title, default relevance

no

How the rows are ordered.

limit

integer, 1 to 25, default 5

no

Rows to keep from each archive.

page

integer, 1 to 100, default 1

no

Which page of rows.

fan_out

boolean, default true

no

Ask every archive.

sources

array of source ids

no

Ask these archives alone.

The three archives divide their material differently. The Internet Archive searches every kind at once when none is named; the Library of Congress is one route per kind, so a search naming none is told which one was read; and the BnF search reads works. A media_type one archive has no notion of leaves that archive out, and the answer says so.

In return: rows in the shape a hit carries, with per_source giving one report per archive: its status, the count it contributed, its reported_total and reported_total_means, which says what that number counts there.

get_item

Reads one record in a single shape, whichever archive holds it.

Argument

Type

Required

What it does

identifier

string, 1 to 500 characters

yes

The id a row carries.

sections

array of description, subjects, copies, context, default ["description"]

no

Which parts to return.

max_copies

integer, 1 to 50, default 10

no

Copies to list.

text_offset

integer, 0 to 1000000, default 0

no

Where to resume the text.

max_text_chars

integer, 200 to 8000, default 1500

no

How much text to serve.

In return: the record with its id, source and source_name, the archive's own identifier, title, creator, date exactly as published, and year beside year_means, which says what that year is the year of, since the three archives date a record differently. attribution is what that archive asks to be credited with, and identifier_provisional says when the identifier was built rather than read, so a caller knows it may not resolve.

What an answer states about the archives

Every answer accounts for each archive separately. One that failed, one nobody asked, and one that answered with nothing are three different things, and they are reported as three. A total stays beside the archive that published it, with what that archive counts when it says it: one counts documents, another counts newspaper leaves.

What scanned text is worth

The words inside a scanned document came off the page through optical character recognition. An excerpt carries the misreadings of that process, and it is served as it was read rather than corrected. Quote it as scanned text, and link the record so a reader can look at the page.

Configuration

Every variable is optional. Set them in the env block of your client config.

Variable

Default

What it does

BOOKS_USER_AGENT

the project identity

Names your application to the three archives, with an address where a person can be reached.

BOOKS_MIN_INTERVAL_MS

each archive's own pace

Widens the gap between two requests to one archive, from 500 to 60000. Left unset, every archive keeps the pace it publishes, and a figure set here applies only where it is wider.

BOOKS_TIMEOUT_MS

45000

Deadline for one request, from 1000 to 120000.

BOOKS_MAX_RETRIES

3

Attempts after a transient failure, from 0 to 8.

BOOKS_CACHE_TTL_MS

900000

How long an answer stays in memory, from 0 to 86400000.

BOOKS_CACHE_MAX_ENTRIES

200

Answers held in memory at once, from 1 to 5000.

BOOKS_LOG_LEVEL

error

silent, error, info or debug, written to stderr.

A value outside its range falls back to the default, and the reason is written to stderr.

Errors

Every failure carries one of six codes, a message, and where it helps a hint naming the next move.

Code

What happened

What to do

not_found

An archive answered, and holds no such record.

Check the identifier with search_items.

invalid_input

The arguments were refused before any request went out.

Read the message, which names the argument.

rate_limited

An archive asked this client to slow down.

Wait, then call again with the same arguments. The record is still there.

parse_failure

An answer arrived in a shape this client cannot read.

Report it at the issue tracker.

network_error

The request did not complete.

Try again shortly.

timeout

The request passed its deadline.

Raise BOOKS_TIMEOUT_MS, or ask for fewer rows.

An archive that failed is reported per archive rather than failing the whole answer, so one silent archive never hides the others.

As a library

The layer reading the three archives is published on its own, with its pacing, its cache and its errors, and with no protocol attached.

import { BooksClient } from "mcp-books/client";

const client = new BooksClient();
const read = await client.searchItems({ query: "beaumont light-house", limit: 3 });
console.log(read.data.rows.length);

Each read answers { data, cached }, and throws an error carrying one of the six codes. Each archive keeps its own pace, and its floor holds here as well.

Pacing and attribution

Each archive is paced on its own, one request at a time, and the widest of its own floor and the configured interval governs: the Library of Congress publishes the slowest, and asking all three at once therefore costs each of them one request rather than three. The User-Agent always ends with the project identity and an address where a person can be reached.

Every record carries the address of its page and the attribution its archive asks for. The Internet Archive items belong to their depositors, the Library of Congress records state their own rights, and the BnF asks that the source and the date of retrieval be stated wherever its metadata are shown.

This MCP server is an unofficial project, with no affiliation to any of the archives it reads.

Privacy

This server collects nothing about you and sends nothing to its author. It runs on your machine, contacts archive.org, openlibrary.org, www.loc.gov and data.bnf.fr and nothing else, holds its answers in memory while it runs, and writes nothing to disk. PRIVACY.md states what a request carries and which settings change any of it.

Development

npm install
npm run build:fixtures
npm test
npm run check

Tests run against generated fixtures and make no network request. The live suite, npm run test:live, makes one request per route and runs nightly against the archives themselves.

Contributing

Bugs, questions and ideas belong in the issue tracker. Pull requests are welcome; opening an issue first helps agree on the shape of the change. See CONTRIBUTING.md.

License

MIT, see LICENSE. The records belong to the archives that published them and to their depositors.


mcp-books (français)

English version

Trois grandes archives conservent la trace numérisée de ce qui a été publié, et chacune la décrit dans ses propres mots. L'Internet Archive garde les livres, les films, les enregistrements et les logiciels que chacun y dépose, et en a passé des millions par la reconnaissance optique de caractères. La Library of Congress publie les collections nationales des États-Unis, un catalogue par type de document. data.bnf.fr publie les notices d'autorité de la Bibliothèque nationale de France, qui décrivent des œuvres et ceux qui les ont écrites plutôt que des exemplaires.

Ce serveur lit les trois avec une seule question. On peut chercher dans les mots contenus dans les documents numérisés, chercher dans les catalogues, et lire une notice sous une forme unique quelle que soit l'archive qui la détient. Aucune clé d'API, aucun compte.

Installation

Installation en un clic

Install in Cursor Install in VS Code

Claude Code

claude mcp add books -- npx -y mcp-books

Claude Desktop, Cursor, et tout client au format de configuration standard

{
  "mcpServers": {
    "books": {
      "command": "npx",
      "args": ["-y", "mcp-books"]
    }
  }
}

Node 24 ou plus récent est nécessaire, et aucune variable d'environnement n'est à renseigner.

Avec Docker

{
  "mcpServers": {
    "books": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "ghcr.io/smeet666/mcp-books:2.0.1"]
    }
  }
}

-i garde l'entrée standard ouverte, qui est le canal du protocole, et -t est omis parce qu'un TTY réécrit le flux. Le conteneur a besoin d'un accès HTTPS sortant vers archive.org, openlibrary.org, www.loc.gov et data.bnf.fr, et de rien d'autre : aucun volume, aucun port, aucun identifiant.

Bundle, sans npm

Téléchargez mcp-books-2.0.1.mcpb depuis la dernière publication et ouvrez-le. Un client qui gère les bundles MCP l'installe seul, sans npm et sans fichier de configuration à modifier. Le bundle emporte ses dépendances, donc rien n'est téléchargé à l'installation.

Ce qu'on peut demander

  • « Quels livres mentionnent le phare de Beaumont ? »

  • « Trouve-moi ce qu'il y a sur le tremblement de terre de San Francisco en 1906. »

  • « Lis-moi cette notice et dis-moi qui conserve l'original. »

  • « Qu'est-ce que la BnF a sur cet auteur ? »

  • « Cherche dans les photographies plutôt que dans les livres. »

Une réponse prend plusieurs secondes : trois archives sont interrogées, chacune à son rythme.

Les trois sources

Source

Archive

Ce qu'elle décrit

archive

l'Internet Archive

les exemplaires déposés, de tout type

loc

la Library of Congress

les collections nationales, un catalogue par type

bnf

la Bibliothèque nationale de France

les œuvres et ceux qui les ont écrites

L'id d'une ligne nomme son archive, donc un identifiant lu dans une réponse retourne vers la bonne. Les comptes ne sont jamais additionnés entre archives, et une archive qui a échoué est rapportée comme ayant échoué plutôt que comme n'ayant rien trouvé.

Les outils

Outil

Ce qu'il fait

search_inside

Cherche dans les mots contenus dans les documents numérisés.

search_items

Cherche dans les catalogues par titre, auteur, sujet ou mots simples.

get_item

Lit une notice sous une forme unique, quelle que soit l'archive.

search_inside

Cherche dans le texte contenu dans les documents numérisés, texte issu de la reconnaissance optique de caractères.

Argument

Type

Requis

Ce qu'il fait

query

chaîne, 2 à 300 caractères

oui

La phrase à chercher dans les documents.

limit

entier, 1 à 25, défaut 3

non

Correspondances à garder de chaque archive.

page

entier, 1 à 100, défaut 1

non

Quelle page de correspondances.

max_excerpt_chars

entier, 80 à 1200, défaut 300

non

La longueur de passage à servir.

max_excerpts_per_match

entier, 1 à 10, défaut 2

non

Passages servis par document correspondant.

fan_out

booléen, défaut true

non

Interroger chaque archive plutôt que s'arrêter à la première qui répond.

sources

tableau d'identifiants de source

non

N'interroger que ces archives.

En retour : hits, chacun portant id, que get_item reprend et qui nomme son archive ; source et source_name ; l'identifier propre à l'archive, sans le préfixe ; title, creator et year ; page_number là où l'archive en indique un ; excerpts ; et excerpt_kind.

excerpt_kind décide de ce que vaut un extrait. Un passage est le texte autour des mots trouvés. Un page_opening est le début de la page, envoyé parce que le texte lu par machine que l'archive a rendu s'arrête avant que ces mots apparaissent : il ne porte pas la correspondance, donc le citer cite autre chose. Tous les extraits d'une correspondance sont d'un seul type.

search_items

Cherche dans les catalogues.

Argument

Type

Requis

Ce qu'il fait

query

chaîne, 1 à 300 caractères

oui

Un titre, un auteur, un sujet, ou des mots simples.

media_type

un type que l'une des archives détient

non

Le type de document à chercher.

year_from

entier, 1000 à 2100

non

Année la plus ancienne.

year_to

entier, 1000 à 2100

non

Année la plus récente.

sort

relevance, newest, oldest ou title, défaut relevance

non

L'ordre des lignes.

limit

entier, 1 à 25, défaut 5

non

Lignes à garder de chaque archive.

page

entier, 1 à 100, défaut 1

non

Quelle page de lignes.

fan_out

booléen, défaut true

non

Interroger chaque archive.

sources

tableau d'identifiants de source

non

N'interroger que ces archives.

Les trois archives découpent leurs fonds différemment. L'Internet Archive cherche dans tous les types à la fois quand aucun n'est nommé ; la Library of Congress a une route par type, donc une recherche qui n'en nomme aucun se voit dire lequel a été lu ; et la recherche de la BnF lit des œuvres. Un media_type dont une archive n'a pas la notion l'écarte de la réponse, et la réponse le dit.

En retour : des lignes dans la forme d'un hit, avec per_source qui donne un rapport par archive : son status, le count qu'elle a fourni, son reported_total et reported_total_means, qui dit ce que ce nombre compte là-bas.

get_item

Lit une notice sous une forme unique, quelle que soit l'archive qui la détient.

Argument

Type

Requis

Ce qu'il fait

identifier

chaîne, 1 à 500 caractères

oui

L'id que porte une ligne.

sections

tableau de description, subjects, copies, context, défaut ["description"]

non

Les parties à rendre.

max_copies

entier, 1 à 50, défaut 10

non

Exemplaires à lister.

text_offset

entier, 0 à 1000000, défaut 0

non

Où reprendre le texte.

max_text_chars

entier, 200 à 8000, défaut 1500

non

La longueur de texte à servir.

En retour : la notice avec son id, source et source_name, l'identifier propre à l'archive, title, creator, date exactement telle que publiée, et year accompagné de year_means, qui dit de quoi cette année est l'année, les trois archives datant une notice différemment. attribution est ce que cette archive demande qu'on lui crédite, et identifier_provisional dit quand l'identifiant a été construit plutôt que lu, pour qu'un appelant sache qu'il peut ne pas résoudre.

Ce qu'une réponse dit des archives

Chaque réponse rend compte de chaque archive séparément. Une qui a échoué, une que personne n'a interrogée et une qui a répondu vide sont trois choses différentes, et elles sont rapportées comme trois. Un total reste à côté de l'archive qui l'a publié, avec ce que cette archive compte en le disant : l'une compte des documents, une autre des feuillets de journaux.

Ce que vaut un texte numérisé

Les mots contenus dans un document numérisé sont issus de la reconnaissance optique de caractères. Un extrait porte les erreurs de lecture de ce procédé, et il est servi tel qu'il a été lu plutôt que corrigé. Citez-le comme un texte numérisé, et liez la notice pour qu'un lecteur puisse regarder la page.

Configuration

Chaque variable est facultative. Elles se posent dans le bloc env de la configuration du client.

Variable

Défaut

Ce qu'elle fait

BOOKS_USER_AGENT

l'identité du projet

Nomme votre application auprès des trois archives, avec une adresse où joindre une personne.

BOOKS_MIN_INTERVAL_MS

le rythme propre à chaque archive

Élargit l'écart entre deux requêtes vers une même archive, de 500 à 60000. Non posée, chaque archive garde le rythme qu'elle publie, et une valeur posée ici ne s'applique que là où elle est plus large.

BOOKS_TIMEOUT_MS

45000

Délai d'une requête, de 1000 à 120000.

BOOKS_MAX_RETRIES

3

Tentatives après un échec passager, de 0 à 8.

BOOKS_CACHE_TTL_MS

900000

Durée pendant laquelle une réponse reste en mémoire, de 0 à 86400000.

BOOKS_CACHE_MAX_ENTRIES

200

Réponses gardées en mémoire à la fois, de 1 à 5000.

BOOKS_LOG_LEVEL

error

silent, error, info ou debug, écrit sur la sortie d'erreur.

Une valeur hors de sa plage retombe sur le défaut, et la raison est écrite sur la sortie d'erreur.

Erreurs

Chaque échec porte un des six codes, un message, et quand cela aide une indication du geste suivant.

Code

Ce qui s'est passé

Que faire

not_found

Une archive a répondu, et n'a pas cette notice.

Vérifiez l'identifiant avec search_items.

invalid_input

Les arguments ont été refusés avant toute requête.

Lisez le message, qui nomme l'argument.

rate_limited

Une archive demande à ce client de ralentir.

Attendez, puis rappelez avec les mêmes arguments. La notice est toujours là.

parse_failure

Une réponse est arrivée dans une forme illisible ici.

Signalez-le sur le suivi d'incidents.

network_error

La requête n'a pas abouti.

Réessayez sous peu.

timeout

La requête a dépassé son délai.

Augmentez BOOKS_TIMEOUT_MS, ou demandez moins de lignes.

Une archive qui échoue est rapportée archive par archive plutôt que de faire échouer toute la réponse, donc une archive silencieuse n'en cache jamais d'autres.

Comme bibliothèque

La couche qui lit les trois archives est publiée seule, avec son rythme, son cache et ses erreurs, sans protocole attaché.

import { BooksClient } from "mcp-books/client";

const client = new BooksClient();
const read = await client.searchItems({ query: "beaumont light-house", limit: 3 });
console.log(read.data.rows.length);

Chaque lecture répond { data, cached }, et lève une erreur portant un des six codes. Chaque archive garde son propre rythme, et son plancher tient également ici.

Rythme et attribution

Chaque archive est cadencée pour elle-même, une requête à la fois, et c'est le plus large de son propre plancher et de l'intervalle configuré qui gouverne : la Library of Congress publie le plus lent, et interroger les trois à la fois coûte donc à chacune une requête plutôt que trois. Le User-Agent se termine toujours par l'identité du projet et une adresse où joindre une personne.

Chaque notice porte l'adresse de sa page et l'attribution que son archive demande. Les documents de l'Internet Archive appartiennent à ceux qui les ont déposés, les notices de la Library of Congress énoncent leurs propres droits, et la BnF demande que la source et la date de récupération soient indiquées partout où ses métadonnées sont montrées.

Ce MCP est un projet non officiel, sans affiliation à aucune des archives qu'il lit.

Confidentialité

Ce serveur ne collecte rien sur vous et n'envoie rien à son auteur. Il tourne sur votre machine, ne joint que archive.org, openlibrary.org, www.loc.gov et data.bnf.fr, garde ses réponses en mémoire le temps qu'il tourne, et n'écrit rien sur le disque. PRIVACY.md dit ce qu'une requête emporte et quels réglages changent cela.

Développement

npm install
npm run build:fixtures
npm test
npm run check

Les tests s'exécutent sur des fixtures engendrées et n'émettent aucune requête. La suite en direct, npm run test:live, émet une requête par route et tourne chaque nuit contre les archives elles-mêmes.

Contribuer

Les anomalies, les questions et les idées ont leur place dans le suivi d'incidents. Les propositions de modification sont bienvenues ; ouvrir un ticket d'abord aide à s'accorder sur la forme du changement. Voir CONTRIBUTING.md.

Licence

MIT, voir LICENSE. Les notices appartiennent aux archives qui les ont publiées et à ceux qui les y ont déposées.

Available Tools

3 tools
get_itemRead one record, routed by the archive its identifier namesA
Read-onlyIdempotent

Read one record in full from the archive its identifier names: what it is, who made it, when, what the archive says about it, and what a reader can open. 'identifier' must come from search_inside or search_items. It names the archive, so this reads the right one without guessing; a string no archive would have minted is refused, and a shape more than one archive mints is refused rather than sent to a guess, because sending it anywhere answers about the wrong thing. Terms of reuse come back on every read and belong to that record alone. A record stating none has granted nothing, and silence is never read as permission. 'sections' decides what else comes back, and the answer names what was left out and what this archive files nothing under: a field empty because nobody asked for it is a different thing from a field the archive never fills. 'description_means' says what the archive files under the field this reads a description out of, which holds an account of the thing on one record and a line of the catalogue entry on the next. Long prose is returned one window at a time: 'text_offset' says where to resume, and a window ends at a line boundary. An offset past the end says so rather than answering with an empty description. An answer can take several seconds, because one of the archives publishes a request ceiling this server keeps to.

ParametersJSON Schema
NameRequiredDescriptionDefault
sectionsNoWhich parts to return besides the record's identity and its terms of reuse.
identifierYesFrom a search, such as 'archive:mobydickorwhale01melv' or 'loc:sn83030214/1900-01-01/ed-1/seq-1'.
max_copiesNoCopies to list. The answer says how many more the record holds.
text_offsetNoWhere to resume in the record's prose, in characters from its start.
max_text_charsNoCharacters of prose to return. The text block shows roughly the first 1400 of them and the whole window is in the structured output. The answer says where to resume.

Output Schema

ParametersJSON Schema
NameRequiredDescription
itemYes
notesYes
id_read_asYesHow a raw identifier was routed, when it was not spelled with its archive.
text_windowYesWhich part of the record's prose this answer carries.
sections_omittedYesSections this call did not ask for. A field belonging to one of these is empty for that reason alone.
sections_returnedYes
fields_not_read_from_this_archiveYesFields this server reads nothing into from this archive. A field named here is empty for every record it returns, which is a different thing from a record that left it blank.

TDQS

A4.4/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description adds extensive behavior beyond readOnly/idempotent annotations: routing/refusal logic, terms-of-reuse semantics, empty-vs-absent fields, windowed text with line boundaries, offset past end behavior, and latency warning. No contradiction with annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is verbose and tangentially structured, with convoluted phrasing about identifier routing and a phantom parameter. While informative, it would benefit from tighter organization and shorter sentences. It is not concise but not entirely wasteful.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

The description handles many edge cases and behaviors (ambiguous identifiers, reuse terms, empty fields, pagination, latency) making it largely complete for a complex read tool. However, the phantom parameter and lack of explicit return-value summary (though output schema exists) prevent a top score.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Although the input schema already covers all parameters (100% coverage), the description adds context about identifier origin and routing, sections semantics, and text_offset windowing. However, it references a non-existent 'description_means' parameter, which could mislead the agent and detracts from clarity.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's function with a specific verb ('Read') and resource ('one record in full'), and distinguishes it from sibling search tools by requiring identifiers from search_inside/search_items and explaining archive routing. The title reinforces the purpose.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It implies usage after search tools and describes constraints on identifiers (refuses ambiguous/unminted strings), giving clear context. It doesn't explicitly list alternatives for exclusion, but the routing and identifier source make the intended use clear.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

search_insideFind a phrase in the scanned text of every archiveA
Read-onlyIdempotent

Search the machine-read text of every archive this server reads that holds any, at the same time, and get one list of where a phrase was printed. The archives behind this tool hold different material, so the list is additive: it is the places a phrase appears in each corpus, put together, rather than the same question answered twice. 'per_source' names what each corpus is. A match carries 'page_number', which is a real leaf on an archive whose index holds one and null on an archive whose index holds none. That null is the index having no leaf, never a page this server dropped, and no page is ever invented. A match also carries 'excerpt_kind'. 'passage' is the text around the words that matched. 'page_opening' is the start of the page, sent because the machine-read text that came back stops before the searched words appear, so it does not carry the match. The notes say how many excerpts are openings. Every count is that archive's own and counts something of its own: documents in one place, leaves in another. They are never added together, and there is no total across archives. Rows are interleaved one archive at a time. Nothing ranks them against each other and nothing orders them by date, because a year is measured on different things in each archive. Matches whose excerpt carries the searched words are placed before matches whose excerpt is a 'page_opening' and carries them nowhere. That rests on what each row states about its own excerpt rather than on any score, no match is ever dropped for it, and the interleaving holds inside each of the two groups. The archives read the words given in different ways, and 'per_source' says which each one does. An index that answers only where every word appears returns nothing for a question written as a sentence, even on a work it holds several copies of; an index that scores the words instead answers such a question with the pages it ranks highest, which can carry only some of them. Either way it is the words: a character that is neither a letter nor a digit is no word to an index, and 'non_word_characters' lists any the query carried. Shorter and differently spelled wordings are therefore derived from the query and asked for their union, which costs nothing extra when the words as asked already answer. Every wording sent is named in 'per_source' with what it returned, every match carries the wording that returned it in 'found_by_query', and 'fan_out' turns the derivation off. Use search_items for a work by its title, its creator or its subject: this tool reads the text on the pages and knows nothing of a catalogue, so a title given here finds every book that happens to mention it and misses the book itself. Answers take several seconds, because one of the archives publishes a request ceiling this server keeps to. A slow answer is the pacing, not a stall.

ParametersJSON Schema
NameRequiredDescriptionDefault
pageNoWhich page of matches, from 1. Each archive is paged separately.
limitNoMatches to take from each archive, so one archive cannot fill the whole list. The text block shows about six matches; the rest of what comes back is in the structured output.
queryYesWords, or a phrase in double quotes such as '"call me ishmael"'.
fan_outNoWhether to derive shorter and differently spelled wordings from the query and ask each archive for the union of what they return. A question written as a sentence returns nothing where every word given has to appear, and the rows an index scores highest where it does not. An archive is asked a derived wording only when the words as asked did not return as many rows as 'limit', so a query that works costs one request. Set false to send exactly the words given. 'per_source' names every wording, sent or not, and each match names the one that returned it.
sourcesNoArchives to ask, by id. Left out, every archive that reads machine-read text is asked, which is the point of this tool.
max_excerpt_charsNoBudget for one passage. Read it together with 'max_excerpts_per_match': the size of the answer is the product of the two, the limit, and the number of archives.
max_excerpts_per_matchNoPassages to keep per match. A long work matches in several places, and the later ones rarely say anything the first did not.

Output Schema

ParametersJSON Schema
NameRequiredDescription
hitsYes
pageYes
notesYes
orderYesHow the list was built, in words.
queryYes
hit_countYesMatches in this answer, across every archive. It is a count of what came back, never a total of what exists.
per_sourceYes
queries_runYesRequests this server sent for this answer, counting every wording on every archive. Each archive's own wordings are in 'per_source'.
excerpt_kindsYesHow many excerpts here are of each kind.
non_word_charactersYesCharacters in the query that are neither letters nor digits. These indexes answer on words, so a match here can carry none of them, and 'requires_every_word' covers the words that were given rather than these.

TDQS

A5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Discloses extensive behavioral context beyond the annotations: results are additive across archives with no total, page_number is null for archives without a leaf index, excerpt_kind distinguishes passages from page openings, ordering groups matches by excerpt type without ranking, and no match is ever dropped. Also explains the meaning of non_word_characters and the fan_out derivation. No contradiction with the readOnly/openWorld/idempotent annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Although lengthy, every sentence contributes unique value—covering result semantics, ordering, parameter behavior, and performance. The description is front-loaded with the core purpose and then systematically adds detail. No redundant restatement of schema or annotations.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

The description is exceptionally complete given the tool's complexity: it explains output semantics (per_source, page_number, excerpt_kind, found_by_query), inter-archive behavior, parameter interactions, edge cases like derived wordings, and performance expectations. The presence of an output schema does not reduce the need for this context, and it delivers.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, but the description adds significant meaning beyond the schema: it clarifies that 'fan_out' enables derived wordings and that 'per_source' names every wording sent, each match carries 'found_by_query', and 'max_excerpt_chars' and 'max_excerpts_per_match' multiply to form answer size. It also explains the query syntax and behavior for sentence queries.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Opens with a specific verb+resource+scope statement: 'Search the machine-read text of every archive this server reads... and get one list of where a phrase was printed.' It clearly distinguishes from siblings by explicitly telling the user to 'Use search_items for a work by its title, its creator or its subject' because this tool reads page text and knows nothing of a catalogue.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides explicit when-to-use and when-not-to-use guidance, naming the alternative tool (search_items) and explaining why a title query would fail here. Also details nuanced usage rules: how fan_out behaves, how results are combined across archives, and that answers take several seconds due to a request ceiling.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

search_itemsSearch every catalogue at onceA
Read-onlyIdempotent

Search the catalogue of every archive this server reads, at the same time, for a title, a creator or a subject, and get one merged list. Each row carries the id get_item takes, and that id names the archive it came from, so nothing has to be guessed afterwards. 'media_type' keeps one name across the archives and a vocabulary per archive, because the same word does not name the same set of things twice. An archive that files nothing under the name you give is not asked and is named as absent, with its own names listed, rather than asked under a translation. An archive that keeps one catalogue per kind of material is asked for its default when you name none, and the answer says which catalogue that was. Rows are interleaved one archive at a time. No score orders them against each other, and 'sort' is applied inside each archive rather than across them: a year is the date of an edition in one place and the date on a catalogue record in another, so there is no date order that spans the answer. 'oldest' and 'newest' order on a date field carrying a year and no era, so a date before the common era is filed there as a year of this one, and a record stating no date is placed by a stand-in rather than by its age. The first row of a date order is therefore not established as the oldest or newest thing an archive holds, the notes count the rows carrying no year, and this server orders nothing itself. Every count in 'per_source' is that archive's own and counts something of its own. They are never added together, and there is no total across archives. The catalogues read the words given in different ways, and 'per_source' says which each one does. One answers only where every word appears, so a question written as a sentence comes back empty; another scores the words and answers with the records it ranks highest, so a row of its can carry only some of them. Either way it is the words: a character that is neither a letter nor a digit is no word to an index, and 'non_word_characters' lists any the query carried. A name is also filed under more than one spelling, so further wordings are derived from the query and asked for their union. It costs nothing extra when the words as asked already answer. Every wording sent is named in 'per_source' with what it returned, every row carries the wording that returned it in 'found_by_query', and 'fan_out' turns the derivation off. A row's 'media_type' is the word that record carries for the kind of thing, which is often none of the names this argument takes: those are the divisions of a catalogue, and 'media_types' publishes them per archive. A row states no terms of reuse. Read the record with get_item for what that record itself says, and read silence as silence. Use search_inside for a phrase printed on a page: this tool reads catalogue records and knows nothing of what a book says, so a sentence given here matches only where a catalogue happens to carry it. Answers take several seconds, because one of the archives publishes a request ceiling this server keeps to.

ParametersJSON Schema
NameRequiredDescriptionDefault
pageNoWhich page of rows, from 1. Each archive is paged separately.
sortNoApplied inside each archive. The merged list stays interleaved, because no order runs across archives. 'oldest' and 'newest' run on a date field carrying a year and no era, and a record stating no date is placed by a stand-in, so neither end of such an order is a claim about age.relevance
limitNoRows to take from each archive, so one archive cannot fill the whole list.
queryYesA title, a creator, a subject, or plain words.
fan_outNoWhether to derive further wordings from the query and ask each archive for the union of what they return. A question written as a sentence returns nothing where every word given has to appear, and the records an index scores highest where it does not, and a spelling of a name is not the only one a catalogue files it under. An archive is asked a derived wording only when the words as asked did not return as many rows as 'limit', so a query that works costs one request. Set false to send exactly the words given. 'per_source' names every wording, sent or not, and each row names the one that returned it.
sourcesNoArchives to ask, by id. Left out, they are all asked, which is the point of this tool.
year_toNoLatest year, in each archive's own reading of what a year is. It cannot be earlier than 'year_from'.
year_fromNoEarliest year, in each archive's own reading of what a year is. Given with 'year_to', it must not be the later of the two: a range running backwards names no year and is refused rather than read differently by each archive.
media_typeNoThe kind of material, in the vocabulary of whichever archive uses that name. the Internet Archive files texts, movies, audio, image, software, data, web; the Library of Congress files books, photos, maps, audio, film-and-videos, manuscripts, notated-music, newspapers; the Bibliothèque nationale de France files work. The names are the union of those vocabularies rather than a shared one, so an archive that does not use the name you give is named as absent rather than asked under another. Naming none leaves an archive that searches every kind unnarrowed and asks an archive that keeps one catalogue per kind for its own default.

Output Schema

ParametersJSON Schema
NameRequiredDescription
pageYes
itemsYes
notesYes
orderYesHow the list was built, in words.
queryYes
item_countYesRows in this answer, across every archive. Never a total of what exists.
per_sourceYes
media_typesYesWhich name each archive was asked under, published rather than reconciled, so a caller can map the vocabularies once and read what was actually searched.
queries_runYesRequests this server sent for this answer, counting every wording on every archive. Each archive's own wordings are in 'per_source'.
non_word_charactersYesCharacters in the query that are neither letters nor digits. These catalogues answer on words, so a row here can carry none of them, and 'requires_every_word' covers the words that were given rather than these.

TDQS

A5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare read-only, open-world, idempotent, non-destructive. The description adds extensive behavioral context: interleaving, per-archive sorting, date semantics, fan-out derivation, media_type vocabulary differences, per_source counts, and non-word handling. There is no contradiction with annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Although long, every sentence adds distinct value and no redundancy is apparent. The purpose is front-loaded in the first sentence, and subsequent paragraphs are organized by topic (sorting, counts, media_type, fan-out, alternatives). It is appropriately sized for the tool's complexity.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

The description is exceptionally complete for a tool with 9 parameters, an output schema, and complex cross-archive semantics. It covers edge cases, per-archive behaviors, counting rules, and even latency. It fully compensates for any structured-field gaps and leaves no major operational ambiguity.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, but the description adds significant meaning beyond the schema: it explains sort is applied per archive rather than across, media_type vocabularies are per-archive with absence behavior, fan_out triggers only when results are sparse, and year ranges are interpreted per archive. This goes far beyond the schema descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description opens with a specific verb and resource ('Search the catalogue of every archive this server reads, at the same time') and clearly distinguishes this tool from siblings by referencing get_item for reading records and search_inside for page-level phrases. It is unambiguous about the merged-list output and cross-archive scope.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explicitly names alternatives: 'Use search_inside for a phrase printed on a page' and 'Read the record with get_item for what that record itself says.' It also provides usage context, such as latency, per-archive counts, and when the tool is or isn't appropriate for sentence-level queries.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 3 tool updatesv1.2.0
    • Changedget_item9 fields changed
      • addedOutput schema / properties / item / properties / attribution / description
        Added value: +"What to say when repeating this record, as its archive states it. An archive whose licence asks for the date its metadata was retrieved carries that date here."
      • changedOutput schema / properties / item / properties / description / description
        Previous value: -"The window of the record's own prose that 'text_window' describes."New value: +"The window of this record's description that 'text_window' describes. 'description_means' says what this archive files under that name, which is not always prose about the thing."
      • addedOutput schema / properties / item / properties / description_means
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "What the field this description was read out of holds on this archive. Null on an archive no description is read from."
        +}
      • changedOutput schema / properties / item / properties / generated_entries / description
        Previous value: -"Entries the archive lists against this record that are its own bookkeeping or the by-products of its processing rather than copies of the thing. They were left out of 'copies' and are on the archive's own page."New value: +"Entries the archive lists against this record that are not copies of the thing: its own bookkeeping, the by-products of its processing, or an image it attaches to illustrate the record. They were left out of 'copies' and are on the archive's own page."
      • addedOutput schema / properties / item / properties / identifier_provisional
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "boolean"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "True where the archive itself calls this identifier provisional: it can be replaced once a cataloguer settles the record. Null on an archive that mints one kind of identifier and says nothing about settling it."
        +}
      • addedOutput schema / properties / item / properties / rights / properties / covers
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "What the statement covers, where it covers more than this record. Null on an archive setting terms per deposit, where a statement covers the record it sits on and no other."
        +}
      • changedOutput schema / properties / item / properties / rights / properties / note / description
        Previous value: -"How to read the two fields above for this record, including when both are null."New value: +"How to read the fields above for this record, including when they are null."
      • changedOutput schema / properties / item / properties / rights / required
        Previous value: -[
        -  "statement",
        -  "url",
        -  "note"
        -]New value: +[
        +  "statement",
        +  "url",
        +  "covers",
        +  "note"
        +]
      • changedOutput schema / properties / item / required
        Previous value: -[
        -  "id",
        -  "source",
        -  "source_name",
        -  "identifier",
        -  "title",
        -  "creator",
        -  "year",
        -  "year_means",
        -  "date",
        -  "media_type",
        -  "source_url",
        -  "attribution",
        -  "description",
        -  "notes",
        -  "subjects",
        -  "rights",
        -  "copies",
        -  "copies_available",
        -  "generated_entries",
        -  "context"
        -]New value: +[
        +  "id",
        +  "source",
        +  "source_name",
        +  "identifier",
        +  "title",
        +  "creator",
        +  "year",
        +  "year_means",
        +  "date",
        +  "media_type",
        +  "source_url",
        +  "attribution",
        +  "identifier_provisional",
        +  "description",
        +  "description_means",
        +  "notes",
        +  "subjects",
        +  "rights",
        +  "copies",
        +  "copies_available",
        +  "generated_entries",
        +  "context"
        +]
    • Changedsearch_inside21 fields changed
      • addedInput schema / properties / fan_out
        Added value: +{
        +  "default": true,
        +  "description": "Whether to derive shorter and differently spelled wordings from the query and ask each archive for the union of what they return. A question written as a sentence returns nothing where every word given has to appear, and the rows an index scores highest where it does not. An archive is asked a derived wording only when the words as asked did not return as many rows as 'limit', so a query that works costs one request. Set false to send exactly the words given. 'per_source' names every wording, sent or not, and each match names the one that returned it.",
        +  "type": "boolean"
        +}
      • changedInput schema / properties / sources / items / enum
        Previous value: -[
        -  "archive",
        -  "loc"
        -]New value: +[
        +  "archive",
        +  "loc",
        +  "bnf"
        +]
      • addedOutput schema / properties / hits / items / properties / found_by_derivation
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "How that wording was arrived at from the question, in words."
        +}
      • addedOutput schema / properties / hits / items / properties / found_by_query
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "The wording this archive was given that returned this row. It is the query as you wrote it unless a further wording was derived, in which case the row answers that wording's words and not the rest of the question."
        +}
      • changedOutput schema / properties / hits / items / required
        Previous value: -[
        -  "id",
        -  "source",
        -  "source_name",
        -  "identifier",
        -  "title",
        -  "creator",
        -  "year",
        -  "page_number",
        -  "excerpts",
        -  "excerpt_kind",
        -  "source_url",
        -  "matched_file",
        -  "inside_container",
        -  "published_on",
        -  "publication"
        -]New value: +[
        +  "id",
        +  "source",
        +  "source_name",
        +  "identifier",
        +  "title",
        +  "creator",
        +  "year",
        +  "page_number",
        +  "excerpts",
        +  "excerpt_kind",
        +  "source_url",
        +  "matched_file",
        +  "inside_container",
        +  "published_on",
        +  "publication",
        +  "found_by_query",
        +  "found_by_derivation"
        +]
      • addedOutput schema / properties / non_word_characters
        Added value: +{
        +  "description": "Characters in the query that are neither letters nor digits. These indexes answer on words, so a match here can carry none of them, and 'requires_every_word' covers the words that were given rather than these.",
        +  "items": {
        +    "type": "string"
        +  },
        +  "type": "array"
        +}
      • addedOutput schema / properties / per_source / items / properties / attribution
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "What to say when repeating what this archive contributed, as this archive states it for this answer. An archive whose licence asks for the date its metadata was retrieved carries that date here. Null on an archive that was never asked."
        +}
      • changedOutput schema / properties / per_source / items / properties / corpus / description
        Previous value: -"What body of material this archive's full-text index reads."New value: +"What body of material this archive's full-text index reads. Null where this answer did not read it."
      • addedOutput schema / properties / per_source / items / properties / filters_dropped
        Added value: +{
        +  "description": "Narrowings you asked for that this archive never received, because its catalogue cannot apply them. Its rows were not narrowed by them, and a row from it that happens to satisfy one is a coincidence rather than a filter. Empty when it received every narrowing asked for.",
        +  "items": {
        +    "additionalProperties": false,
        +    "properties": {
        +      "because": {
        +        "description": "Why this archive never received it.",
        +        "type": "string"
        +      },
        +      "filter": {
        +        "description": "The narrowing, named as the argument that carries it.",
        +        "type": "string"
        +      }
        +    },
        +    "required": [
        +      "filter",
        +      "because"
        +    ],
        +    "type": "object"
        +  },
        +  "type": "array"
        +}
      • addedOutput schema / properties / per_source / items / properties / queries
        Added value: +{
        +  "description": "Every wording derived for this archive, in the order they were tried, with what each one returned and why any was withheld. Retyping one of them reproduces its rows by hand. Empty on a call that carries no query.",
        +  "items": {
        +    "additionalProperties": false,
        +    "properties": {
        +      "added": {
        +        "anyOf": [
        +          {
        +            "maximum": 9007199254740991,
        +            "minimum": -9007199254740991,
        +            "type": "integer"
        +          },
        +          {
        +            "type": "null"
        +          }
        +        ],
        +        "description": "Rows this wording contributed that an earlier one had not already returned. Null when it was not sent, or did not answer."
        +      },
        +      "count": {
        +        "anyOf": [
        +          {
        +            "maximum": 9007199254740991,
        +            "minimum": -9007199254740991,
        +            "type": "integer"
        +          },
        +          {
        +            "type": "null"
        +          }
        +        ],
        +        "description": "Rows the archive returned for this wording. Zero is that wording finding nothing, which is a statement about the wording. Null when it was not sent, or did not answer."
        +      },
        +      "derivation": {
        +        "description": "How this wording was arrived at from the question, in words.",
        +        "type": "string"
        +      },
        +      "error": {
        +        "anyOf": [
        +          {
        +            "additionalProperties": false,
        +            "properties": {
        +              "code": {
        +                "type": "string"
        +              },
        +              "hint": {
        +                "type": "string"
        +              },
        +              "message": {
        +                "type": "string"
        +              }
        +            },
        +            "required": [
        +              "code",
        +              "message"
        +            ],
        +            "type": "object"
        +          },
        +          {
        +            "type": "null"
        +          }
        +        ],
        +        "description": "Why this wording did not answer. Null when it did, or was never sent."
        +      },
        +      "not_run_because": {
        +        "anyOf": [
        +          {
        +            "type": "string"
        +          },
        +          {
        +            "type": "null"
        +          }
        +        ],
        +        "description": "Why it was withheld. Null when it was sent."
        +      },
        +      "query": {
        +        "description": "The words this archive was given, exactly as they were sent.",
        +        "type": "string"
        +      },
        +      "ran": {
        +        "description": "Whether it was sent. False means it was derived and withheld.",
        +        "type": "boolean"
        +      }
        +    },
        +    "required": [
        +      "query",
        +      "derivation",
        +      "ran",
        +      "count",
        +      "added",
        +      "not_run_because",
        +      "error"
        +    ],
        +    "type": "object"
        +  },
        +  "type": "array"
        +}
      • addedOutput schema / properties / per_source / items / properties / requires_every_word
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "boolean"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "Whether the index this answer put the words to answers only where every word given appears. It covers the words: a character that is neither a letter nor a digit is not a word to an index, and those are listed in 'non_word_characters' instead. False means the index ranks the words and answers with what it scores highest, so one of its rows can carry only some of them. Null on an archive that was not asked."
        +}
      • addedOutput schema / properties / per_source / items / properties / row_describes
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "What one row from this archive is: a copy it holds, a record in a catalogue, or a work as an entity whose editions are records of their own. Rows carry the same fields and describe different kinds of thing."
        +}
      • addedOutput schema / properties / per_source / items / properties / searches_on
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "The fields this archive matched the query against. The archives read different ones, so the same words are not the same question everywhere, and a name given to an index over titles alone comes back as the works written about that person."
        +}
      • removedOutput schema / properties / per_source / items / properties / skipped / anyOf
        Removed value: -[
        -  {
        -    "maximum": 9007199254740991,
        -    "minimum": -9007199254740991,
        -    "type": "integer"
        -  },
        -  {
        -    "type": "null"
        -  }
        -]
      • changedOutput schema / properties / per_source / items / properties / skipped / description
        Previous value: -"Rows this archive sent in a shape the server could not read, and left out. Null on an answer served from a cache that kept the rows and not the count of what was dropped building them."New value: +"Rows this archive sent in a shape the server could not read, and left out of this answer. Always a count, so it reads the same way on every answer. Rows served out of a cache were counted the same way when they were first read, and 'cached' marks an answer whose count can be short of a drop nobody kept a record of."
      • addedOutput schema / properties / per_source / items / properties / skipped / maximum
        Added value: +9007199254740991
      • addedOutput schema / properties / per_source / items / properties / skipped / minimum
        Added value: +-9007199254740991
      • addedOutput schema / properties / per_source / items / properties / skipped / type
        Added value: +"integer"
      • changedOutput schema / properties / per_source / items / required
        Previous value: -[
        -  "source",
        -  "name",
        -  "status",
        -  "stage",
        -  "absent_because",
        -  "count",
        -  "reported_total",
        -  "reported_total_means",
        -  "skipped",
        -  "more_on_this_archive",
        -  "ordered_on",
        -  "media_type_asked",
        -  "year_means",
        -  "publishes_page_number",
        -  "corpus",
        -  "cached",
        -  "error"
        -]New value: +[
        +  "source",
        +  "name",
        +  "status",
        +  "stage",
        +  "absent_because",
        +  "count",
        +  "reported_total",
        +  "reported_total_means",
        +  "skipped",
        +  "more_on_this_archive",
        +  "ordered_on",
        +  "media_type_asked",
        +  "attribution",
        +  "searches_on",
        +  "row_describes",
        +  "filters_dropped",
        +  "queries",
        +  "year_means",
        +  "publishes_page_number",
        +  "corpus",
        +  "requires_every_word",
        +  "cached",
        +  "error"
        +]
      • addedOutput schema / properties / queries_run
        Added value: +{
        +  "description": "Requests this server sent for this answer, counting every wording on every archive. Each archive's own wordings are in 'per_source'.",
        +  "maximum": 9007199254740991,
        +  "minimum": -9007199254740991,
        +  "type": "integer"
        +}
      • changedOutput schema / required
        Previous value: -[
        -  "query",
        -  "page",
        -  "hits",
        -  "hit_count",
        -  "per_source",
        -  "order",
        -  "excerpt_kinds",
        -  "notes"
        -]New value: +[
        +  "query",
        +  "page",
        +  "hits",
        +  "hit_count",
        +  "per_source",
        +  "queries_run",
        +  "non_word_characters",
        +  "order",
        +  "excerpt_kinds",
        +  "notes"
        +]
    • Changedsearch_items29 fields changed
      • addedInput schema / properties / fan_out
        Added value: +{
        +  "default": true,
        +  "description": "Whether to derive further wordings from the query and ask each archive for the union of what they return. A question written as a sentence returns nothing where every word given has to appear, and the records an index scores highest where it does not, and a spelling of a name is not the only one a catalogue files it under. An archive is asked a derived wording only when the words as asked did not return as many rows as 'limit', so a query that works costs one request. Set false to send exactly the words given. 'per_source' names every wording, sent or not, and each row names the one that returned it.",
        +  "type": "boolean"
        +}
      • changedInput schema / properties / media_type / description
        Previous value: -"The kind of material, in the vocabulary of whichever archive uses that name. the Internet Archive files texts, movies, audio, image, software, data, web; the Library of Congress files books, photos, maps, audio, film-and-videos, manuscripts, notated-music, newspapers. The names are the union of those vocabularies rather than a shared one, so an archive that does not use the name you give is named as absent rather than asked under another. Naming none leaves an archive that searches every kind unnarrowed and asks an archive that keeps one catalogue per kind for its own default."New value: +"The kind of material, in the vocabulary of whichever archive uses that name. the Internet Archive files texts, movies, audio, image, software, data, web; the Library of Congress files books, photos, maps, audio, film-and-videos, manuscripts, notated-music, newspapers; the Bibliothèque nationale de France files work. The names are the union of those vocabularies rather than a shared one, so an archive that does not use the name you give is named as absent rather than asked under another. Naming none leaves an archive that searches every kind unnarrowed and asks an archive that keeps one catalogue per kind for its own default."
      • changedInput schema / properties / media_type / enum
        Previous value: -[
        -  "audio",
        -  "books",
        -  "data",
        -  "film-and-videos",
        -  "image",
        -  "manuscripts",
        -  "maps",
        -  "movies",
        -  "newspapers",
        -  "notated-music",
        -  "photos",
        -  "software",
        -  "texts",
        -  "web"
        -]New value: +[
        +  "audio",
        +  "books",
        +  "data",
        +  "film-and-videos",
        +  "image",
        +  "manuscripts",
        +  "maps",
        +  "movies",
        +  "newspapers",
        +  "notated-music",
        +  "photos",
        +  "software",
        +  "texts",
        +  "web",
        +  "work"
        +]
      • changedInput schema / properties / sort / description
        Previous value: -"Applied inside each archive. The merged list stays interleaved, because no order runs across archives."New value: +"Applied inside each archive. The merged list stays interleaved, because no order runs across archives. 'oldest' and 'newest' run on a date field carrying a year and no era, and a record stating no date is placed by a stand-in, so neither end of such an order is a claim about age."
      • changedInput schema / properties / sources / items / enum
        Previous value: -[
        -  "archive",
        -  "loc"
        -]New value: +[
        +  "archive",
        +  "loc",
        +  "bnf"
        +]
      • changedInput schema / properties / year_from / description
        Previous value: -"Earliest year, in each archive's own reading of what a year is."New value: +"Earliest year, in each archive's own reading of what a year is. Given with 'year_to', it must not be the later of the two: a range running backwards names no year and is refused rather than read differently by each archive."
      • changedInput schema / properties / year_to / description
        Previous value: -"Latest year, in each archive's own reading of what a year is."New value: +"Latest year, in each archive's own reading of what a year is. It cannot be earlier than 'year_from'."
      • addedOutput schema / properties / items / items / properties / found_by_derivation
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "How that wording was arrived at from the question, in words."
        +}
      • addedOutput schema / properties / items / items / properties / found_by_query
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "The wording this archive was given that returned this row. It is the query as you wrote it unless a further wording was derived, in which case the row answers that wording's words and not the rest of the question."
        +}
      • addedOutput schema / properties / items / items / properties / identifier_provisional
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "boolean"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "True where the archive itself calls this identifier provisional: it can be replaced once a cataloguer settles the record, so a citation carrying it can stop naming anything. Null on an archive that mints one kind of identifier and says nothing about settling it, which is not the same as an archive stating this one is settled."
        +}
      • changedOutput schema / properties / items / items / properties / online / description
        Previous value: -"Whether a digitised copy can be read online. Null on an archive that holds a copy of everything it catalogues and so states nothing."New value: +"Whether a digitised copy can be read online. Null on an archive that states nothing about a copy against a catalogue row."
      • changedOutput schema / properties / items / items / required
        Previous value: -[
        -  "id",
        -  "source",
        -  "source_name",
        -  "identifier",
        -  "title",
        -  "creator",
        -  "year",
        -  "date",
        -  "media_type",
        -  "source_url",
        -  "downloads",
        -  "location",
        -  "online"
        -]New value: +[
        +  "id",
        +  "source",
        +  "source_name",
        +  "identifier",
        +  "title",
        +  "creator",
        +  "year",
        +  "date",
        +  "media_type",
        +  "source_url",
        +  "downloads",
        +  "location",
        +  "online",
        +  "identifier_provisional",
        +  "found_by_query",
        +  "found_by_derivation"
        +]
      • changedOutput schema / properties / media_types / items / properties / vocabulary / description
        Previous value: -"Every name this archive files material under."New value: +"Every name this archive takes as the 'media_type' argument, which is how its catalogue is divided. The 'media_type' on a row is the word that record carries, and is often none of these."
      • addedOutput schema / properties / non_word_characters
        Added value: +{
        +  "description": "Characters in the query that are neither letters nor digits. These catalogues answer on words, so a row here can carry none of them, and 'requires_every_word' covers the words that were given rather than these.",
        +  "items": {
        +    "type": "string"
        +  },
        +  "type": "array"
        +}
      • addedOutput schema / properties / per_source / items / properties / attribution
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "What to say when repeating what this archive contributed, as this archive states it for this answer. An archive whose licence asks for the date its metadata was retrieved carries that date here. Null on an archive that was never asked."
        +}
      • changedOutput schema / properties / per_source / items / properties / corpus / description
        Previous value: -"What body of material this archive's full-text index reads."New value: +"What body of material this archive's full-text index reads. Null where this answer did not read it."
      • addedOutput schema / properties / per_source / items / properties / filters_dropped
        Added value: +{
        +  "description": "Narrowings you asked for that this archive never received, because its catalogue cannot apply them. Its rows were not narrowed by them, and a row from it that happens to satisfy one is a coincidence rather than a filter. Empty when it received every narrowing asked for.",
        +  "items": {
        +    "additionalProperties": false,
        +    "properties": {
        +      "because": {
        +        "description": "Why this archive never received it.",
        +        "type": "string"
        +      },
        +      "filter": {
        +        "description": "The narrowing, named as the argument that carries it.",
        +        "type": "string"
        +      }
        +    },
        +    "required": [
        +      "filter",
        +      "because"
        +    ],
        +    "type": "object"
        +  },
        +  "type": "array"
        +}
      • addedOutput schema / properties / per_source / items / properties / queries
        Added value: +{
        +  "description": "Every wording derived for this archive, in the order they were tried, with what each one returned and why any was withheld. Retyping one of them reproduces its rows by hand. Empty on a call that carries no query.",
        +  "items": {
        +    "additionalProperties": false,
        +    "properties": {
        +      "added": {
        +        "anyOf": [
        +          {
        +            "maximum": 9007199254740991,
        +            "minimum": -9007199254740991,
        +            "type": "integer"
        +          },
        +          {
        +            "type": "null"
        +          }
        +        ],
        +        "description": "Rows this wording contributed that an earlier one had not already returned. Null when it was not sent, or did not answer."
        +      },
        +      "count": {
        +        "anyOf": [
        +          {
        +            "maximum": 9007199254740991,
        +            "minimum": -9007199254740991,
        +            "type": "integer"
        +          },
        +          {
        +            "type": "null"
        +          }
        +        ],
        +        "description": "Rows the archive returned for this wording. Zero is that wording finding nothing, which is a statement about the wording. Null when it was not sent, or did not answer."
        +      },
        +      "derivation": {
        +        "description": "How this wording was arrived at from the question, in words.",
        +        "type": "string"
        +      },
        +      "error": {
        +        "anyOf": [
        +          {
        +            "additionalProperties": false,
        +            "properties": {
        +              "code": {
        +                "type": "string"
        +              },
        +              "hint": {
        +                "type": "string"
        +              },
        +              "message": {
        +                "type": "string"
        +              }
        +            },
        +            "required": [
        +              "code",
        +              "message"
        +            ],
        +            "type": "object"
        +          },
        +          {
        +            "type": "null"
        +          }
        +        ],
        +        "description": "Why this wording did not answer. Null when it did, or was never sent."
        +      },
        +      "not_run_because": {
        +        "anyOf": [
        +          {
        +            "type": "string"
        +          },
        +          {
        +            "type": "null"
        +          }
        +        ],
        +        "description": "Why it was withheld. Null when it was sent."
        +      },
        +      "query": {
        +        "description": "The words this archive was given, exactly as they were sent.",
        +        "type": "string"
        +      },
        +      "ran": {
        +        "description": "Whether it was sent. False means it was derived and withheld.",
        +        "type": "boolean"
        +      }
        +    },
        +    "required": [
        +      "query",
        +      "derivation",
        +      "ran",
        +      "count",
        +      "added",
        +      "not_run_because",
        +      "error"
        +    ],
        +    "type": "object"
        +  },
        +  "type": "array"
        +}
      • addedOutput schema / properties / per_source / items / properties / requires_every_word
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "boolean"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "Whether the index this answer put the words to answers only where every word given appears. It covers the words: a character that is neither a letter nor a digit is not a word to an index, and those are listed in 'non_word_characters' instead. False means the index ranks the words and answers with what it scores highest, so one of its rows can carry only some of them. Null on an archive that was not asked."
        +}
      • addedOutput schema / properties / per_source / items / properties / row_describes
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "What one row from this archive is: a copy it holds, a record in a catalogue, or a work as an entity whose editions are records of their own. Rows carry the same fields and describe different kinds of thing."
        +}
      • addedOutput schema / properties / per_source / items / properties / searches_on
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "description": "The fields this archive matched the query against. The archives read different ones, so the same words are not the same question everywhere, and a name given to an index over titles alone comes back as the works written about that person."
        +}
      • removedOutput schema / properties / per_source / items / properties / skipped / anyOf
        Removed value: -[
        -  {
        -    "maximum": 9007199254740991,
        -    "minimum": -9007199254740991,
        -    "type": "integer"
        -  },
        -  {
        -    "type": "null"
        -  }
        -]
      • changedOutput schema / properties / per_source / items / properties / skipped / description
        Previous value: -"Rows this archive sent in a shape the server could not read, and left out. Null on an answer served from a cache that kept the rows and not the count of what was dropped building them."New value: +"Rows this archive sent in a shape the server could not read, and left out of this answer. Always a count, so it reads the same way on every answer. Rows served out of a cache were counted the same way when they were first read, and 'cached' marks an answer whose count can be short of a drop nobody kept a record of."
      • addedOutput schema / properties / per_source / items / properties / skipped / maximum
        Added value: +9007199254740991
      • addedOutput schema / properties / per_source / items / properties / skipped / minimum
        Added value: +-9007199254740991
      • addedOutput schema / properties / per_source / items / properties / skipped / type
        Added value: +"integer"
      • changedOutput schema / properties / per_source / items / required
        Previous value: -[
        -  "source",
        -  "name",
        -  "status",
        -  "stage",
        -  "absent_because",
        -  "count",
        -  "reported_total",
        -  "reported_total_means",
        -  "skipped",
        -  "more_on_this_archive",
        -  "ordered_on",
        -  "media_type_asked",
        -  "year_means",
        -  "publishes_page_number",
        -  "corpus",
        -  "cached",
        -  "error"
        -]New value: +[
        +  "source",
        +  "name",
        +  "status",
        +  "stage",
        +  "absent_because",
        +  "count",
        +  "reported_total",
        +  "reported_total_means",
        +  "skipped",
        +  "more_on_this_archive",
        +  "ordered_on",
        +  "media_type_asked",
        +  "attribution",
        +  "searches_on",
        +  "row_describes",
        +  "filters_dropped",
        +  "queries",
        +  "year_means",
        +  "publishes_page_number",
        +  "corpus",
        +  "requires_every_word",
        +  "cached",
        +  "error"
        +]
      • addedOutput schema / properties / queries_run
        Added value: +{
        +  "description": "Requests this server sent for this answer, counting every wording on every archive. Each archive's own wordings are in 'per_source'.",
        +  "maximum": 9007199254740991,
        +  "minimum": -9007199254740991,
        +  "type": "integer"
        +}
      • changedOutput schema / required
        Previous value: -[
        -  "query",
        -  "page",
        -  "items",
        -  "item_count",
        -  "per_source",
        -  "media_types",
        -  "order",
        -  "notes"
        -]New value: +[
        +  "query",
        +  "page",
        +  "items",
        +  "item_count",
        +  "per_source",
        +  "queries_run",
        +  "media_types",
        +  "non_word_characters",
        +  "order",
        +  "notes"
        +]
  2. 3 tool updatesv1.0.0
    • First observedget_item
    • First observedsearch_inside
    • First observedsearch_items

TDQS

A4.7/5.0

Scored across 3 tools

Disambiguation5/5

The three tools are clearly distinct: search_items for catalogue metadata, search_inside for full-text search, and get_item for retrieving a specific record. The descriptions explicitly cross-reference each other to prevent misuse.

Naming Consistency4/5

Two tools follow verb_noun (search_items, get_item), while search_inside uses a verb_preposition structure. The pattern is still predictable and readable, but not perfectly uniform.

Tool Count5/5

Three tools is a well-scoped count for a read-only search and retrieval server, covering the core workflows without unnecessary bloat.

Completeness5/5

The tool surface covers the full lifecycle for searching book archives: query by metadata, query by content, and fetch full records. No obvious gaps exist for the stated purpose.

Maintenance

ActivityActive
ResponsivenessWithin a week

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    MCP server for the Internet Archive's Wayback Machine. Search archived snapshots, extract page text from a specific date, track how a site has changed over time, check if broken links are recoverable, and perform research across Internet Archive collections.
    6
    3
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Self-hosted MCP server for searching and discovering books using Anna's Archive and Goodreads datasets, enabling full-text search, ISBN/md5 lookup, similarity matching, and optional download URL retrieval.
    7
    2
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    MCP server for searching and retrieving full-text pages from the Library of Congress, including newspapers, books, and manuscripts, via the loc.gov API.
    3
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Full-coverage MCP server for Internet Archive, enabling search, metadata lookup, collection browsing, and Wayback Machine snapshot retrieval via 13 tools.
    BSD Zero Clause