PluginWorld
Tr

traveler-data-access-demo

MCP

Enables governed, AI-safe access to traveler data through three typed tools, enforcing consent, PII removal, provenance, and audit at the MCP boundary.

@surendra-gvsr · MIT License · updated today

SECURITY

B

SCORE

60

STARS

▲ 0

PLUG IN

git clone https://github.com/surendra-gvsr/traveler-data-access-demo.git

See the README to configure this MCP server

README

traveler-data-access-demo

tests

Governed, AI-safe access to customer data: consent, PII removal, provenance and audit at the boundary where a model asks a question.

All data here is synthetic and generated by this repo. No real company, product, dataset or branding is involved.


The problem

An agent that can answer "what kind of trip is this traveler likely planning?" is useful. Getting there means handing a model access to customer data, and that access has four ways of going quietly wrong:

It sees personal information it never needed. The traveler's email is sitting in the same row as their favourite destination. Nothing about answering the question requires it, but nothing stops it travelling either into the prompt, the model's context, the logs, and anywhere the response is forwarded.

It acts on data that arrived out of order. Events are replayed, retried and delayed. A correction delivered before the thing it corrects, or an event delivered twice, leaves a profile that is wrong in a way nothing complains about.

It answers an as-of question with data from the future. "How many searches in the 30 days before this booking?" is easy to answer with information the platform did not have at that moment. The number looks reasonable and cannot be reproduced in production.

It sees data the traveler did not consent to share. Consent changes over time, and it changes in both directions. An opt-out delivered today has to survive a replay of last week's opt-in.

What these failures have in common is that none of them raise an exception. Every one produces a plausible number. That is the entire design problem: the dangerous outcome looks exactly like the correct one, so correctness has to be structural rather than careful.


What this is

A small, complete slice of that platform — about 1,700 lines of Python, SQLite for storage, five specs written before the code, and 99 tests that map back to them.

Piece Does Spec
Event stream Generates synthetic searches, bookings, cancellations and consent changes with deliberate duplicates, out-of-order delivery and late arrivals 001
Profile builder Applies events incrementally; idempotent under replay, correct under reordering 002
Point-in-time features "Searches in the last 30 days, as of T" computed from what was actually known at T 003
Access layer The single read path: consent, PII scrubbing, provenance, audit 004
MCP server Three typed tools — the only surface a model ever sees 005

specs/README.md maps every acceptance criterion to the test that proves it.


The design, in plain English

Two clocks, not one

Every event carries two timestamps:

  • occurred_at — when the thing happened
  • ingested_at — when the platform found out

Most pipelines keep only the first, and that one omission is what makes point-in-time features wrong. There are two different leaks hiding here:

  • Future leakage — the event had not happened yet at as_of. Filtering on occurred_at catches this, and it is the leak everyone remembers.
  • Knowledge leakage — the event had happened, but had not arrived yet. Only ingested_at catches this one.

A feature that passes the first check and fails the second is the dangerous case: it describes a world the platform could not see. Train on it and the model learns from information that will not exist at serving time.

So every feature read goes through one function, features.visible_events, which filters on both clocks and nothing else does. The synthetic data includes an event that occurred on 2 October and arrived on 11 October specifically so there is an instant where the two filters disagree, and a test (C6) places its cut point inside that window.

Events are classified by how they behave under replay

Replaying an event is not uniformly safe or unsafe — it depends on the field:

  • Counters (search_count, destination tallies) commute, so order never matters, but they must be applied exactly once. The processed_events ledger provides the "once": INSERT OR IGNORE plus a rowcount check is the duplicate test. Checking first and writing second leaves a window for a concurrent second delivery to slip through.
  • Last-write-wins fields (home_origin, preferred_currency) do not commute, so each is guarded by a version comparison and skipped when the incoming event is older.
  • Consent is last-write-wins, versioned separately, and the one place where being wrong is a privacy incident rather than a bad metric.

A late event is therefore neither a no-op nor a full apply: its counters land and its overwrites do not. That partial outcome is reported as APPLIED_STALE rather than hidden.

