Skip to main content
Glama

facts-mcp

An MCP server that only holds facts. It measures and it remembers; it never judges.

Point it at a service — a local app, a device on your network, a public API or website — and it returns the raw measurement with the time it was taken. Write down what you concluded, with the evidence, and it keeps that claim — for good. Nothing in it is a verdict, so an agent reading it does not have to verify the answer.

It knows nothing about any particular application. The project is a parameter of every call, and what the server may reach is set by whoever runs it, not by the code.

The three rules

Without these, this is just another agent you would have to check.

  1. If something can be measured now, it is not answered from memory. recall.* never measures. When it returns a claim backed by a measurement it also returns remeasure_with — the exact tool and arguments to take that measurement again yourself.

  2. Every response carries the time it was observed, so staleness is visible. Each item in a collection carries its own age, not the age of the query.

  3. No output field is a judgement. No health, score, severity, passed, recommendation. ok means only "the observation could be taken".

And the distinction that holds it together: an error of observation is not the observation of an error. A cleanly received HTTP 500 is a success of the tool — ok: true, http_status: 500. Only what prevented measuring is an error.

Related MCP server: Witness

Install and run

Requires Node 22.11 or newer. The package is not on npm yet; build it from a clone:

git clone https://github.com/Yesid8/mcp-measure-and-recall.git
cd mcp-measure-and-recall
npm ci
npm run build

It speaks MCP over stdio. Point any MCP client at the built entry point, with a data directory of its own:

{
  "mcpServers": {
    "facts": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-measure-and-recall/dist/main.js"],
      "env": { "PLINTH_DATA_DIR": "/absolute/path/to/a/data/dir" }
    }
  }
}

On Windows, write the paths with forward slashes or doubled backslashes: a single backslash in JSON is an escape. If the client does not connect, read its stderr — every refusal to start begins with facts-mcp: and says why. The guide for consumers is docs/USO.md.

The six tools

Every call takes project, a free string that isolates one consumer's data from another's. It is created on first use and returned normalized, so a typo is visible immediately.

Tool

What it returns

facts.measure.http

Status, headers, body, timings — and which IP it actually connected to

facts.measure.ws

The messages that arrived in N seconds, each with absolute time and offset

facts.recall.assert

The claim as written, with its evidence embedded

facts.recall.retract

The claim marked retracted, with a reason. Never deleted

facts.recall.query

Claims with their age, their stale mark, and how to measure them again

facts.recall.history

The raw events of one claim, in order

A worked example

Measure an endpoint:

// facts.measure.http
{ "project": "checkout", "url": "http://localhost:3000/api/orders" }
{
  "observation_id": "o_01K5Z9K2QX7T4BVWRM3P8QJHYD",
  "observed_at": "2026-09-22T10:14:03.482+02:00",
  "age_seconds": 0.14,
  "age_basis": "monotonic",
  "ok": true,
  "error": null,
  "data": {
    "kind": "http",
    "http_status": 200,
    "body": { "orders": [] },
    "peer": { "address": "127.0.0.1", "port": 3000, "family": 4 }
  }
}

Write down what you concluded, citing that measurement:

// facts.recall.assert
{
  "project": "checkout",
  "statement": "GET /api/orders responds 200 with an empty array",
  "evidence": [{ "type": "observation", "observation_id": "o_01K5Z9K2QX7T4BVWRM3P8QJHYD" }],
  "observed_by": "claude-opus-5 (session 3f9a1c7e)",
  "tags": ["api", "orders"]
}

Three days later, someone reads the registry:

// facts.recall.query → { "project": "checkout", "text": "orders" }
{
  "claim_id": "c_01K5Z9M5P0RA2CF7XN4T6WQJBE",
  "statement": "GET /api/orders responds 200 with an empty array",
  "age_seconds": 259200.0,
  "stale": true,
  "remeasure_with": {
    "tool": "facts.measure.http",
    "arguments": { "project": "checkout", "url": "http://localhost:3000/api/orders" },
    "omitted": []
  }
}

The server does not decide whether to re-measure. It gives you the age and the how; the decision is yours. If the answer has changed, retract with a reason — the original claim stays in the log, byte for byte, and facts.recall.history shows both events.

Where the data lives

$PLINTH_DATA_DIR/                 # defaults to ~/.plinth
└── projects/<project>/logs/
    ├── claims.jsonl              # append-only. Readable with tail, copyable with rsync
    └── observations.jsonl        # measurements, failed ones included, so they can be cited

claims.jsonl is append-only in the strict sense: a retraction is a new line, never an edit. The current state is a projection over the events. There is no update and no delete — not discouraged, absent from the interface.

Network safety

Measurements come from the server's network position, and the destination is validated by resolved IP, not by name. A name is not a destination: evil.localhost can resolve to a public address, and can resolve to loopback once and to something else a moment later.

  • Every resolved address is classified; if any falls outside the profile, the call is refused without opening a socket.

  • The connection is made to the validated address, with the original Host and SNI, which closes the window between checking and connecting.

  • Every redirect hop repeats the whole check.

  • 169.254.169.254 and all link-local addresses are denied in every profile, allowlist included. That is the first stop of any exfiltration in a container.

  • Environment proxies are ignored: a proxy would bypass the IP validation entirely.

The operator picks one profile when starting the server; no tool argument can widen it:

Profile

Reaches

deny-all

Nothing

loopback (default)

Services on the same machine

private

The same machine and private networks (RFC 1918, fc00::/7) — devices on your LAN

allowlist

Exactly the host:port pairs listed, public or not — the way to measure a website

An allowlist entry is an exact host:port. There are no domain wildcards, because a wildcard turns an MCP server into an outbound proxy with an extra step; a malformed entry stops the server at startup rather than being ignored. The active profile is stated in server/discover, so the model knows where it can measure from.

Secrets (authorization, cookie, set-cookie, API keys, JWTs, PEM blocks, URL userinfo, query parameters such as ?token=) are sent intact to the destination and redacted before anything is written to disk, returned, or logged — claims and retraction reasons included. There is no window in which a credential sits in a file. remeasure_with leaves out whatever was redacted rather than replaying a placeholder as a broken request, and names it in omitted so you know what to supply.

Configuration

Read once at startup. A value that does not parse stops the server with a message naming the variable.

Variable

Default

Effect

PLINTH_DATA_DIR

~/.plinth

Where the registry lives. Give each client its own: one server per directory

PLINTH_FACTS_NET_PROFILE

loopback

deny-all, loopback, private or allowlist

PLINTH_FACTS_ALLOW_HOSTS

Comma-separated host:port pairs. Required by allowlist, refused by the others

PLINTH_FACTS_MAX_REDIRECTS

5

Hops a call may follow when it asks to (020)

PLINTH_FACTS_DEFAULT_STALE_SECONDS

3600

stale threshold when a query gives none

To measure a public site, for example:

"env": {
  "PLINTH_DATA_DIR": "/absolute/path/to/a/data/dir",
  "PLINTH_FACTS_NET_PROFILE": "allowlist",
  "PLINTH_FACTS_ALLOW_HOSTS": "api.example.com:443,www.example.com:443"
}

No variable introduces knowledge of any particular project: no base URLs, no routes, no credentials. That is the point — the same server serves every app you point it at.

What it will never do

measure.assert_status · measure.wait_until · recall.summarize · recall.rank · recall.delete · project.health

Each one breaks rule 1 or rule 3. The moment this server starts giving verdicts it stops being a source and becomes another agent you have to verify — which is exactly the work it exists to save.

Protocol

Implements MCP 2026-07-28 — the stateless revision: no initialize handshake, no sessions, server/discover required, resultType on every result.

The wire format is implemented directly against the normative schema, which is vendored in vendor/ and checked against the implementation at compile time. The official SDK for this revision exists as @modelcontextprotocol/server 2.0; whether to adopt it is a measured decision still to be taken, and every protocol detail lives behind src/protocol/ so that swapping it touches nothing else. See ADR-0001 and ADR-0002.

Development

npm install
npm run typecheck      # strict, with noUncheckedIndexedAccess and exactOptionalPropertyTypes
npm test               # unit, contract and conformance tests
npm run test:coverage  # the same, with a coverage floor that fails the run
npm run lint
npm run format:check
npm run build

CI runs all of the above on Linux and Windows, on Node 22 and 24. Windows is in the matrix on purpose: file truncation and stdin chunking behave differently there, and two of the bugs the tests now guard against only ever showed up on it.

The TLS tests generate a throwaway certificate with openssl and skip when it is not installed; no key material is committed.

The tests are the contract, not examples. They fail if the server starts judging: a 500 must stay ok: true, silence on a socket must stay a valid observation, a retraction must leave the previous bytes untouched, and a clock jumping backwards must never produce a negative age. The mechanical checks of the specification run as ordinary tests too: every schema field described, no judgement key in any output, no secret in any file on disk, no socket opened under deny-all, no application name anywhere in src/.

Specification in docs/. Prose is in Spanish; code, field names and error codes are in English. Contributions: CONTRIBUTING.md. Security reports: SECURITY.md.

License

0BSD: use, copy, modify and distribute it for any purpose, with or without fee, and with no attribution required.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    D
    maintenance
    Enables submitting claims and receiving Ed25519-signed, hash-chained verdicts resolved against real external ground truth, supporting resolvers like GitHub PRs, on-chain transactions, URL JSON, HTTP status, and Kalshi markets.
    5
    352 npm
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Provides evidence-oriented MCP service for cryptographically identified agents, bounded public contracts, privacy-preserving records, and append-only audit.
    Apache 2.0
  • A
    license
    B
    quality
    B
    maintenance
    Enables software agents to maintain a signed, evidence-aware local ledger of claims, with policy-gated proposals, conflict queries, and constitution-pinned access through an MCP stdio server.
    7
    3
    Apache 2.0