Docs / Reference / Read API and MCP
Reference For integrators

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/v1 with 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 /events from 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.
Documentation for version 0.2.0 Reviewed 2026-10-04 Covers dpf/web/api_v1.py, dpf/web/machine.py, dpf/tokens.py, dpf/mcp.py