Skip to main content
Glama

Cairn

A self-hosted MCP server that remembers what it has already told you.

A cairn is a stone you leave on a path so you can find your place when you come back.


The problem

On a screen you can skim. You glance at a list, see the three things you already read this morning, and your eye jumps past them in under a second.

In voice you cannot. The channel is serial, it runs at about 150 words a minute, and you cannot look ahead. So when a voice assistant answers "what's new?" by reading you the top of a feed, you sit through the same items you heard yesterday, at full length, before you reach anything you did not know.

The scarce resource in a spoken interface is not answers. It is not being told the same thing twice, and not having to re-establish where you were.

Cairn is a small MCP server that supplies both, and nothing else.

Related MCP server: JauMemory MCP Server

What it does

Ask it what's new and it queries several public sources at once, then removes everything you have already been told — in this session, in yesterday's, in any session. What it reads you is what is actually new.

Then it stays useful afterwards. A briefing is numbered, and the numbers keep working:

"What's new on protein folding?""Three new things. First: … Second: … Third: …"

…two days later, a different conversation…

"Tell me more about the second one.""The second one. …"

That follow-up is the part that does not exist today. An MCP session begins at initialize and dies with the connection. A person's memory of what they were told does not. Conflating the two is exactly why assistants repeat themselves, and Cairn's whole design is the refusal to conflate them: state is keyed to the user, never to the session.

What it is measured to do

Every number below is produced by a script in this repository, on real data. Re-run them and you will get your own.

It stops repeats — 57.3% → 0%

scripts/measure_impact.py replays the last five calendar days of a real, timestamped arXiv corpus across three topics, one "what's new" per day. The day boundaries and the feed's turnover both come from Item.published; nothing about the arrival rate is assumed. Both arms read the same feed, to the same depth, and may speak at most five items per session — that symmetry is enforced in the script, not asserted here.

stateless assistant

Cairn

spoken slots used

75

32

repeats

43 (57.3%)

0 (0%)

distinct things heard

32

32

distinct per spoken slot

0.43

1.0

speech spent on repeats

202 s

0 s

The last row is timed by the product's own renderer: cairn.speech.spoken_seconds over the line render_brief actually speaks for each repeated item, at its own 150 words per minute. It used to be a hand-rolled word count over titles, in a script that imported spoken_seconds and never called it.

The claim is the third and fourth rows together: the same thirty-two things, heard in thirty-two slots instead of seventy-five. Cairn does not find the user more; it stops spending their time on what they already have. The baseline always fills its five slots, because a repeat still costs a slot. Cairn stops when the feed holds nothing it has not already said.

Two earlier versions of this table were wrong, and both corrections are worth stating because the second one is the more interesting failure. The first printed "75 | 75" and called it the same speaking budget; it was not. The second fixed that and then quietly gave Cairn a fifteen-item feed while the baseline read the top five of it, which made the "distinct things heard" row read 32 against 55 — a gap produced by the feed depth, not by deduplication. At equal depth the two arms deliver exactly the same thirty-two items, and that is what the table now reports.

Cairn can legitimately read deeper than a stateless assistant, precisely because it can skip what it has already said, and that is a real advantage — at a fifteen-deep feed it delivers 55 distinct items to the baseline's 32. But that is a different claim from deduplication, so it is measured as a labelled variant by scripts/sensitivity.py (F) rather than folded into the headline.

The measured arrival rates over the replay differ by more than an order of magnitude between topics — 3.2, 1.0 and 0.2 items a day — and that is the mechanism: a recency-ranked top five on a slow topic is mostly yesterday's top five. The script reports the rate it measured for each topic rather than assuming one.

What this figure does and does not depend on. It is a function of the corpus's timestamps, not its text: swap the titles for random strings and keep the dates, and you get 57.3% again, because a repeat is the same item resurfacing. Strip the dates and the measurement collapses to zero rather than inventing a rate; compress every arrival into one day and it goes to 0%; spread arrivals one per day and it rises to 64%. scripts/sensitivity.py runs all four variants, and tests/test_claims.py keeps them as regression tests. This matters because the previous implementation did not pass that test: it hardcoded two arrivals per day, never read Item.published, and reproduced its headline 48% exactly on a corpus of 180 random strings. The corpus contributes its timestamps to this figure and its text to the partial-recall and wasted-speech figures below.

