mcp - the reading as a tool an assistant can call

truecopy/mcp

An assistant handed somebody's statement has a tool call and nothing else. It offers two tools over stdio. One reads the table, the other checks the values against the rows they were cited from, and the file never leaves the machine.

An agent with a terminal already had npx truecopy. One that can import a module had the library. Most assistants are neither: a host that speaks MCP reaches a tool call and nothing else, so the reading was unreachable from the place it is most needed - the model that has just been handed somebody’s bank statement and has no way to check what it read off it.

{
  "mcpServers": {
    "truecopy": {
      "command": "npx",
      "args": ["-y", "-p", "truecopy", "-p", "pdfjs-dist", "truecopy-mcp"]
    }
  }
}

pdfjs-dist is named on the command line because it is an optional peer dependency: without it the server still reads CSV, TSV and pasted tables, and answers no-engine on a PDF rather than pretending.

Two tools

read_table turns a PDF, a CSV, a TSV or a saved paste into rows, and says what the reading could not vouch for. It hands back the cut into columns, what each column was taken to hold, the rows numbered, and the doubts. At most 2000 rows a call, 200 by default - and what it leaves is counted, never silently dropped.

check_citations takes the records the model claims to have read, each citing the row numbers it came from, and says which values those rows do not print. A JSON number is checked as a figure, with the document’s own decimal mark; a JSON string as its words in order. Each value is looked up in the cited rows and nowhere else, so nothing can be produced - only pointed at. It is cite, reached from a host instead of from code.

Cite the numbers read_table returned, for the same file and the same maximumPages: a different page bound is a different reading, and its rows are numbered differently.

A description is a prompt, not documentation

The sentence a host pastes in front of a model decides which tool it reaches for, so neither description stops at what its tool does. Both name what the tool does not prove - read_table says outright that an empty list of doubts is not a promise that the reading is right. A description that said “reads tables from PDFs” and stopped would have taught the opposite of this library.

Over stdio, and not over HTTP

That is the design, not a limitation. A statement, a payslip and a career record are the worst documents there are to upload, and there is nothing a server could compute on them that this does not compute where the file already sits: the reading is deterministic and makes no network call. An endpoint would add a copy of somebody’s document to the world and buy nothing with it.

TRUECOPY_MCP_ROOT confines what may be opened, when the host is not to be trusted with a path. It is applied to the resolved path, so a link inside the root cannot walk back out of it.

A server of your own

truecopy/mcp is the deciding half, and it touches no filesystem, no stream and no clock.

import { TOOLS, respond } from 'truecopy/mcp';

const answer = await respond(message, {
  open: (path) => openTheFile(path),
  version: '2.0.4'
});
TOOLS the descriptors, with their input and output schemas, and the sentences above
respond one JSON-RPC message in, one McpResponse out - null for a notification
McpCapabilities what the module needs from the world: open, a path to a File, and the version to report
McpResponse jsonrpc, id, and one of result or error

open is injected rather than imported, which is what keeps the promise above: the confinement of what may be opened belongs to whoever launches the server, not to the protocol. version is passed in for the same reason - a browser has no package.json to read.

null for a notification is not an implementation detail: by the JSON-RPC rules a message with no id must not be answered, and a client that receives an answer to notifications/initialized has to work out what it belongs to.

bin/truecopy-mcp.mjs is the other half - the one that talks to the world - and it is thin enough to read in one sitting. Same split as the parsers, for the same reason: what decides is tested, and what touches the world is small.

Two generations of the protocol, both answered

Up to 2025-11-25 a client opens with initialize. From 2026-07-28 the protocol is stateless, that method is gone from the schema, and a client either probes server/discover or simply calls. Answering only the newer one would go silent against every client shipped before July 2026, and none of the three costs anything to keep.