cordata-catalog-mcp
A read-only Model Context Protocol server that answers questions about datasets — who owns them, what columns they have, how those columns are classified, and whether a Lake Formation grant would reach them — and never returns a row of the data itself. It serves the example catalog that ships with cordata-pipeline-runtime.
It is the runnable version of a design written up in two posts on cordata.tech. Fabric + Mesh on AWS — LakeFormation, DataZone, and one governance plane lays out the reference topology this server is modelled on, with one AWS account per data domain and a governance account that owns the LF-tag ontology and the grants, and closes its § 3 by proposing an MCP catalog tool. The catalog is the API — a governed mesh over MCP designs that tool: which questions it answers and where its schemas come from, why read-only has to be enforced rather than annotated, and how authorization works. Where this README says "§ N of the post", it means that second post.
Verified against MCP revision 2026-07-28, the Python SDK mcp 2.3, and cordata-pipeline-runtime 0.2. MCP has changed substantially between revisions, so check those three numbers before trusting a detail below against a newer stack.
Start it
uvx cordata-catalog-mcp
That starts the server over stdio, which is how a desktop or terminal MCP client launches a local server. To give it to a client, add it to the client's server configuration:
{
"mcpServers": {
"cordata-catalog": {
"command": "uvx",
"args": ["cordata-catalog-mcp"]
}
}
}
In Claude Code the same thing is one command: claude mcp add cordata-catalog -- uvx cordata-catalog-mcp.
Over stdio there is no token. The client that started the process is the only party that can talk to it, so every domain is visible. Serving it over HTTP needs a token issuer and is described below.
What it answers
Five tools, each a lookup or an evaluation against a record the governance plane already holds:
| Tool | Answers |
|---|---|
list_domains |
Which domains exist, and who owns their datasets |
get_dataset |
One dataset's owner, contract and columns, each column with its effective LF-tags |
resolve_lf_tag |
Whether a tag set is valid against the governance-owned ontology |
check_grant_expression |
Whether a tag-based grant expression would reach a dataset, and which columns |
find_datasets |
Which datasets carry given tag values, for example subject_type=customer |
LF-tags are Lake Formation's key–value classification labels; a grant written as a tag expression reaches every table or column whose tags match it, which is how the reference topology controls access without per-table grants.
Two resources sit beside the tools: the ontology as cordata://ontology/lf-tags, and each dataset as the template cordata://domain/{domain}/dataset/{table}.
get_dataset returns the runtime's own Metadata and Contract models from pipeline_runtime.descriptor, so the SDK derives the tool's outputSchema from the same Pydantic classes the pipeline executor validates descriptors with. Nothing in this repository restates them. resolve_lf_tag runs the executor's own policy.resolve(), so a tag set fails here exactly as it would fail in a pipeline run.
find_datasets can say which datasets hold customer data, and in which columns. It cannot say where one person's rows are, because finding those is a query against the data, and this server never reads data.
Where the columns come from
Ownership, contracts and the ontology all come from cordata-pipeline-runtime. Column lists do not, because a pipeline descriptor does not carry one: in a deployment they live in each domain's Glue catalog. Here columns.json stands in for that catalog. Each column's tags are checked against the ontology when the server starts, and CI runs the runtime's three example pipelines and fails if a column name or type in columns.json differs from the Parquet they write.
The IBAN question, on this example
§ 1 of the post opens with a domain engineer who is tagging a new column and asks which sensitivity level applies to one holding an EU IBAN. The answer should come from how comparable columns are already classified, not from what a model knows about IBANs. Against this example, it goes like this.
Find the precedent. find_datasets with {"subject_type": "customer"} returns two datasets that hold customer data, fraud/transactions_scored and policy/claims_deduped, each with an IBAN column. get_dataset on the first shows how that column is tagged and who to ask about it:
{
"metadata": {"name": "transactions-scored-daily", "domain": "fraud", "owner": "fraud-data@example.com"},
"contract": {"freshness_sla": "PT4H",
"lf_tags": {"sensitivity": "high", "residency": "eu", "subject_type": "customer"}},
"columns": [
{"name": "iban", "type": "VARCHAR",
"lf_tags": {"domain": "fraud", "sensitivity": "high", "residency": "eu", "subject_type": "customer"}}
]
}
(trimmed to the one column)
Validate the proposal. resolve_lf_tag with {"sensitivity": "high", "residency": "eu", "subject_type": "customer"} returns the tag set unchanged, because every value is in the ontology. A value that is not fails with the permitted ones, which is the executor's own error:
lf_tag residency='eea' is not in the ontology; allowed: ['eu', 'ch', 'us', 'global']
Validation catches a value nobody defined; it cannot catch a legal value that is wrong for this column. residency records where the data is stored, so an IBAN column in an extract replicated across regions should be global, and resolve_lf_tag accepts eu and global alike. That is the failure § 1 describes, and the reason the precedent lookup comes first.
Check who could read it. The analyst role in the reference topology may read every sensitivity except high. check_grant_expression with that role's expression, {"domain": ["fraud"], "sensitivity": ["public", "internal", "confidential"], "residency": ["eu"]}, returns {"reaches": false, "columns": []} for transactions_scored. In this example the whole table is classified high, so the role reaches nothing in it. The reference topology's Terraform tags only the iban column of its customer table high, and there the same role sees the table with the IBAN column left out. The IBAN columns here carry their own high tag as well, so that they stay high if a table's classification is ever lowered.
Read-only, and how that is enforced
Every tool carries readOnlyHint: true, but § 4 of the post argues that annotation is display metadata and enforces nothing. What enforces it here is that no handler writes: there is no write path to remove, the catalog is loaded once into an immutable mapping, and every object it hands out is a frozen Pydantic model. The tests call a write tool that does not exist and check that it returns an error and that the next read is unchanged.
Serving it over HTTP
uvx cordata-catalog-mcp --http --issuer https://idp.example --public-key issuer.pem \
--resource-url https://catalog.example/mcp
Over HTTP every request needs a bearer token: a JWT access token in the RFC 9068 shape, signed ES256 by the one issuer named with --issuer, whose audience is this server's own URL (--resource-url, by default http://HOST:PORT/mcp). A token minted for any other resource is refused, which is the RFC 8707 audience binding the MCP authorization spec requires, and the server never passes a token on to anything else.
Access is per domain. A token needs catalog:read to reach the server at all, and catalog:{domain}:read for each domain it may see — catalog:fraud:read, for example. The responses follow § 5 of the post:
- No token, or an invalid one:
401, with aWWW-Authenticateheader whoseresource_metadatapoints at/.well-known/oauth-protected-resource/mcp. That document is the RFC 9728 protected-resource metadata, and it names the issuer a client should get a token from. - A request for a domain the token has no scope for:
403, witherror="insufficient_scope"andscope="catalog:{domain}:read", so the client knows which scope to ask for. The server does not answer with an empty result, which a client could not tell apart from a dataset with nothing in it. - Lists —
list_domainsandfind_datasets— show only the domains the token may read. List results are markedcacheScope: private, because they differ per caller and a shared cache must not serve one caller's catalog to another.
The scope is checked again inside each handler on every call, so holding a token that read one domain grants nothing for the next request. The tests cover each of these against a throwaway issuer, a key pair generated in the test suite; no real identity provider is involved.
Deploying it against AWS
v1 makes no AWS calls; it serves the example data only. A deployment would read the ontology from lakeformation.list_lf_tags() in the governance account, the columns from each domain's Glue catalog, and ownership from DataZone. Three things about the identity chain were settled while designing it, and are recorded here because they are easy to get wrong:
- Use
sts:AssumeRoleunder the server's own identity, neverassume_role_with_web_identity. Federating the caller's token into AWS would forward a token this server accepted as its own audience to a third party, which the MCP authorization spec prohibits. The caller travels as the session name and a session tag instead. - Sanitise
RoleSessionName. STS accepts only[\w+=,.@-], up to 64 characters, and an OIDCsubcan contain|(Auth0 issuesauth0|abc123). - Allow
sts:TagSessionin the reader role's trust policy. Without it the session tags are refused, and the caller's identity never reaches CloudTrail.
Because AWS never sees the caller's token, no IAM policy can condition on it, and the scope check stays in application code. That is the reason each reader role should be scoped to a single domain.
Development
pip install -e ".[dev]"
ruff check . && ruff format --check .
pytest
The column check needs a warehouse the runtime's example pipelines have written, from a clone of cordata-tech/pipeline-runtime at the installed version; .github/workflows/ci.yml shows the steps.
CORDATA_WAREHOUSE=../pipeline-runtime/example/warehouse python -m tools.check_columns
Licence
MIT. See LICENSE.
Metadata
Release files for cordata-catalog-mcp 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cordata_catalog_mcp-0.1.0.tar.gz | 24.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cordata_catalog_mcp-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.9 kB
Release files / cordata_catalog_mcp-0.1.0.tar.gz
| Download URL | cordata_catalog_mcp-0.1.0.tar.gz |
|---|---|
| Size | 24.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
de36ff18a68b40f34cbc4f8454897a4fc4b9415b459db3f06fa44756cd6f17a1
|
|
BLAKE2b-256 checksum How to use checksums |
3be6b2f0c6913638235a88f568b1f68e425fb5b4823489138ee0ff7c76d5d697
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logRelease files / cordata_catalog_mcp-0.1.0-py3-none-any.whl
| Download URL | cordata_catalog_mcp-0.1.0-py3-none-any.whl |
|---|---|
| Size | 19.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
243dfc39409e97f7d87f26270703017c2e3a7bf216057713659a9e1c13b97541
|
|
BLAKE2b-256 checksum How to use checksums |
9c5da0e37701f92f4ba70076ca9ed6fe4c354986ef0555aa316e1c5f85bbbf1e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency log