thebetween-mcp
by Tristiank
README.md
# The Between — MCP server
A public, **read-only** [MCP](https://modelcontextprotocol.io) server over the verified
company map at [thebetween.ai](https://thebetween.ai), so an assistant can query it
directly instead of scraping the site.
Nothing here writes anything, anywhere. All four tools are `readOnlyHint`.
---
## The three words that matter
If you take one thing from this README, take this. **MAPPED**, **VERIFIED MATCHES** and
**MEMBERS** are three different things, and conflating them misrepresents real companies:
- **Mapped** — a company discovered *and* verified from public information. Being mapped is
**not consent**, not a relationship, and not membership. These companies did not opt in.
- **Verified matches** — evidenced pairings between two mapped companies. The badge proves
the cited source was fetched and **names** the company. It is *not* a claim that the
source is *about* that company, nor that it states the reason given for the match. Every
card links to its source so you can judge that yourself.
- **Members** — companies that have claimed their agent. The only consent-based number.
Every tool description opens with that distinction, and every response carries a `_notice`
repeating it with the live counts. Please don't describe mapped companies as members,
customers, users, clients or partners of The Between.
## Tools
| Tool | Returns |
|---|---|
| `search_network(query, vertical?, location?, min_fit?, limit=10)` | Matching companies: profile, whether they are an origin of the map, match count |
| `get_matches(company, min_fit?, freshness?, limit=10)` | Verified matches with the full evidence card — dated source URL, signal, fit score, reason |
| `get_claim_info(company?)` | How a company claims its agent, and what an agent would inherit from the evidence already on the map |
| `get_census()` | The live census, with definitions |
`get_claim_info` returns the reserve URL and stops. The form on the site is the only write
path into the network; this server cannot register, claim or change anything.
## Connecting
The server speaks MCP over Streamable HTTP. Once it is deployed at
`https://mcp.thebetween.ai/mcp`, point any MCP client at that URL — no authentication, no
API key.
> **Not yet deployed.** The public endpoint is not live. Run it locally in the meantime.
```bash
git clone https://github.com/Tristiank/thebetween-mcp
cd thebetween-mcp
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
thebetween-mcp # serves on 127.0.0.1:8000
curl localhost:8000/healthz
```
| Variable | Default | Meaning |
|---|---|---|
| `BETWEEN_DATA_URL` | `https://thebetween.ai/data/discoveries.json` | Where the map is read from |
| `BETWEEN_DATA_PATH` | *unset* | Read a local file instead |
| `BETWEEN_REFRESH_SECONDS` | `900` | Refresh interval |
| `BETWEEN_ALLOWED_HOSTS` | *localhost only* | Comma-separated `Host` values to accept. **A deployment must set this** — see below |
| `BETWEEN_TRUST_PROXY` | *off* | Read the `Fly-Client-IP` header. **Only turn on behind that proxy** — see below |
| `HOST` / `PORT` | `127.0.0.1` / `8000` | Bind address |
## How it works
**The data is never bundled.** The map is fetched from the site on boot and every 15
minutes, ETag-conditional, keeping the last known good copy on failure and reporting
staleness in every response's `_data` block. A bundled copy would go stale the moment the
site is redeployed. It is a ~1.1 MB JSON file, so there is no database and no search index —
it is loaded and indexed in memory.
**The reverse index is the point.** The published file is keyed origin → cards. 716 of the
887 mapped companies appear only as the *discovered* side of a card, so without indexing the
other way they cannot be looked up at all. 145 companies are both, and their evidence lives
in both directions — reading only one returns half of it.
**Rate limiting protects the server, not the data.** 30 requests/minute per IP, burst 10,
2,000/day, `limit` capped at 25. The whole dataset is a single public file: if you want all
of it, fetch [discoveries.json](https://thebetween.ai/data/discoveries.json) rather than
paging this API. That is faster for you and kinder to a very small machine.
`BETWEEN_TRUST_PROXY` is **off by default and should stay off unless the server is actually
behind a proxy that overwrites the header**. When on, the limiter reads `Fly-Client-IP`;
anything able to reach the origin directly could then send a new value per request and get a
fresh bucket. When off, all callers behind a proxy share one bucket — correct locally, wrong
in production. There is no value that is right in both places, so it is an explicit switch.
**The `Host` allowlist is not optional in production.** MCP validates the `Host` header to
prevent DNS-rebinding attacks, and its default allowlist is localhost only. Left alone, a
deployed server answers every request with `421 Invalid Host header` — while passing every
local test, because local tests send `Host: 127.0.0.1:<port>`. So `BETWEEN_ALLOWED_HOSTS`
must name every hostname the server is reached by:
```
BETWEEN_ALLOWED_HOSTS=mcp.thebetween.ai,thebetween-mcp.fly.dev
```
Unset, it falls back to localhost. That means a deployment that forgets it fails loudly
rather than running with `Host` validation quietly switched off, and the startup log names
the hosts it will accept.
## Deploying
`Dockerfile` and `fly.toml` are here and ready. The image is a plain `python:3.12-slim`
running as a non-root user; every dependency ships manylinux wheels, so there is no
compiler in the build and no builder stage to strip.
```bash
fly launch --no-deploy # first time only; keeps the fly.toml in this repo
fly deploy --remote-only # builds on Fly, so no local Docker needed
fly logs
```
Then the custom domain:
```bash
fly certs add mcp.thebetween.ai # prints the exact DNS records to create
fly certs check mcp.thebetween.ai
```
The machine is sized from measurement rather than habit: the process sits at **~81 MB
resident** with the map loaded, peaking at ~83 MB during a refresh when it briefly holds
two snapshots. `shared-cpu-1x` with **256 MB** is about three times what it needs.
`auto_stop_machines` is on, so the server scales to zero and costs close to nothing when
idle — every tool call is a pure read of a shared snapshot and no per-client state is kept,
so a machine can stop and restart between requests with nothing to strand. The cost is a
cold start: boot, then fetch the ~1.2 MB map before the first answer. Parsing and indexing
take about 18 ms; the fetch dominates. If that first-request delay matters, set
`min_machines_running = 1`.
## Two things the data forces
**`vertical` belongs to origins.** The map classifies its 171 origin companies and nobody
else. A discovered company gets `matched_verticals` — the verticals of the origins that
matched it — and never a bare `vertical`. A logistics firm matched to a beverage brand is
not a Food & Beverage company. `search_network(vertical=...)` therefore means "matched to an
origin in this vertical".
**`min_fit` excludes unscored matches.** 165 of 991 cards carry no fit score. They are
verified but were never scored, so they cannot be shown as clearing a bar. Responses report
how many were excluded, so you can tell "no matches" from "no scored matches".
## About `src/thebetween_mcp/_vendored/`
Those files are **generated, not written**, and the header in each says so.
The pipeline that builds the map is closed-source. This server needs a handful of functions
from it — chiefly `norm()`, which decides whether two spellings are the same company — and a
public package cannot install a private one, so they are copied in by a generator on the
other side.
That is worth being careful about: `norm()` once existed in five places, and two production
incidents were drift between those copies. A curly apostrophe (U+2019) split *L'Oréal* so a
screened-out company reached the public map; a macron split *Inde Wīld* into two census rows,
inflating the mapped count and inventing a connector. So the copy is policed from both sides:
- **`tests/test_vendored.py`** asserts it still reproduces golden vectors generated by the
canonical implementation — it catches drift *between* the repositories.
- **`tests/test_norm_contract.py`** asserts what `norm()` must *mean* — accents fold rather
than vanish, every apostrophe variant collapses, distinct companies do not merge. It
catches a change made badly in *both*, which the vectors would happily record as correct.
**Please don't edit anything under `_vendored/`** — it will be overwritten. Everything else
is ordinary code and pull requests are welcome.
## Tests
```bash
pytest # synthetic fixture only
BETWEEN_MCP_LIVE_TESTS=1 pytest # also the census-parity regression, against the live map
```
Two layers. A synthetic six-company map, hand-countable, exercises every awkward shape in
miniature — an origin that is also discovered, an origin nobody discovered, a connector, an
unscored card, an accented name. Against the real map, the regression tests assert **census
parity**: the indexes must reproduce `mapped 887 · verified matches 991 · connectors 58 ·
fresh 691` exactly, the same recomputation the site's own launch gate runs before every
deploy. That catches the subtlety that is easiest to get wrong — a *connector* is a
**discovered** company matched to more than one origin, and counting origins too gives 94.
## Removal, contact, licence
Any mapped company can email **tristian@thebetween.ai** and be removed from the public map
within **7 days**. No reason required, and no form. If something about an entry is wrong,
say so and it gets fixed whether or not you want to stay.
- [Privacy policy](https://thebetween.ai/privacy) · [Terms of service](https://thebetween.ai/terms)
- Issues and pull requests: [github.com/Tristiank/thebetween-mcp](https://github.com/Tristiank/thebetween-mcp)
MIT licensed — see [LICENSE](LICENSE). The licence covers this server's code. It does not
grant rights in the map's contents, which are published at thebetween.ai under the terms
linked above.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues