Read API and MCP
The read API gives the record of every data product to systems and agents, without a browser: products and their gates, requirements, releases, the published agreement, drift, coverage, the ontology, and the audit log as a feed.
- Read-only, by design. A token reads. It never answers a demand, accepts a draft, signs off a test or approves a release. Those decisions belong to a named person in the application — see Decision 0006.
- One record, two doors. People use the application; systems use
/api/v1with a service token. Both see the same record. - Two ways in for agents. The endpoints below, and the factory's MCP server — on
stdio on the same machine, or over HTTP at
/api/v1/mcp.
The OpenAPI 3.1 document is at /api/v1/openapi.json, and downloadable from the
application's API page.
Quick start
1. Issue a token. On the machine that runs the factory, as the account it runs under:
python -m dpf token create ci --note "nightly status report"
The token is printed once. Only its SHA-256 is stored, so it cannot be shown again. Put it in your secret store now.
2. Read.
export DPF_TOKEN=dpf_…
export DPF=https://<your host>/dataproductfactory/api/v1
curl -H "Authorization: Bearer $DPF_TOKEN" $DPF/products
3. Follow what happens. The audit log is a feed. Poll with the last next:
curl -H "Authorization: Bearer $DPF_TOKEN" "$DPF/events?after=0&limit=100"
The same in Python, with nothing to install:
import json, os, urllib.request
def get(path):
req = urllib.request.Request(
os.environ["DPF"] + path,
headers={"Authorization": "Bearer " + os.environ["DPF_TOKEN"]})
with urllib.request.urlopen(req) as r:
return json.load(r)
for p in get("/products"):
print(p["name"], p["phase"], p["gates"])
Authentication
Every request to /api/v1 carries a service token:
Authorization: Bearer dpf_…
| Issued by | an administrator, on the box: python -m dpf token create NAME [--note "…"] |
| Stored as | SHA-256 only. A copy of the database is not a copy of anybody's access. |
| Shown | once, when issued |
| Named | for the system that holds it: ci, jira-intake, claude-desktop. One live token per name, so the record can always tell two systems apart. |
| Scope | read. There is no other scope. |
| Opens | /api/v1/… and nothing else. The same header sent to a page of the application is ignored; a token is an integration, not a person. |
| Seen as | service:NAME. GET /api/v1/ answers with it under you. |
| Listed | python -m dpf token list, and on the API page, with when each was last used |
| Revoked | python -m dpf token revoke NAME. It stops working on the next request. |
| Rotated | issue a token under a new name, move the system over, revoke the old one |
Issuing and revoking are events in the audit log (token.issued, token.revoked).
Reading is not: a read is not a decision, and one event per call would bury the
decisions the log exists to show. Each token's last_used_at records that it was used.
A token is a bearer credential: whoever holds it can read the whole record, including who decided what (names and sign-in addresses). Treat it like a database password — in a secret store, never in a repository, over HTTPS only.
The API asks for a token on every deployment, including a laptop running in open
mode, so an integration that works on a laptop works unchanged behind a door.
Endpoints
All GET except /mcp, all JSON, relative to /api/v1. {slug} is the product's
identifier, as in its address in the application (PRODUCT_SLUG).
This section is generated from the list the routes are built from, so it is exactly what the running version serves.
| Method | Path | What it answers |
|---|---|---|
| GET | / |
What this API is |
| GET | /products |
Every data product |
| GET | /products/{slug} |
One product, step by step |
| GET | /products/{slug}/requirements |
Requirements |
| GET | /products/{slug}/releases |
Releases |
| GET | /products/{slug}/contract |
The published agreement |
| GET | /products/{slug}/flags |
Drift |
| GET | /products/{slug}/coverage |
Coverage |
| GET | /events |
The audit log, as a feed |
| GET | /ontology |
The ontology |
| POST | /mcp |
MCP over HTTP |
| GET | /openapi.json |
This API, described |
GET /
The service, its version, and where everything else is.
Example response:
{
"service": "data-product-factory",
"version": "1.0",
"read_only": true,
"you": "service:ci"
}
GET /products
One row per product: where it is in the process, which gates are closed, and whether it is live.
Example response:
[
{
"slug": "example_product",
"name": "Example product",
"owner": "Use case team",
"live": true,
"version": "2.0.0",
"phase": "deliver",
"steps_done": 9,
"steps": 9,
"gates": {
"approval to load": "closed",
"agreement": "closed",
"sign-off": "closed"
}
}
]
GET /products/{slug}
The product, each phase with its gate — closed or open, and by whom — and the state of every step, derived from what exists rather than from a stored flag.
| Parameter | In | What it is |
|---|---|---|
slug |
path | The product's identifier, as in its URL. |
Example response:
{
"slug": "example_product",
"phases": [
{
"key": "source",
"title": "Source",
"outcome": "The tables are loaded",
"gate": {
"code": "approval to load",
"decision": "The source owner approves loading these tables for this purpose",
"state": "closed",
"decided_by": "Jane Doe",
"decided_at": "2026-09-09T09:29:00+00:00"
}
}
],
"steps": [
{
"key": "brief",
"title": "Define the data product",
"phase": "source",
"state": "done",
"detail": "54 words"
}
]
}
GET /products/{slug}/requirements
Every requirement at its current revision, with its acceptance criteria and its lifecycle.
| Parameter | In | What it is |
|---|---|---|
slug |
path | The product's identifier, as in its URL. |
Example response:
[
{
"key": "REQ-1",
"title": "Monthly totals by category",
"lifecycle": "accepted",
"revision": 2,
"priority": "must",
"criteria": [
{
"id": "AC-1",
"text": "…"
}
]
}
]
GET /products/{slug}/releases
Every release, newest first: the agreement version it carries, its state, and who approved it.
| Parameter | In | What it is |
|---|---|---|
slug |
path | The product's identifier, as in its URL. |
Example response:
[
{
"seq": 2,
"state": "live",
"agreement": "2.0.0",
"created_at": "2026-09-28T14:48:00+00:00",
"created_by": "John Doe",
"approved_by": [
"Jane Doe"
]
}
]
GET /products/{slug}/contract
What consumers are promised: the interface of the live agreement, without the internal mapping. 404 until something is live.
| Parameter | In | What it is |
|---|---|---|
slug |
path | The product's identifier, as in its URL. |
GET /products/{slug}/flags
Every flag raised against the product: what drifted, how badly, and what was decided about it.
| Parameter | In | What it is |
|---|---|---|
slug |
path | The product's identifier, as in its URL. |
GET /products/{slug}/coverage
Which acceptance criteria the live release verifies, and which it does not. 404 until something is live.
| Parameter | In | What it is |
|---|---|---|
slug |
path | The product's identifier, as in its URL. |
GET /events
Every recorded event, oldest first, from a cursor. Poll with after set to the last id you saw; next in the response is the value to send next time. Each event has its code, for integrating on, and a sentence, for a person reading it.
| Parameter | In | What it is |
|---|---|---|
after |
query | Return events after this id. 0 for the beginning. |
product |
query | Only this product's events. |
limit |
query | At most this many, up to 500. Default 100. |
Example response:
{
"events": [
{
"id": 412,
"at": "2026-09-09T09:29:00+00:00",
"code": "demand.granted",
"sentence": "Access granted · Source system",
"product": "example_product",
"object": {
"type": "demand",
"key": "src"
},
"actor": "Jane Doe",
"principal": "jane.doe@example.com",
"from": "requested",
"to": "granted"
}
],
"next": 412
}
GET /ontology
The semantic layer as one graph — products, requirements, tables, attributes, terms, systems, owners and how they connect. The same graph the MCP tools search.
| Parameter | In | What it is |
|---|---|---|
product |
query | Only what one product touches. |
POST /mcp
The factory's MCP server, for an agent that is not on this machine: JSON-RPC posted to one URL, as MCP's Streamable HTTP transport specifies, answered as JSON. The same five read-only tools as python -m dpf mcp on stdio — products, search, describe, facet, gaps.
Request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "products",
"arguments": {}
}
}
GET /openapi.json
An OpenAPI 3.1 document for everything above.
Following the audit log
GET /events answers the audit log as a feed, oldest first. Poll with after set
to the last id you saw, and send the response's next back as the following
call's after; an empty page returns the same after you sent. Ids only increase
and the log only grows, so polling from a cursor never misses or repeats an event.
Integrate on code, not on sentence. Codes are stable; sentences are written for
a person reading the payload and improve without notice. actor is the name on the
record; principal is who was signed in — the two differ when an administrator acts
as a role in a demo, and the four-eyes checks read principal. Events written by a
service carry service:NAME.
Codes worth knowing, by phase — the full list is in Audit events:
| Phase | Codes |
|---|---|
| Source | product.created, product.scope_set, demand.requested, demand.granted, demand.conditional, demand.denied |
| Refine | requirement.created, requirement.accepted, proposal.made, proposal.accepted, contract.accepted |
| Deliver | run.passed, run.failed, release.created, signoff.requested, signoff.signed, release.approved, release.promoted, release.rolled_back |
| After go-live | flag.raised, flag.accepted, flag.resolved |
| Workspace | token.issued, token.revoked |
A gate closes on demand.granted / demand.conditional (approval to load),
contract.accepted (agreement) and signoff.signed (sign-off).
MCP
The factory's MCP server has five read-only tools: products, search, describe,
facet (contracts, service levels, ownership, lineage, descriptions) and gaps
(what is undocumented). Each describes itself to the client.
Over HTTP, for an agent on another machine. JSON-RPC posted to one URL, answered as JSON, behind the same token:
claude mcp add --transport http data-product-factory \
https://<your host>/dataproductfactory/api/v1/mcp \
--header "Authorization: Bearer $DPF_TOKEN"
Or in a client's JSON config:
{"mcpServers": {"data-product-factory": {
"type": "http",
"url": "https://<your host>/dataproductfactory/api/v1/mcp",
"headers": {"Authorization": "Bearer dpf_…"}}}}
A message or a batch in, the replies out; a notification is answered 202 with no
body. There is no server-sent-event stream, because every tool answers in one response.
On stdio, for an agent on the same machine as the factory, without a token:
python -m dpf mcp. The application's API page links to MCP on this
machine, which prints the exact client config with this machine's paths.
Errors
Every error is JSON with an error sentence.
| Status | When |
|---|---|
400 |
A parameter is the wrong type: after=abc |
401 |
No token, a malformed header, or a token that is unknown or revoked. Carries WWW-Authenticate: Bearer. |
404 |
No such product, or nothing live yet for contract and coverage |
405 |
Any method but GET on a GET endpoint, or anything but POST on /mcp |
Versioning
Two version numbers travel with every response, and they answer different
questions. The API's own version is in the path (/api/v1) and the X-DPF-API
header; within v1, fields are only added, never renamed or removed, so a client
should ignore one it does not know, and a change that would break a client becomes
/api/v2 with v1 kept alongside it.
The application's version — the same one in the sidebar and python -m dpf
--version — travels separately, in the X-DPF-Version header; GET /api/v1/ also
answers it as app_version. It moves on every release, API or not; what the
numbers mean is in Versioning.
What is not here, on purpose
- Writes. No endpoint creates, edits, approves or promotes. The process exists so that each of its decisions is made by a named person who is not the one who asked, and the record says who. An API that wrote would need its own answer to that before it needed endpoints.
- Webhooks. Poll
/eventsfrom a cursor. It is one request per interval, it cannot lose an event, and it needs nothing reachable on your side. - Machine identity beyond a bearer token. Each system authenticates with one shared secret, issued under its name; there is no OAuth and no Entra service principal.