One door, four controls

Every read — from the CLI, from the tests, from the model — goes through access.serve(). It is the only function that touches traveler data, and it always does these four things in this order:

  1. Consent, evaluated at read time against current state. Not as-of: as_of rewinds facts, never permission. Allowing it to rewind consent would make it a one-parameter bypass. No consent record means deny — the default is closed.
  2. PII removal, by allowlist. The typed output models simply do not name contact_email, full_name or phone, so those fields have nowhere to go. A scrubber runs afterwards and checks field names, this traveler's exact known values, and generic email/phone patterns. It fails the response rather than masking it, because masking lets a leak ship quietly in a degraded form.
  3. Provenance, attached to every result: source, version, computed_at, as_of, event count, and the age of the newest input event. An agent handed a bare number cannot tell a fresh profile from a stale one.
  4. Audit, appended for allows and denials. A log that records only successes cannot prove anything about the failures. It stores field names, never values — an audit trail full of payloads is a second, less guarded copy of the data it exists to police.

The MCP tools are deliberately thin wrappers with no database access of their own. A test parses mcp_server.py and fails if a second read path ever appears, because the whole guarantee rests on there being exactly one.

The schema is the privacy policy

As far as a model is concerned, a field that is not in the output schema does not exist. It cannot be requested, inferred or prompted out. So the tools return closed Pydantic models rather than dicts, and the tests assert against the schema rather than a sample response: a response can happen to be clean, while a schema is a standing promise.


Running it

Python 3.11+. No installation needed beyond the dependencies.

pip install pydantic "mcp>=1.2" pytest pytest-asyncio

# make the package importable (or: pip install -e .)
export PYTHONPATH=src          # PowerShell: $env:PYTHONPATH="src"
# 1. generate the synthetic stream (deterministic: seed 7)
python -m traveler_access.generate

# 2. load it and build profiles
python -m traveler_access.pipeline load --reset
python -m traveler_access.pipeline build
python -m traveler_access.pipeline status

# 3. serve one traveler through the governed path
python -m traveler_access.pipeline show trv_003
python -m traveler_access.pipeline show trv_010     # DENIED: AI access revoked
python -m traveler_access.pipeline audit -n 5

# 4. the tests
python -m pytest -q

The demo

python demo/ask_claude.py --dry-run     # no API key needed
python demo/ask_claude.py               # needs ANTHROPIC_API_KEY

For the second one, copy .env.example to .env and put the key there. .env is gitignored and only the demo reads it; the pipeline and the tests never need a key.

Both modes launch the MCP server as a real subprocess and speak to it over stdio. The difference is only whether a model chooses the tool calls.

The question asked is "What kind of trip is this traveler likely planning?", for two travelers: trv_003, who consented, and trv_010, who revoked AI access. Every tool call and result is printed, then the whole conversation — everything the model was shown and everything it wrote — is checked against all 36 PII values in the dataset, and the audit trail the server subprocess wrote is printed.

demo/transcript.md is a recorded run, so the governance is reviewable without an API key. It also shows the raw event payload containing the email next to the complete tool response that does not.

trv_003 searches Barcelona and Rome from ARN, two adults, 3.8 nights on average, premium economy, booked about 37 days ahead, with a live booking departing after the as-of instant. Short European city breaks for a couple, planned over a month in advance. trv_010 returns AI_ACCESS_REVOKED from every tool, and the system prompt tells the model that a denial is a final answer rather than an error to route around.

Other travelers worth looking at

Traveler Shows
trv_003 The happy path, plus an out-of-order delivery and a late-arriving search
trv_010 Opt-out, followed by a replayed older opt-in that must not reinstate access
trv_011 Consent for a narrow purpose only → PURPOSE_NOT_PERMITTED
trv_012 No consent record at all → NO_CONSENT_RECORD, the fail-closed path

What the tests prove

99 tests, grouped by spec. Each is named after the acceptance criterion it covers.

File Tests Covers
test_event_stream.py 13 Determinism, bitemporal ordering, duplicates, nothing after NOW
test_profile_builder.py 16 Idempotency under replay, correctness under reordering, checkpointing
test_point_in_time.py 15 Both leakage classes, window boundaries, as-of carriage
test_access_layer.py 36 Consent in all four states, PII leak injection, provenance, audit
test_mcp_tools.py 19 Tool schemas, denial envelopes, single read path, a real stdio session

Tests that assert a control works are cheap to write and easy to fool, so three of them were checked by deliberately breaking the control and confirming the right tests failed:

Mutation Expected failures Got
Neutralise the ingested_at filter knowledge-leak tests only C2 ×3, C6 ×2
Replace per-field watermarks with one profile-wide watermark the cross-event-type tests B5, B3-regression
Evaluate consent as-of instead of at read time the consent-rewind tests D5 ×2, D4

Two bugs the tests actually caught

Both of these were written by me, found by a test, and fixed — they are in here because "the tests pass" is only interesting if the tests were capable of failing.

A profile-wide version watermark looked equivalent to per-field watermarks, and was not. Searches and bookings write overlapping but different field sets. With one watermark per profile, a search at version 10 delivered before a booking at version 4 marks the booking stale — so the booking never sets preferred_currency, a field no search ever writes. The column stays NULL forever and nothing reports an error. Criterion B5 caught it; the fix was a field_versions JSON column, one watermark per field, and spec 002 now says why.

utc(datetime.now()) labelled local time as UTC instead of converting it. The helper treats a naive datetime as already-UTC, which is right for parsing stored timestamps and wrong for the system clock. Every computed_at and the as_of fallback were off by the machine's timezone offset — invisible on a UTC host, five hours wrong on mine. It survived the whole test suite and was caught by eye when the demo printed a provenance timestamp five hours behind the audit record beside it. There is now a regression test, and the one correct way to read the clock lives in events.now().


Limitations

Things I deliberately did not build, and would need to before this went near production:

  • Preferences are latest-state, not point-in-time. get_traveler_preferences reads the current profile; only get_recent_searches honours as_of. Making preferences as-of-able means storing profile history, which is a real design decision, not an afterthought.
  • list_travelers would not exist. Letting an agent enumerate customers is a demo affordance for making the consent cases visible. It is marked as such in the tool description.
  • Consent is a boolean plus a purpose list. Real consent has jurisdictions, expiry, withdrawal propagation to downstream copies, and lawful bases that are not consent at all.
  • No identity resolution. One traveler, one id. The hard part of a customer data platform — deciding that two sets of events belong to the same person — is assumed away.
  • SQLite and a single process. The idempotency argument relies on per-event transactions and a unique primary key, which is the same argument in Postgres, but the throughput story is not.
  • The scrubber only knows PII it can recognise. Field names, this traveler's exact values, and email/phone shapes. A free-text note containing a surname would pass.

How I built this with AI tools

I used Claude Code throughout, and the division of labour matters more than the volume:

Written first, by hand, and not delegated: the problem framing, the scope, and the acceptance criteria in specs/. Each spec was written before its implementation and is what the implementation was checked against. Where the design changed, the spec changed first.

AI-generated, then reviewed line by line: most of the implementation and most of the test bodies. The generated code was wrong in interesting ways twice — the profile-wide watermark and the UTC bug above — which is roughly the error rate I would expect from myself on first draft, and the reason the specs and tests came first rather than after.

Reviewed and rejected: several things that passed their tests but did not deserve to. A leak-injection test originally mutated a Pydantic model's __dict__, which model_dump() ignores, so it proved nothing until it was rewritten to declare a real PII field. A point-in-time test chose an as_of outside the late event's in-flight window, so it passed with the knowledge-time filter deleted. Both were found by breaking the control on purpose and noticing the tests stayed green.

Hand-verified: the mutation table above, the four consent cases end to end, the MCP stdio session as a real subprocess, and the claim that no PII crosses the boundary.

I can explain every line in this repo, including the parts I did not type.


License

MIT — see LICENSE.

SIMILAR PLUGINS