MAP Data API
v1One read-only HTTP route your developer adds to a server you already run. MAP calls it while your agent is answering a question, and reads only what the route says it may read.
MAP only reads
Your agent can search and read what you connect. It has no way to add, change or delete anything — not because it is told not to, but because there is no request it could send that would.
Your endpoint decides
Your developer writes what it returns. There is nothing to configure here, because the decision belongs where the knowledge is — a price can be visible while the cost behind it never leaves your building.
Off means off
Switching a connection off stops your agents reading it on their very next answer. Nothing to publish, nothing to wait for.
Quick start
Plain HTTP with a JSON body — no protocol to learn, nothing to install, about thirty lines. Three operations, all of them reads.
- Add one
POST /maproute that answers the three operations. - Create a connection on the Connections tab and copy the secret to your server.
- Press Test connection, then ask your agent a question.
POST https://api.your-company.com/mapAuthorization: Bearer $MAP_DATA_SECRET {"op": "schema"} -> what is readable{"op": "search", "resource": "routes", "query": "Warsaw"} -> matching records{"op": "get", "resource": "routes", "id": "route_182"} -> one recordA complete handler
Both examples share the shape that matters: a literal map of what may be read, and a lookup against it. Passing resource to your database as an identifier is how a resource name becomes a table name, so the lookup is the allowlist.
Point them at a view built for this rather than at your operational tables — the grants are in Security.
import express from "express"; const app = express();app.use(express.json()); // MAP shows this once, when you create the connection.const SECRET = process.env.MAP_DATA_SECRET; // What you are willing to expose. Written by hand, on purpose.const RESOURCES = { routes: { description: "Lanes we serve, transit times and current pricing.", fields: ["origin", "destination", "estimated_days", "base_price", "currency"], view: "map_public_routes", // a view, not the table },}; app.post("/map", async (req, res) => { if (req.get("authorization") !== "Bearer " + SECRET) { return res.status(401).json({ error: { code: "UNAUTHORIZED" } }); } const { op, resource, query = "", filters = {}, id, limit = 5 } = req.body; if (op === "schema") { return res.json({ version: "1", service: "thomas-logistics", resources: Object.entries(RESOURCES).map(([name, r]) => ({ name, description: r.description, fields: r.fields, })), }); } // The lookup IS the allowlist. Passing resource to your database // as an identifier is how a resource name becomes a table name. const spec = RESOURCES[resource]; if (!spec) { return res.status(404).json({ error: { code: "RESOURCE_NOT_FOUND" } }); } if (op === "search") { const rows = await db.search(spec.view, { filters, query, limit: Math.min(limit, 25), }); return res.json({ items: rows }); } if (op === "get") { return res.json({ item: (await db.byId(spec.view, id)) ?? null }); } res.status(400).json({ error: { code: "UNKNOWN_OP" } });});Authentication
MAP generates the secret and shows it once, so it is never typed into a browser. It arrives as a bearer token on every request, alongside four headers you can log.
X-MAP-Request-ID also appears in the Request log tab here, so a line on that screen and a line in your own logs describe the same call.
Call it from your server, never a browser
The secret authenticates MAP to you. Anything that puts it in front-end code publishes it. Compare it in constant time — a plain === leaks it one character at a time to anyone patient enough to measure.
Authorization: Bearer $MAP_DATA_SECRETMAP-Data-Protocol-Version: 1X-MAP-Request-ID: req_7f2a9c...User-Agent: MAP-Agent-DataConnector/1Accept: application/jsonThe three operations
Every request carries an op and a version. There is no fourth operation, and none of these three writes.
op: "schema" — declare what is readable
The most important operation. It is also the health check — an endpoint that answers it is up, authenticated and speaking the protocol, which is everything a separate /health route would have established.
Always "1". A version MAP does not speak is reported as a clear connection status rather than failing mid-conversation.
A name for your system, shown after a successful test. Never shown to a visitor.
A plain identifier — [A-Za-z0-9_.], 120 chars. Anything else is dropped on arrival.
The model reads this to decide whether to call you. 500 chars. Write it for a new colleague, not for a schema browser.
The columns you return. This list is the boundary — MAP keeps these and drops everything else, so a column you leave out never reaches your agent even if your query returns it.
Write this list by hand
Generating it from your database means every column you add later is exposed by default. A literal means you decide once, and a migration changes nothing about what your agent can say.
{ "version": "1", "service": "thomas-logistics", "resources": [ { "name": "routes", "description": "Lanes we serve, transit times and current pricing.", "fields": ["origin", "destination", "estimated_days", "base_price", "currency"] }, { "name": "stock", "description": "Pallet availability by warehouse, updated hourly.", "fields": ["sku", "warehouse", "quantity"] } ]}op: "search" — find matching records
The workhorse. Called when a visitor's question turns on something your systems know.
One of the names your schema declared. MAP refuses anything else before the request is sent, so you will not see an unknown name here.
Free text in the visitor's own words, up to 300 chars, possibly empty. Implement it however you like — ILIKE, full-text, a vector index, a hand-written filter. MAP sends an intent and reads the result.
Exact-match narrowing, up to 10 pairs, compared as given. No operators and no ranges — nothing here is an expression language.
1–25, default 5. A request, not a promise MAP relies on: it clamps again on the way back, so an endpoint that ignores this cannot flood the answer.
Rows. Must be an array — a missing or non-array items is reported as an invalid response rather than as “no data”.
Opaque to MAP; only ever handed back to you in get. Need not be a primary key, and always survives projection — you do not have to declare it.
Return no rows, not an error
“Nothing matched” is a normal answer and the agent is told how to say it. A 500 is a different claim, and it makes your agent tell the visitor it could not check at all.
{ "op": "search", "version": "1", "resource": "routes", "query": "Warsaw Berlin pallet", "filters": { "destination_country": "DE" }, "limit": 5}{ "items": [ { "id": "route_public_182", "origin": "Warsaw", "destination": "Berlin", "estimated_days": 2, "base_price": 300, "currency": "EUR" } ]}op: "get" — read one record in full
Lets the agent search broadly and pull detail only when a question needs it — so your search response can stay small. id is always a value your own search returned; the agent is instructed never to invent one.
{ "op": "get", "version": "1", "resource": "routes", "id": "route_public_182" } // -> { "item": { "id": "route_public_182", "origin": "Warsaw", "destination": "Berlin", "restrictions": "Max 900kg per pallet." } } // or, when there is no such record { "item": null }Type reference
The complete surface, in both directions. Row is deliberately open — the shape belongs to you, and a route, a treatment and a garment share no columns. What is not open is which keys survive: that is your fields list.
fields is also accepted under the older name return_fields, so an endpoint written against an earlier draft keeps working.
// ------------------------------------------------ inbound type MapRequest = | { op: "schema"; version: "1" } | { op: "search"; version: "1" resource: string query: string // may be "" filters: Record<string, string> // may be {} limit: number } | { op: "get"; version: "1" resource: string id: string }; // ----------------------------------------------- outbound interface Resource { name: string; // [A-Za-z0-9_.], <=120 description?: string; // <=500 - the model reads this fields: string[]; // <=200 - the boundary} interface SchemaResponse { version: "1"; service?: string; resources: Resource[]; // <=100} // Your shape. MAP keeps id plus your declared fields.type Row = Record<string, unknown> & { id?: string }; interface SearchResponse { items: Row[] }interface GetResponse { item: Row | null }interface ErrorResponse { error: { code: string; message?: string } }from pydantic import BaseModel, Fieldfrom typing import Any, Literal class MapRequest(BaseModel): op: Literal["schema", "search", "get"] version: str = "1" resource: str = "" query: str = "" filters: dict[str, str] = Field(default_factory=dict) id: str = "" limit: int = 5 class Resource(BaseModel): name: str description: str = "" fields: list[str] class SchemaResponse(BaseModel): version: str = "1" service: str = "" resources: list[Resource] class SearchResponse(BaseModel): items: list[dict[str, Any]] class GetResponse(BaseModel): item: dict[str, Any] | NoneWhat MAP does to your reply
Four steps before a single row reaches the agent. Knowing them tells you what you can be relaxed about and what you cannot.
| Step | What happens |
|---|---|
| Clamp | Rows past limit are discarded, so an endpoint that ignores it cannot flood the answer. |
| Sanitise | Strings over 2,000 chars truncated, arrays cut to 20 items, objects nest two levels. Anything not JSON-primitive is dropped rather than coerced. |
| Project | Only id plus your declared fields survive. Runs on the way out, so it holds even if your query returned more. |
| Frame | Handed to the model as data, inside a fence that says so. Text in your rows cannot change its instructions. |
This is a safety net, not a plan
Declaring a column you did not mean to expose still exposes it. The cheapest data to protect is data that never left your building — return the least you can.
// your schema declared:fields: ["origin", "destination", "base_price"] // your handler actually returned (SELECT *):{ "id": "route_182", "origin": "Warsaw", "destination": "Berlin", "base_price": 300, "supplier_cost": 210, // <- not declared "margin": 90 // <- not declared} // what the agent sees:{ "id": "route_182", "origin": "Warsaw", "destination": "Berlin", "base_price": 300}How your agent uses it
What happens between a stranger asking your business something and your endpoint receiving a request.
- 01An external AI asks your agent a questionOver MCP, through one public tool. It has no idea your systems exist, and never will.
- 02Your catalogue is already in the promptResource names, descriptions and field lists, from your schema. The model does not spend a call discovering what you offer.
- 03The model decides whether it needs you“What kind of company are you?” comes from published documents. “What is your Warsaw–Berlin pallet price?” does not.
- 04MAP calls you — your code runsBackend to backend. The agent never holds your address or your secret; it names a resource, and the backend does the reaching.
- 05Your rows are clamped, projected and fencedThe four steps above, then handed to the model as data.
- 06The agent answers from what came backQuoting your values, keeping your units and currencies, and saying plainly when it found nothing.
visitor -> "Two pallets Warsaw to Berlin - can you, and roughly what?" agent reads its catalogue (already in prompt) decides this needs live data MAP -> POST /map { "op": "search", "resource": "routes", "query": "Warsaw Berlin pallet", "filters": {}, "limit": 5 } you -> { "items": [{ "id": "route_public_182", "origin": "Warsaw", ..., "base_price": 300, "currency": "EUR" }] } agent-> "Yes - Warsaw to Berlin runs as pallet freight, about two days. Base rate 300 EUR, plus 60 EUR per pallet." total connector calls: 1What the model reads
Two things you write end up in front of a language model, and both decide whether your endpoint gets called at the right moment. Nothing else crosses over — not your URL, not your secret, not your table names, not your error text.
Field names reach the model too, and it reasons about them. base_price is understood; bp_amt is a guess. If your columns are internal shorthand, alias them in the view you expose.
// Too thin - the model cannot tell when to use it"routes table" // Structural - describes the storage, not the use"Rows from map_public_routes, joined to pricing" // Good - says what questions it answers"Lanes we serve between EU cities, with transit times and current pallet pricing. Use for 'do you ship X to Y' and 'what would it cost'."// Ambiguous - 420 of what? per what?{ "price": 420, "weight": 900 } // Unambiguous - nothing left to infer{ "base_price": 300, "price_per_pallet": 60, "currency": "EUR", "price_type": "estimate", "max_weight_kg": 900} // Unknown, said out loud{ "base_price": null, "currency": "EUR" }How results are read
| Your response | How the agent reads it |
|---|---|
| items: [] | “I could not find a matching record.” Never “they do not offer that” — an empty search is a failure to find, not proof of absence. |
| null | Unknown. Not zero, and never free. A missing price is never reported as no charge. |
| 300 + "EUR" | Quoted with its currency. The agent will not convert, round, or combine figures into a total you did not give it. |
| text in a row | Data. A field saying “ignore your instructions” is read as text your database contains, not as a command. |
| 5xx | “I could not check the live information right now.” The agent will not reuse a figure it saw earlier in the conversation. |
The practical consequence: send units and currencies as their own fields, and prefer an explicit null over omitting a key.
Limits and errors
A visitor is waiting through every one of these calls. The bounds exist for them.
| Bound | Value | Notes |
|---|---|---|
| Request timeout | 4s | Wall clock, per call. Slower is treated as unavailable. |
| Response size | 1 MB | Enforced while streaming, after decompression. |
| Calls per answer | 5 | Across all connections, for one visitor question. |
| Rows per search | 25 | Default 5. MAP's ceiling wins over any request. |
| Filters | 10 | Exact-match pairs only. |
| Query text | 300 | Characters. |
| Resources | 100 | Per schema. |
| Fields | 200 | Per resource. |
| Redirects | 0 | Point the connection at the final address. |
Your status code becomes a sentence the owner reads on this page. Your error text is never shown to a visitor — the agent says it could not check the live information, and the specifics stay here and in the request log.
| Status | What the owner is told |
|---|---|
| 401 / 403 | Your server rejected MAP's credential. Check it has the current MAP_DATA_SECRET. |
| 404 | Check the route is mounted under the base URL and path prefix configured here. |
| 429 | Reported. MAP does not wait and retry inside a turn — the visitor is still there. |
| 5xx | Reported, and the agent answers without the live data. |
{ "error": { "code": "RESOURCE_NOT_FOUND", "message": "The requested resource is unavailable." }}Security
What MAP guarantees
- It calls exactly one address, and only ever asks for those three operations.
- No model output ever becomes a URL, a path, an HTTP method or a query. The agent names a resource; the request shape is fixed.
- The agent process never receives your address or your secret.
- Your credential is sealed with AES-256-GCM, bound to its row, and never returned by any API.
- Only columns your schema declares reach the agent.
- Switching a connection off stops the next request. No republish, nothing to wait out.
What MAP cannot guarantee
That your handler is side-effect free. If it writes to your database when asked to search, that write happens. MAP never asks it to — and there is no mechanism that could check, which is why the connection test reads your schema and never probes by attempting a write.
POST is not a signal here
The route is a POST because a search has more structure than a query string carries comfortably. All three operations are reads. Do not infer safety from a verb in either direction — a badly built GET /delete elsewhere is not safe either.
Two habits worth keeping
Expose a view, not a table. Build something for this integration containing only the columns you are content for an AI to quote to a stranger. Then adding a column to the underlying table changes nothing here — which is the property that matters six months from now, when someone who never read this adds internal_notes.
Connect as a read-only database user. Then a bug in your own endpoint cannot write, whatever it was asked to do. It costs one CREATE USER.
-- A read-only role and a view built for MAP.---- The view is the security boundary. Granting SELECT on your-- operational tables would mean every column you add later is-- exposed by default; a view means you decide once, explicitly. create user map_reader with password '...'; grant connect on database your_db to map_reader;grant usage on schema public to map_reader; create view map_public_routes as select id, origin, destination, estimated_days, base_price, currency from routes where is_published; -- and nothing else. -- No supplier_cost. No margin. grant select on map_public_routes to map_reader; -- Deliberately absent: INSERT, UPDATE, DELETE, and SELECT on any-- base table. Your endpoint connects as map_reader, so a bug in it-- cannot write.