A REST API's documentation is read by a developer once. An MCP tool description is the one piece of text a model reads on every single call, which makes it where you stop an agent misreporting your numbers. It is also prose, so it goes stale like prose.
AddressIntel reads building permits and parcel records across the SF Peninsula and scores what could be built on a given lot. It already had a REST API. Putting a Model Context Protocol server in front of that API is, as engineering, close to trivial: the whole thing is 312 lines of TypeScript across two files, six tools, and a client that adds a header, builds a query string and unwraps the API's error envelope.
What is not trivial is that the consumer changed. A developer reading REST docs forms a mental model once, writes code against it, and that code then behaves the same way forever. A model reads the tool description on every call and decides, each time, what the returned numbers mean and how to report them to whoever asked. The description is not documentation. It is the contract, and it is enforced by nothing.
That matters here more than it would for most data, because this data is full of ways to be confidently wrong. A teardown ROI of null is not a zero. A score that ranks candidates is not a return. A negative number is usually correct. Get any of those wrong in a summary to a buyer and you have invented a deal that does not exist.
What got built
Six tools over stdio, published to npm as addressintel-mcp and listed in the official MCP registry under the DNS-authenticated namespace co.addressintel/permits. search_permits for issued permits by city, keyword and valuation; list_sb9_inventory for lot-split and duplex eligibility with the per-parcel block reasons; list_adu_leads and list_redevelopment_leads for feasibility-ranked parcels; list_market_signals for the opportunity feed; get_property_intelligence for one property in depth.
The declaration keeps each tool's validation schema and its advertised JSON Schema side by side in one object, because a tool that spells its shape out twice in two separate blocks will eventually spell it differently:
interface ToolDef<T extends z.ZodTypeAny> {
name: string;
description: string;
inputSchema: { /* advertised to the client */ };
args: T; // validates the incoming call
run: (args: z.infer<T>) => Promise<unknown>;
}
Two decisions in the transport layer are worth more than the rest of the code put together, and both exist because the caller is an installed agent rather than a developer at a terminal.
The first is what happens with no API key. The first draft of this client sent no auth header at all, so every endpoint returned 401 and the server was useless until you went and got a key. Now an unset key falls back to the literal string demo, which the API recognises as a public tier at 10 requests per minute and 5 rows per call. The server does something real the moment it is installed. A human will go find a key when they hit a wall; an agent will simply report that the tool is broken.
The second is error shape. The API returns a readable error in its failure envelope, and that string gets surfaced to the model rather than a bare status line, because it is what tells the agent whether to narrow the query, back off, or ask the user for a key. A status code tells a model nothing it can act on. For the same reason, a lookup that misses returns { found: false, message: "No property found with ID: ..." } rather than null: outside our coverage area a miss is a normal answer, not a failure, and it should arrive in words the model can pass along.
API_BASE defaults to the published domain rather than the bare App Hosting URL. Both serve the API, but the latter moves with the backend, and an installed client is the worst place to hard-code something that moves.
The descriptions are where the guardrails live
Here is the part I did not expect to spend the most time on. The description field for list_market_signals ends with three sentences that are not describing anything:
Quote those assumptions alongside the number.
nullmeans not modellable, withunmodellableReasonnaming the missing input: treat it as unknown, never as zero. Negatives are normal here; most of this market does not pencil.
That is an instruction to a model, sitting in a schema field. It is there because the alternative is an agent that says "four of these properties show a 0% return" when what the data said was "I could not model four of these properties." get_property_intelligence carries the matching one in the other direction: its scores rank candidates, none of them is a return, and the tool explicitly tells the model that this endpoint carries no ROI figure at all and points it at the other tool for one.
I went looking for how often that guard actually fires. Asking the live API for the five rows a demo-key agent gets, on 2026-10-14:
- San Mateo:
proformaROInull, reasonno_envelope_rule - San Jose:
proformaROInull, reasonno_envelope_rule - Saratoga:
proformaROI-7 - Los Altos Hills:
proformaROInull, reasonno_envelope_rule - San Jose:
proformaROInull, reasonno_envelope_rule
Four of five null, one modellable at -7%, no positives. Both instructions are load-bearing on the very first call a new agent makes. An agent that reads null as zero reports four flat deals; an agent that does not know negatives are normal either suppresses the one real number or flips its sign. Five rows is a demo-tier sample and I am not generalising it to the corpus, but it is exactly what an agent sees, which is the population that matters for this question.
The reason the nulls are nulls took me a second read of the predicate to state correctly, and it is not the jurisdiction story I assumed. no_envelope_rule is parcel-scoped, not city-scoped: it is true when no curated allowable floor area governs a single-family rebuild on that parcel, which five separate conditions can cause. Measured against a production snapshot on 2026-09-03, 501 of 1,039 modelled active listings landed there, and 423 of those were condos, townhomes and multi-family units that never had an envelope to give. Another 56 were unincorporated county parcels, 2 were single-family rows the floor-area pass had not reached, and 20 carried a curated envelope more than four times the existing house, which is a rule that exists being deliberately distrusted. So the dominant case is a property type the model does not apply to, not a city nobody has curated.
In all five cases the served proforma refuses rather than substituting a default, which is the only defensible choice: a substituted envelope produces a number indistinguishable from a real one. The predicate was added after a Saratoga listing cleared an internal shortlist at 39.8% on a nine-thousand-square-foot build that nothing but existing x 1.5 had sized. The on-page calculator still seeds a default for a human to drag; the served number never quotes it.
Two of my own instructions had already gone stale
While checking the above I found that the description I have been praising is wrong, in two places. One drifted three days after the server's last commit, the other about two weeks later.
The hard cost. The list_market_signals description tells the model the proforma assumes "$400/sqft build," and tells it to quote those assumptions alongside the number. The live API serves assumptions.costPerSqft: 550. The constant moved from 400 to 550 on 2026-08-21, three days after the server's last commit, because 550 is the midpoint of a verified $400-700 builder band and 400 was the band's floor, which flattered every deal. Measured over 407 gated active listings at the time of that change, moving the hard cost 400 to 550 to 700 took the median ROI from -3.9% to -14.3% to -22.9%, and the count of parcels clearing 20% net from 60 to 28 to 15.
So the payload carries 550 and the prose next to it says 400, and the prose is the thing instructing the model to quote it. The field is right there in the same response, but nothing obliges a model to prefer a field it was not told to read over a sentence it was told to repeat. A stale number in REST docs misleads a developer who will probably notice. A stale number in a tool description is actively read out to the end user as an assumption behind a figure it does not match.
The history window. On 2026-09-03 the free tier was windowed to 30 days of history, grandfathering every key created before 2026-09-04. The server's paging argument still says only "Clamped to your API key's tier ceiling," which was a complete description of the tiers when it was written and is not one now. Nothing in the server mentions a history window at all; the word does not appear in its source, README or manifest.
The API itself is better behaved than its tool description, and the difference is the whole point. The two routes that apply the window, permits and market signals, both report a historyWindow field in the response, explicitly so a windowed caller can tell a quiet 30 days from a broken query; the other four routes the tools hit are not windowed at all. What is missing is any instruction to read it. For a question like "has anything been permitted at this address," a free key minted since that date is answering from the last 30 days, and the difference between a no and an unknown is sitting in a field the model was never told about. Worth being precise about the scope, too, since "30 days" sounds larger than it is: on the permits tool it windows permit history, but on list_market_signals it touches sold rows only and the active feed stays whole.
There is a third, cheaper instance I may as well report: src/index.ts still announces itself to clients as version 1.1.0 while the package and the registry manifest are both at 1.1.2. That one misleads nobody, which is exactly why it survived. I am not counting it alongside the other two, because those are instructions a model acts on and this is a string.
None of these is exotic. They are the ordinary consequence of putting operational constants into prose in a separate repository with its own release cycle, and they are the strongest argument I have for generating the volatile parts of a tool description from the same constants the API serves, rather than writing them by hand.
Getting listed is the other half of the work
Publishing an MCP server and having anyone able to find it are different projects. Seven surfaces, re-checked live on 2026-10-14:
- Official MCP registry - listed, v1.1.2. Took a DNS-authenticated namespace and a manifest that validates against the schema.
-
Glama - listed, tools indexed. Auto-indexed, then claimed; the maintenance score wanted CI, a tagged release and a
glama.json. - awesome-mcp-servers - merged 2026-09-07. A PR, corrected to the repo's own category conventions.
-
npm - 1.1.2, published from CI with a scoped token. The
binshebang is what makes it npx-runnable. -
mcp.so - submission still open at 68 days. Free listings are
nofollow ugc; the paid dofollow tier was declined. - Smithery - blocked upstream. The bundle path rejects any bundle declaring tools, and their issue has been open since June.
- PulseMCP - structurally closed. New submissions are paused while they rework ingestion.
Four of seven landed cleanly, one is open indefinitely, one is blocked by a bug in somebody else's tooling and one has the door shut. The useful pattern is that the surfaces which worked are the ones with a machine-checkable artifact behind them: a manifest that validates, a published package, a repo a crawler can read. The ones that stalled are the ones gated on a human reading a queue. If you are planning this work, budget for the manifest and the package, and treat the directory listings as a lottery you enter cheaply.
On adoption I have nothing to report and will not pretend otherwise. npm shows 191 downloads in the 30 days to 2026-10-13, which for a package in this position is mostly mirrors and crawlers rather than people; the repo has no stars; PulseMCP alone lists over 21,700 servers. AddressIntel has never been monetized and has no customers. What exists is a published, installable capability, measured honestly.
What this shows
The adapter was an afternoon. The work was deciding what a model is allowed to conclude from each field, writing that into the one place the model reads on every call, and then going back and checking the contract against the live API instead of trusting it. That check is what found the $400 and the missing history window, and neither was visible from the code, the README or the repo's own commit history.
Exposing data to agents is becoming the default ask, and the easy part of it is the protocol. The hard part is that an agent will restate your numbers to someone who cannot see the payload, which means every caveat that used to live in a footnote now has to be machine-readable and kept in sync with the system that produces it.
Andrew Maury is the founder of Rantum, a senior data science and ML studio that turns messy, fragmented and adversarial data into models, APIs and products that ship. Originally published at rantum.xyz, which is the canonical source.
Top comments (0)