What it also depends on: the shape of the session. The 57.3% is measured at five days of five spoken items. That is a choice, and it moves the number: across sessions in {3, 5, 7, 10, 14} and items-per-session in {3, 5, 8} the baseline repeat rate ranges from 37.0% to 77.1%, rising with both. The direction never changes — the baseline repeats substantially in all fifteen cells and Cairn is at 0% in all fifteen — but the point estimate is one cell of that grid and the whole grid is printed by scripts/sensitivity.py (G). Those two constants used to be undocumented and unswept, which meant a reader told "sensitivity.py runs all the variants" would have believed the headline was stress-tested on every axis when it was stress-tested on one.

Follow-ups survive the session — 100% by number

Deliver twelve briefings to a file-backed store, close it, and resolve the references from a separate operating-system process that opens the file for the first time. The parent keeps no handle; the process ids are printed and recorded. tests/test_transport.py does the same thing one level up, across a real initialize / DELETE / initialize boundary over HTTP.

(An earlier version of this script claimed to "close that store's session and open a new one" while holding a single Store(":memory:") object throughout — an in-memory SQLite database cannot be reopened at all. It measured a live object answering its own writes. That is fixed, and the numbers below are from the cross-process run.)

resolution method

result

by ordinal — "the second one"

60 / 60 (100%)

by description, verbatim words

25 / 25 (100%) — an upper bound, not a finding

by description, partial recall

20 / 25 (80%)

a stateless server

0%, by construction — with no referent frame, an ordinal from an earlier session indexes into nothing

The two description rows are separated on purpose. Handing the matcher three words copied straight out of the title scores 100%, and that number means nothing: it is the mechanism being fed its own input. The row worth reading is the second one — two words from the second half of the title, modelling a listener who has lost the distinctive opening, which is exactly what a spoken channel takes from you. That scores 80%, and it is reported at 80%.

It is on the protocol revision it claims

scripts/conformance.py drives a running server over real HTTP — no SDK, no mocks — and validates every response against spec/schema-2025-11-25.json, the unmodified schema published by the MCP project (vendored with attribution in spec/NOTICE).

$ python3 scripts/conformance.py --spawn
...
40 passed, 0 failed, 40 checks

Those 40 include the things that are easy to claim and awkward to prove: Origin rejection with 403, session 404 after DELETE, 400 on an unsupported MCP-Protocol-Version, batch rejection, and the whole 2025-11-25 task surface — tasks/get, tasks/list, tasks/result, tasks/cancel, the -32602 on cancelling a terminal task, and the io.modelcontextprotocol/related-task metadata on a task result.

Why tasks, and why they are the honest version-check

A five-source fan-out takes seconds. A voice turn cannot block on it. With task augmentation, brief returns a task handle immediately — the assistant can say "hold on, checking five sources" — and the result is collected when it is ready.

Tasks are also the reason the version claim is checkable. Any server can put "2025-11-25" in a string. Tasks did not exist in 2025-06-18, so a working tasks/result is evidence the revision is real rather than asserted.

Reproducing all of it, offline

Every figure above comes from a script in this repository, and the inputs those scripts read are committed.

python3 -m pytest -q                                     # 187 tests
python3 scripts/conformance.py --spawn                   # 40 checks over real HTTP
python3 scripts/measure_impact.py --numbers data/measured.json
python3 scripts/check_readme_numbers.py                  # this README vs that file
python3 scripts/sensitivity.py                           # try to break the headline number
python3 scripts/mutants.py                               # 17 deliberate defects, all caught

data/cassette.json holds recorded responses for three topics, so the fan-out and the briefing pipeline run with no network at all. Every source that issues a request for a topic is recorded for it; USGS appears once, for the hazards topic, because for the other two it declines to ask (see the source table below), and tests/test_replay.py asserts that each of the five delivers items for at least one recorded topic:

CAIRN_HTTP_MODE=replay CAIRN_CASSETTE=data/cassette.json python3 -m cairn
python3 scripts/demo.py                                  # the whole demo, offline, ~50s
python3 scripts/record_cassette.py                       # re-record it (needs network)

tests/test_replay.py runs that path with socket.socket monkeypatched to raise, so a live request escaping during replay fails the suite rather than quietly returning different items.

What the tests do and do not cover

pytest reports 79% statement coverage of cairn/. The largest single block of the gap is cairn/transport.py at 41% of the missed statements — the SSE streaming and task-notification paths, driven by scripts/conformance.py over a real socket (C4.1C4.14) rather than by pytest. The second is cairn/__main__.py, which pytest never imports at all and which is exercised only by conformance.py --spawn. An earlier revision of this paragraph said the gap was "mostly" transport; that overstated its share by about a factor of two and did not mention the CLI entry point. That split is stated because a single coverage percentage would hide it.

Coverage is a weak signal, so the suite is also checked by mutation, and the campaign is in the repository rather than in a report:

$ python3 scripts/mutants.py
baseline (unmutated copy): GREEN
  M1   KILLED   the DNS-rebinding origin check is deleted
  ...
17/17 killed by pytest alone

Seventeen deliberate defects — the origin check deleted, DELETE made a no-op, session ids replaced by a counter, PROTOCOL_VERSION downgraded, the arXiv namespace broken, the OpenAlex abstract reconstruction reversed, the argument validator removed, the mute guard weakened, the USGS topic gate removed, the body-size guard removed, the date-driven replay reverted, and one for each of the six defects the second audit found — are all caught by pytest alone, each by a named test. Six of the first eleven previously survived; four survived both suites.

Each mutant is applied to a copy under /tmp, never to the working tree, and the unmutated copy is run first and has to be green. That guard is not decoration: without it a bad command line makes every mutant look killed and reports a perfect kill rate for a suite that killed nothing.

The dedup mechanism, stated accurately

Two layers, deliberately not presented as equals.

Layer 1 — canonical fingerprint. This is the guarantee. An exact key built from a stable identifier where one exists (arXiv id, DOI, OpenAlex id, HN item id, Wikipedia page id), with the decorations stripped, so the same paper reached through arXiv and through OpenAlex collapses to one key. No threshold, no tuning. This handles the dominant real case: an item resurfacing on a later day.

Layer 2 — lexical near-duplicate. This is a conservative assist. IDF-weighted containment over title and summary, with the IDF built from this user's own history. It is tuned for precision, not recall, because the two errors are not symmetric: a missed rewording costs a mild repeat, while a false positive means the user is never told something new at all and has no way to find out.

Measured on the labelled corpus in tests/test_novelty.py: zero false positives, and a minority of rewordings caught. Short titles do not carry enough signal for a lexical method to do better, and an earlier simhash implementation was cut from this repository after measurement showed its same-claim and different-claim distances overlapped. Do not read this layer as paraphrase detection. It is not.

Sources

Five, all public and credential-free — a hard constraint, so that anyone who clones this repo can run it with no key to obtain and no account to create.

source

what it is for

arXiv

new preprints

OpenAlex

the same literature, indexed differently — included deliberately as an overlap, because it is what proves Layer 1 earns its keep

Hacker News

the practitioner counterpart. It queries Hacker News's own search index (hn.algolia.com, public, no key) newest-first. It used to filter the ~30-story front page by topic word, which an audit measured at zero items for all three recorded topics — a source that is queried, succeeds and returns nothing advertises breadth the user never gets.

USGS

a fast-moving feed with hard identifiers, where a repeat is actively harmful. It is a global feed with no topic parameter, so unlike the others it decides for itself whether the question is one it can answer. It answers only when the topic carries a geoscience word outright (seismic, tsunami, aftershock) or an ambiguous one in a geoscience collocation (fault line, natural disaster); a bare ambiguous word is refused, so fault tolerance, magnitude estimation in astronomy and hazard ratio survival analysis get nothing from it. Both of those were real defects, found by audit and fixed in that order: first it accepted the topic and ignored it, putting six off-topic earthquakes in every briefing on every subject; then its keyword list treated fault and magnitude as sufficient.

Wikipedia

background for an unfamiliar topic

Adding one is a small, safe change: adapters only fetch and parse, and every judgement about what the user hears happens above them.

A briefing says which sources failed. Depending on five public services means one of them will be down, and presenting a thinner answer as though it were complete would be the wrong kind of quiet.

Running it

Python 3.11 or newer. No runtime dependencies.

git clone https://github.com/jianwang-ntu/cairn-mcp
cd cairn-mcp
python3 -m cairn                      # http://127.0.0.1:8765/mcp

Point any MCP client at http://127.0.0.1:8765/mcp over Streamable HTTP.

Or watch it do the thing it is for, with no network and no client to install:

python3 scripts/demo.py

That spawns the server against the committed cassette and drives it over real HTTP — initialize, tools/list, a briefing, the same question again (three items suppressed), DELETE, a new initialize, and then "tell me more about the second one" answered from a session that no longer exists. It is the script the demonstration video records.

A friction log from building this against the MCP 2025-11-25 spec is in FRICTION_LOG.md.

python3 -m cairn --help               # host, port, store path, weather opener
python3 -m cairn --weather 1.29,103.85,Asia/Singapore

Development:

pip install -e '.[dev]'
python3 -m pytest -q                  # 187 tests, no network needed
python3 scripts/conformance.py --spawn
python3 scripts/measure_impact.py --numbers data/measured.json
python3 scripts/check_readme_numbers.py
python3 scripts/sensitivity.py
python3 scripts/mutants.py
python3 scripts/measure_impact.py --record   # refresh the corpus (needs network)
python3 scripts/record_cassette.py           # refresh the cassette (needs network)

The tools

tool

the utterance it is for

brief

"what's new", "catch me up"

follow_up

"tell me more about the second one" — works across sessions

track

"follow X", "never mention Y again"

mark_heard

"I already knew that"

place_marker

"remember where I am" / "where was I?"

recap

"what have you told me"

Every tool returns a spoken string alongside its structured payload: URLs stripped, et al. expanded, length capped, ordinal spoken first so the user has a short handle to reply with.

Three MCP resources expose the durable state directly — cairn://profile, cairn://ledger/recent, cairn://markers — because a memory you cannot inspect is one you cannot trust.

Security

The transport spec's requirements are implemented, not described:

  • the Origin header is validated, and a present-and-invalid one gets 403 (DNS-rebinding defence);

  • the listener binds 127.0.0.1 by default;

  • session ids come from secrets.token_urlsafe, and are visible ASCII only.

Cairn holds a record of what one person has been read. Treat the SQLite file as personal data. There is no authentication layer yet: run it on loopback, or put one in front of it. That is a real limitation and it is stated here rather than left to be discovered.

What it does not do

  • No paraphrase detection worth the name — see Layer 2 above.

  • No authentication.

  • Description-based follow-up resolves 80% of the time on partial recall, not 100% — the figure in the table above, not a different one. (This line said 60% for two commits while the table said 80%. The README now quotes data/measured.json, which scripts/measure_impact.py writes, and scripts/check_readme_numbers.py reads the tables above positionally — row label, then cell, then the numbers in that cell in order — so corrupting any checked figure fails it. The first version of that checker tested only that each figure appeared somewhere in the document, which an audit showed could not fail on a wrong number: 57.3% → 57.4% passed, and a spoken-slot count of 55 → 5 passed. tests/test_claims.py now corrupts each checked cell in a copy of this file and asserts the checker rejects it.)

  • Weather is exempt from the ledger, because it changes. That exemption is narrow and deliberate, and it is the only one.

Licence

Apache-2.0. See LICENSE. The vendored MCP schema keeps its own attribution in spec/NOTICE.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jianwang-ntu/cairn-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server