facts-mcp
by Yesid8
README.md
# 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.
## Install and run
Requires Node 22.11 or newer. The package is not on npm yet; build it from a clone:
```bash
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:
```jsonc
{
"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](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:
```jsonc
// facts.measure.http
{ "project": "checkout", "url": "http://localhost:3000/api/orders" }
```
```jsonc
{
"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:
```jsonc
// 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:
```jsonc
// 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 (`0`–`20`) |
| `PLINTH_FACTS_DEFAULT_STALE_SECONDS` | `3600` | `stale` threshold when a query gives none |
To measure a public site, for example:
```jsonc
"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](docs/adr/0001-implementar-protocolo-sin-sdk.md)
and [ADR-0002](docs/adr/0002-el-sdk-v2-se-publico-con-otro-nombre.md).
## Development
```bash
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/](docs/). Prose is in Spanish; code, field names and error codes are
in English. Contributions: [CONTRIBUTING.md](CONTRIBUTING.md). Security reports:
[SECURITY.md](SECURITY.md).
## License
[0BSD](LICENSE): use, copy, modify and distribute it for any purpose, with or without fee,
and with no attribution required.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues