Skip to main content
Glama
Rethink-Carbon

CDOP MCP Server

Official

CDOP reference API

A reference implementation of an API for the Carbon Data Open Protocol (CDOP) schema v2.0.

It serves the CDOP documents over a hypermedia REST API (HAL + HAL-FORMS). It publishes an OpenAPI 3.1 description that embeds the CDOP JSON Schemas verbatim. It exposes the same data to AI agents over MCP and streams changes as CloudEvents. It ships with a synthetic multi-registry dataset and a lifecycle simulator. Every place where the schema could not be implemented as published is logged in SCHEMA-FEEDBACK.md.

Maintained by Rethink Carbon. MIT licence.

Why this exists

CDOP is a member-driven data standard for carbon credit projects (co-chairs GCMU, Sylvera, RMI and S&P Global; 75 members). Schema v2.0 was published on 16 to 18 September 2026. It is authored in Excel and converted to JSON Schema 2020-12 by a script. The published artefacts are twelve schema files and eleven example documents. There is no API, the examples do not validate against the schemas, there is no lint step, and there is no model for change or synchronisation.

In September 2026 the CDOP Technical Working Group confirmed it wanted a reference implementation of an API. Rethink Carbon committed to deliver one. The commitment covers a working API with an OpenAPI description and synthetic data for every current schema document. It also covers an exploration of hypermedia navigation, and a written record of every gap, ambiguity or decision that could affect schema development. This repository is that deliverable. Rethink Carbon also uses the protocol itself.

Related MCP server: RationalBloks MCP Server

Status

M1 (v0.1) is feature complete locally: the read-only API, the seed, the conformance tests and the MCP read surface all pass CI's checks. It is not yet deployed to the hosted demo. The endpoint table below marks each route with the milestone in which it lands.

Milestone

Scope

Target

M1

Read-only API, all CDOP documents, OpenAPI, Scalar API reference, hal-explorer, MCP read tools, seed of 370 projects across seven standards, schema-feedback register

27 September 2026

M2

Ledger (issuances, unit blocks), HAL-FORMS actions with API keys, events (SSE, pull feed, webhooks), simulator, accounts, MCP write tools

weeks 2 to 3

M3

500 projects across 7 standards, golden fixtures, rate limiting, demo reset, PUT of pod documents, client package

weeks 4 to 5

M4

TWG iteration, Round 2 pods (Registry, Validation, Verification Metadata), v1.0.0

week 6

Quickstart

You need Docker with Compose. Nothing else.

git clone https://github.com/rethink-carbon/cdop-reference-api.git
cd cdop-reference-api
docker compose up

The first boot applies the migrations and seeds the synthetic dataset (SEED_ON_START=true). Then open:

URL

What

http://localhost:3000/atlas

Visual explorer: map, charts and drill-throughs (see below)

http://localhost:3000/docs

OpenAPI 3.1 reference (Scalar)

http://localhost:3000/explorer

HAL browser (hal-explorer)

http://localhost:3000/v2

HAL root: follow the links from here

http://localhost:3000/mcp

MCP endpoint (Streamable HTTP)

http://localhost:3000/healthz

Health check

/atlas shows every project and registry account on a map, with drill-throughs into a project's status history, issuances, units, documents and CDOP documents, and charts of projects by stage and units by vintage. It is a HAL client of this API and lists the calls behind each view. The map needs a public Mapbox token: MAPBOX_API_KEY=pk.… docker compose up. Without one, everything but the map works.

If ports 3000 or 5433 are busy on your machine, set CDOP_PORT and CDOP_DB_PORT before starting:

CDOP_PORT=3100 CDOP_DB_PORT=5434 docker compose up

The hosted demo runs at https://cdop.rethinkcarbon.co.uk with the same routes.

A five-step tour with curl

The API is hypermedia. Start at the root and follow links; you never need to construct a URL. Each response carries _links, and the cdop curie resolves every custom relation to a page under /rels/.

Step 1: the root.

curl -s http://localhost:3000/v2 | jq '._links | keys'

Step 2: follow cdop:projects to the collection. Collections are compact index rows under _embedded, keyed by the collection's name (projects, units, documents), with total, limit and next/prev/first links. Filters use CDOP field names.

PROJECTS=$(curl -s http://localhost:3000/v2 | jq -r '._links["cdop:projects"].href')
curl -s "$PROJECTS?standard=wcc&limit=3" | jq '{total, limit, items: [._embedded.projects[] | {id, project_name, lifecycle_stage}]}'

Step 3: open one project. The resource uses CDOP field names verbatim, plus status (the current status record), lifecycle_stage and registry_status.

PROJECT=$(curl -s "$PROJECTS?standard=wcc&limit=1" | jq -r '._embedded.projects[0]._links.self.href')
curl -s "$PROJECT" | jq '{project_name, lifecycle_stage, registry_status, status, documents: [._links["cdop:document"][].name]}'

Step 4: fetch the schema-pure Full List document. cdop:document is an array of named links, one per CDOP pod. Note the ETag, the Link: rel="describedby" header and X-CDOP-Schema-Version.

FULL_LIST=$(curl -s "$PROJECT" | jq -r '._links["cdop:document"][] | select(.name == "full-list") | .href')
curl -s -D - "$FULL_LIST" -o full-list.json | grep -iE '^(etag|link|x-cdop)'

Step 5: validate it. POST /v2/validate runs Ajv (JSON Schema 2020-12) against the vendored CDOP schema.

curl -s -X POST "http://localhost:3000/v2/validate?schema=full-list" \
  -H 'content-type: application/json' --data @full-list.json | jq '{valid, errors: (.errors | length)}'

Expect "valid": false. That is the point of the exercise, not a bug: no Full List document for a voluntary-market project can validate against the published schema, because project.compliance_market_id is required with at least one item (CDOP-FB-020). The X-CDOP-Conformance header from step 4 names the register entry behind every error, for example invalid; errors=4; ref=CDOP-FB-005,CDOP-FB-020,CDOP-FB-028. The pods those defects do not touch validate. Add ?strict=1 to have the API fill required-but-unpublished sections with flagged placeholders:

ESTIMATIONS=$(curl -s "$PROJECT" | jq -r '._links["cdop:document"][] | select(.name == "estimations") | .href')
curl -s "$ESTIMATIONS?strict=1" | curl -s -X POST "http://localhost:3000/v2/validate?schema=estimations" \
  -H 'content-type: application/json' --data @- | jq '{valid}'

A longer walkthrough, including If-None-Match, the URN resolver, HAL-FORMS templates and problem details, is in docs/hateoas-tour.md.

Three surfaces, one model

REST (HAL + HAL-FORMS). Every resource carries _links. Collections embed compact rows. State-gated actions appear as _templates (M2), derived from one transition table per entity, so a retired block never offers retire. Errors are RFC 9457 application/problem+json with a documented type per problem (/problems/{slug}). Custom relations are documented under /rels/{rel} and in docs/rels/.

OpenAPI 3.1. /v2/openapi.json describes our routes and embeds each CDOP schema under components.schemas["cdop.v2.<Pod>"] without re-expressing it. Scalar renders it at /docs, served from this origin (see "Self-hosted UIs and optional external services" below). The artefact is committed at apps/api/openapi/openapi.json and CI fails if it drifts from the code.

MCP. /mcp mounts an MCP server over Streamable HTTP with the same services behind it: search_projects, get_project, get_cdop_document (with an Ajv conformance report), validate_payload, explain_schema, list_enum, get_state_machine and more. Results carry HAL links so an agent can keep navigating. See docs/mcp.md.

Events (M2). Every status-record append becomes a CloudEvents 1.0 event of type org.cdop.<entity>.status.changed, written to one outbox table. Consume them over SSE (/v2/events, resumable with Last-Event-ID), as a pull feed (/v2/changes?since=), or as signed webhooks (/v2/webhooks). A row edited directly in the database produces org.cdop.row.changed.

Conformance

The CDOP schemas are vendored, not re-expressed. packages/cdop-schemas/schemas/v2/ holds the twelve schema files and examples/v2/ the eleven upstream examples, pinned to upstream commit eff6ca3 (16 September 2026) with a SHA-256 per file in UPSTREAM.json. Ajv 2020-12 validates every CDOP-shaped payload, with the x-cdop-* annotation keywords registered as a vocabulary.

Command

What it does

pnpm cdop:verify

Recomputes every vendored file's hash against UPSTREAM.json. Runs in CI.

pnpm cdop:lint

Static checks the upstream pipeline lacks: keys with spaces, mangled keys, integer identifiers, links and dates without format, required-but-private fields, annotation casing, enum near-duplicates. Informational.

pnpm cdop:sync --ref <sha>

Re-vendors from upstream at a commit and rewrites UPSTREAM.json. Run in a PR.

pnpm cdop:drift

Compares upstream main with the pinned copy. A weekly workflow opens an issue on drift.

The test suite asserts that the upstream examples for Unit Description, Estimations, Crediting Period and Full List do not validate against the upstream schemas (see CDOP-FB-004).

It also projects every seeded project through every pod and validates the result. Eight of the eleven project pods validate for every project under every standard. The other three fail only for reasons the register records, and the suite fails the build on any error no register entry explains:

Pod

Result

Why

Location Details, Disclosures, Issuances, Crediting Period, Estimations, Co-Benefits, Durability & Permanence, Project Finance

valid for every project

Project Approach & Details

valid except WCC and Peatland Code projects

The UK codes are missing from the program and methodology enums (CDOP-FB-028)

Full List, Labels & Certifications

invalid for every project

compliance_market_id is required with minItems: 1 (CDOP-FB-020); labelled units also hit CDOP-FB-029

API version is not schema version

The URL prefix /v2 is the API major version. The CDOP schema version travels separately in a response header on every CDOP document and on the root:

X-CDOP-Schema-Version: 2.0+eff6ca3
X-API-Version: 0.1.0

The schema label is derived from UPSTREAM.json, so re-vendoring the schema changes the header without touching the API version. A ?strict=1 query on a cdop/{pod} document adds X-CDOP-Conformance, which explains any placeholder the API had to emit to satisfy a defect in the published schema.

Self-hosted UIs and optional external services

The running service talks to its Postgres database and nothing else. /docs and /explorer are served from the API's own origin and the browser is told to enforce that. Mapbox and operator-configured Rybbit analytics are the scoped exceptions described below.

  • /docs is Scalar (MIT): the browser bundle from @scalar/api-reference, pinned to an exact version in the lockfile and in pnpm audit, copied alone into the image. No CDN. Its font CDN, telemetry, hosted AI agent and hosted-client link are switched off, and its "Connect MCP" entry points at this API's own /mcp (ADR 0009).

  • /explorer is HAL Explorer (MIT), vendored. Its theme picker is hard-wired to bootswatch.com; the API serves the themes itself instead, so every theme is the Bootstrap build already in the bundle.

  • By default both carry Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self'; .... A script, stylesheet, font or request to any other origin is blocked by the browser, not just absent by convention. The test suite fails if either page references another origin.

  • Install scripts run only where pnpm-workspace.yaml allows them (vue-demi, pulled in by Scalar, is denied), and the image installs with --ignore-scripts.

  • /atlas draws its basemap with Mapbox, which cannot be self-hosted (ADR 0010). Only when MAPBOX_API_KEY holds a public token does the page load Mapbox GL JS from api.mapbox.com, at an exact version with a Subresource Integrity hash, and only that page's policy allows api.mapbox.com, *.tiles.mapbox.com and events.mapbox.com. Mapbox then sees each visitor's IP address, this origin as referrer, and their map loads. Without a token the page keeps the self-only policy. Its own scripts and charts are served from this origin, and the image contains no Mapbox code.

  • Optional Rybbit analytics (ADR 0011): set both RYBBIT_ORIGIN (HTTPS origin, no trailing slash) and RYBBIT_SITE_ID to count visitors to /docs/, /explorer/ and /atlas/, plus Atlas navigation and actions. Only the configured origin is added to connect-src; scripts stay local. Search text, URL queries, API credentials and session replay are excluded. Do Not Track and Global Privacy Control are respected. Unset both to disable it.

Local development

Prerequisites: Node 24 (.nvmrc), pnpm 12 via corepack (corepack enable), Docker.

pnpm install --frozen-lockfile
docker compose up -d db                 # Postgres 17 on localhost:5433
cp .env.example apps/api/.env           # the API scripts read .env from apps/api/
pnpm db:migrate                         # applies supabase/migrations/*.sql with checksums
pnpm seed                               # deterministic synthetic dataset (CDOP_SEED=2026)
pnpm dev                                # tsx watch on http://localhost:3000
pnpm test

pnpm seed -- --counts wcc=8,pc=6,vcs=6 seeds a smaller mix (this is what CI does). The Supabase CLI works too: supabase start runs the same migrations on a local stack (API 54421, database 54422, Studio 54423). pnpm db:migrate refuses to run against a database the Supabase CLI already manages, to avoid applying files twice.

Environment variables are documented in .env.example. The API needs only DATABASE_URL.

Endpoints

All API routes live under /v2, respond with application/hal+json, and use RFC 9457 problem details for errors.

Method

Path

Notes

Milestone

GET

/v2

HAL root: curies, collections, service-desc, service-doc, schemas

M1

GET

/v2/projects, /v2/projects/{id}

Filters use CDOP field names (status, country_code, registry, standard, project_type, mitigation_type, methodology, project_identifier, current_registry_project_id, developer_account_id, q, modified_since, bbox); sort, limit (max 100), opaque cursor

M1

GET

/v2/projects/{id}/status-history

Status records newest first, with effective_at, recorded_at, actor and event_id

M1

GET

/v2/projects/{id}/{facet}

stakeholders, crediting-program, registry, cobenefits, buffer-pool, labels, finance

M1

GET

/v2/projects/{id}/{collection}

facilities, methodologies, validations, estimations, documents, geolocation-files (+ /{gid}/content as application/geo+json), issuances, units

M1

GET

/v2/projects/{id}/{collection}

verifications, agreements, milestones; embed= allow-list

M2

GET

/v2/projects/{id}/cdop/{pod}

Schema-pure CDOP document: full-list, location-details, project-approach-details, disclosures, issuances, crediting-period, estimations, co-benefits, durability-permanence, project-finance, labels-certifications

M1

GET

/v2/units, /v2/units/{id}, /v2/units/{id}/status-history, /v2/units/{id}/cdop/{unit-description|full-list}

One resource is one credit block; limit max 250

M1

GET

/v2/issuances, /v2/issuances/{id}

M1

GET

/v2/accounts, /v2/accounts/{id}, /v2/accounts/{id}/units

Registry accounts

M1

GET

/v2/identifiers/{urn}

Resolves a CDOP URN with a 303

M1

GET

/v2/reference, /v2/reference/{list}

Every enum in the vendored schema

M1

GET

/v2/schemas, /v2/schemas/{file}

The vendored schema files, served intact

M1

GET

/v2/state-machines, /v2/state-machines/{entity}

Transition tables and lifecycle mapping tables

M1

POST

/v2/validate?schema={pod}

Ajv result for any payload

M1

POST

/v2/{projects|units|issuances}/{id}/actions/{action}

Transitions from HAL-FORMS templates; Idempotency-Key, If-Match; API key required

M2

POST

/v2/projects

Create from a CDOP Project Approach & Details document

M2

PUT

/v2/projects/{id}/cdop/{pod}

Upsert a pod document

M3

GET

/v2/events

SSE stream; Last-Event-ID or since, types, project_id; heartbeat every 15 s

M2

GET

/v2/changes

Pull feed of CloudEvents; since, types, project_id, limit, wait (long-poll up to 30 s)

M2

GET, POST, DELETE

/v2/webhooks, /v2/webhooks/{id}/actions/ping, /v2/webhooks/{id}/deliveries

Standard Webhooks signatures, 8 retries over about 8 hours

M2

ALL

/mcp, /mcp/{token}

MCP Streamable HTTP, stateless

M1 read, M2 write

GET

/v2/openapi.json, /docs, /explorer, /atlas, /rels/{rel}, /problems/{slug}, /healthz

M1

POST

/v2/admin/reset, /v2/admin/sim

Reseed; simulator start, stop and rate (admin key)

M3

Reads are anonymous. Writes need Authorization: Bearer cdop_<keyid>.<64hex>. Keys are stored as SHA-256 hashes with one of the roles developer, vvb, code_admin, registry, admin, sandbox. pnpm seed prints demo keys for a local database. pnpm keys:new --role <role> --label <text> stores a new key and prints its token once; pnpm keys:new on its own prints a token for ADMIN_API_KEY, which is never stored.

Repository layout

apps/api/                 the API, MCP server, seeder and simulator (Hono, Kysely, Ajv)
  src/domain/lifecycle/   per-standard state machines and the canonical vocabularies
  src/http/               HAL, HAL-FORMS, problem details, cursors, ETags
  src/db/                 Kysely setup and the SQL migration runner
  src/seed/               deterministic synthetic data generator
  src/routes/ src/mcp/    read-only routes and the MCP server (M1)
  src/sim/ src/webhooks/  land in M2
  test/                   generator, conformance and HTTP suites
packages/cdop-schemas/    vendored CDOP schemas + examples, field registry, Ajv validator, lint, sync, drift
supabase/migrations/      SQL migrations (run by docker compose, supabase start and supabase db push alike)
supabase/config.toml      local Supabase stack configuration
docs/                     ADRs, link relations, API profile, tours, deployment
docker-compose.yml        API + Postgres 17 for forkers
docker-compose.dokploy.yml  API only, for the hosted demo behind Traefik
.github/workflows/        ci, docker (GHCR + Dokploy webhook), schema-drift

Documentation

Data

All data in this repository and on the hosted demo is synthetic. Names, places, identifiers, dates and figures are generated deterministically from a seed. Nothing is taken from a real registry.

Licence

MIT. See LICENSE. The CDOP schemas are vendored under their own MIT licence from the upstream repository.

Related MCP Connectors

Related MCP Servers