Documentation
DataCards issues scoped, revocable access cards that gate what an AI agent can see and do inside a DataHub catalog — which domains, platforms and entity types, how many calls per period, and whether it can write findings back. Any agent plugs a card in over MCP; the connection is the card, and revoking it kills access instantly.
Overview
Agents need catalog context: the right datasets, their lineage, the queries that feed them. Handing an agent an admin token to the whole metadata graph is reckless — one bad instruction and it reads, or writes, everything. DataCards takes the model the card industry settled on decades ago and applies it to metadata access: the data steward stays the owner, and the agent gets a card, a scoped authority to draw from the catalog.
- Your DataHub is the account. Nothing is copied or exported; the agent always reads the live catalog through the server.
- The card is a policy. Terms compiled to an enforceable rule set: allowed domains / platforms / entity types / entity URNs / glossary terms, lineage depth, call budget per fixed window, lifetime cap, usage count, expiry, and write-back scope.
- The agent holds the card, not the keys. What the agent gets is an MCP endpoint URL. Behind it, every call is checked against the card's terms and metered on a ledger — the agent never holds credentials to the catalog.
- Revoke kills it instantly. Freeze or revoke a card (or its whole sub-card tree) and every tool call stops, server-side immediately.
DataCards is a fork of GlassPay — an agentic spending-card system — re-aimed at metadata. The same enforcement shape applies: typed refusals instead of silent failures, a metered ledger, sub-card attenuation, and OpenTelemetry traces for every tool call. It plugs into any DataHub deployment through the standard GraphQL API.
How a Data Call Happens
One agent tool call, end to end:
- The agent calls a tool on its MCP connection (say search). The connection URL encodes a secret card id.
- The server loads the card and its ancestor chain. Every card in the chain must be alive: not frozen, not revoked, not expired — a liveness check.
- The call is validated against every ancestor's budget (each costs 1 unit): the fixed-window period cap, the lifetime cap, and the usage count. Refusals are typed and structured, not generic errors.
- The request is scoped before it ever leaves the server: search filters (domains, platforms, entity types, glossary terms) are injected into the upstream GraphQL query, and lineage is capped at the card's depth. Direct URN reads are gated against the fetched entity's facts.
- On success the ledger records one debit against the card (idempotent on a retry key, so a flaky network can't double-charge), an OpenTelemetry span closes with the tool name and card id, and the response carries the remaining budget so the agent can pace itself.
Enforcement & Refusals
A refused call is not an error blob — it is a structured refusal naming the violated term, so the agent can explain itself and adapt. The MCP response carries isError: true and a JSON body like this:
{
"status": "refused",
"code": "over_period_limit",
"message": "this call exceeds the period budget (498/500 used)",
"detail": {
"card_id": "card_8f2c",
"remaining_this_period": 2,
"period_resets_at": 1786646400
}
}The refusal codes the engine emits:
| Code | Meaning |
|---|---|
| card_frozen | The card (or an ancestor) was frozen by the steward. Only `card` still answers. |
| card_revoked | The card or an ancestor was revoked. The connection is dead. |
| card_expired | The card's expiry passed. |
| over_period_limit | The call would exceed the card's budget for this fixed window. |
| over_lifetime_limit | The call would exceed the card's cumulative lifetime budget. |
| uses_exhausted | The card's total call count is spent. |
| entity_not_allowed | The entity URN is outside the card's scope (or doesn't exist). |
| domain_not_allowed | The entity lives in a domain the card may not read. |
| platform_not_allowed | The entity's platform is not in the card's scope. |
| entity_type_not_allowed | The entity's type (e.g. MLMODEL) is not in the card's scope. |
| lineage_depth_exceeded | The request asked for more lineage hops than the card allows. |
| invalid_terms | The card's terms failed validation. |
| exceeds_parent_terms | A sub-card tried to exceed its parent's scope or budget. |
Budget checks walk the whole ancestor chain — a sub-card's call draws down its parent's budget too. That is what keeps a runaway sub-agent from burning through a budget its parent never approved.
Issuing a Card
The steward issues a card from the dashboard: name the agent's mission, compose the scope (domains, platforms, entity types, specific URNs, glossary terms), set the budget (calls per window, lifetime cap, expiry), and choose whether the card may write documents back. Issuing is one REST call:
POST /api/cards
Authorization: Bearer <DATACARDS_ADMIN_TOKEN>
{
"name": "revenue-analyst",
"terms": {
"scope": {
"domains": ["urn:li:domain:finance"],
"entityTypes": ["DATASET", "DASHBOARD"],
"glossaryTerms": ["urn:li:glossaryTerm:revenue"],
"lineageDepth": 2
},
"budget": { "period": { "amount": 500, "seconds": 86400 } },
"mutations": "documents",
"expiry": 1789488000
}
}
→ 201 {
"card_id": "card_8f2c",
"card_url": "https://datacards.example/c/secret…/mcp",
"terms": { …compiled terms… }
}The response's card_url is the only thing the agent ever needs. Paste it into Claude Code, Cursor, or any MCP client — the server surfaces the tool list from the card's terms.
The connection URL embeds the card secret. Treat it like a card number: share it only with the agent that should hold the card, and rotate by issuing a fresh card.
Card Terms
The terms vocabulary (the terms_json in the engine's card table):
| Field | Meaning |
|---|---|
| scope.domains | Domain URNs the card may read. Search is filtered; direct reads outside refuse. |
| scope.platforms | Platform URNs (snowflake, bigquery, …) the card may read. |
| scope.entityTypes | Entity types allowed (DATASET, DASHBOARD, CHART, CONTAINER, GLOSSARYTERM, DOCUMENT, DATAFLOW, DATAJOB, MLMODEL, …). |
| scope.entities | Explicit entity URN allowlist. Exclusive when no broader rule admits the entity. |
| scope.glossaryTerms | Only entities tagged with one of these glossary terms are visible. |
| scope.lineageDepth | Max lineage hops, 1–3. 1 is direct only; 3 means the neighborhood. |
| budget.period | Calls per fixed window: { amount, seconds }. Resets at start + k·seconds. |
| budget.lifetime | Cumulative lifetime call ceiling. Stacks with period. |
| budget.spend | USD (in cents) per fixed window for the Stripe test-mode payment lane (shop checkout / fiat_pay). Absent = no payments. |
| mutations | "none" (default, read-only) or "documents" (save_document write-back). |
| expiry | Unix seconds. The card dies after this. |
| maxUses | Total number of tool calls. |
| subcards | May this card mint sub-cards? Default true. |
| maxRows | Max rows a single query_data call may return. Absent = no row cap. |
Every card needs at least one budget cap (budget.period and/or budget.lifetime); scope is optional and an absent scope means full-catalog reads — still budgeted, still expiring, still revocable. All URNs are normalized (lowercased, deduped) at compile time and validated before a card is stored.
Sub-Cards & Attenuation
An agent with a card can delegate downward: issue_subcard mints a tighter card for a sub-agent. Sub-cards are the enforcement surface's attenuation rules, applied at issue time:
- Scope is subset-only and restriction-inheriting. A child may not name a domain, platform, entity, glossary term, or depth the parent doesn't already have — and any restriction the parent carries that the child omits is inherited, so a sub-card can never be wider than its parent.
- Budget inherits capped to remaining. Omitted child budgets inherit the parent's cap, clamped to what's actually left. Explicit caps must fit inside both the parent's cap and its remaining budget.
- Mutations only narrow. A child can take documents → none, never the reverse. Write-back is never silently inherited.
- Expiry never extends. A child's expiry cannot outlive the parent's.
- Every child call draws down ancestors. The ledger walks the whole chain, so a sub-agent can never exceed a budget its parent didn't approve.
A parent can kill any direct sub-card with revoke_subcard — descendants die with it. The steward's dashboard can freeze, revoke, or delete any card in the tree.
Connecting an Agent
The agent connects with the card URL, exactly like any other MCP server:
claude mcp add datacards --transport http https://datacards.example/c/card_8f2c_<secret>/mcp
At initialize the server reports its tool list — derived from this card's terms. A read-only card never sees save_document; a card with subcards: false never sees issue_subcard. The tool list is the permission surface. The server also embeds instructions that route the agent to the right tool for its first move, and most clients index that text for tool search.
MCP Tools
| Tool | What it does | Gated by |
|---|---|---|
| card | Live state: remaining budget, terms, recent access log, sub-cards. | always available |
| search | Catalog search; results auto-filtered to the card's scope. | budget · scope filters |
| get_entities | Detailed metadata for up to 10 URNs (schema, platform, domain, owners, tags, glossary terms). | budget · per-URN gate |
| get_lineage | Upstream/downstream lineage, depth capped by the card. | budget · depth cap · root gate |
| list_schema_fields | Dataset columns with types and descriptions; optional keyword filter. | budget · per-URN gate |
| get_dataset_queries | Real SQL queries (manual or system) referencing a dataset. | budget · per-URN gate |
| query_data | Governed SQL preview: scope + PII/sensitive checks (refusal pii_blocked) and row-limit enforcement (row_limit_exceeded). | budget · scope · governance · maxRows |
| lineage_check | Downstream impact summary — what would break if this entity changed. | budget · depth cap · root gate |
| save_document | Persist findings back to the knowledge base, linked to assets in scope. | write-back cards only · per-URN gate |
| card_credentials | Reveal the Stripe test-mode Visa linked to the card (PAN/expiry/CVC). | spend-budget cards · Stripe configured |
| fiat_pay | Buy from the demo shop against the card's spend budget. | spend-budget cards · Stripe configured |
| issue_subcard | Mint a tighter child card; returns its connection URL. | subcards on · attenuation |
| revoke_subcard | Kill a sub-card you issued (descendants die too). | subcards on · ownership |
Every call is wrapped in an mcp_tool_<name> OpenTelemetry span with the tool name, card id, error flag, and refusal code as attributes — the activity feed in the dashboard and your OTLP collector read the same events.
Auth & Connection URLs
Two auth surfaces, deliberately different:
- Stewards (the dashboard) sign in with the server's admin token (DATACARDS_ADMIN_TOKEN), sent as a bearer token on every API call — or with a self-hosted OAuth 2.1 authorization-code flow when configured.
- Agents (MCP) authenticate with the card URL's secret. The server resolves the card, checks liveness, and answers only within the card's terms. No agent ever sees a DataHub credential.
The card secret is the agent's whole identity. There is deliberately no second factor and no session: if the secret leaks, revoke the card — the URL dies instantly, no key rotation anywhere.
Observability
Every tool call is a trace, every refusal is a trace event with its code, every ledger write is a metric. The server ships OpenTelemetry with configurable OTLP export (SigNoz, Grafana, Jaeger — any collector). What you get:
- Traces: mcp_tool_<name> spans with card id, error flag, refusal code, and latency. See which agents burned budget on what, and where.
- Metrics: access counts, refusal counts by code, budget consumption per card.
- Logs: structured access events with card, tool, target URN, and status — the raw audit trail for every card, queryable in the dashboard's activity feed.
REST API
The dashboard speaks to the same HTTP surface you can curl:
| Route | Method | Purpose |
|---|---|---|
| /api/* | Bearer | Every dashboard call carries the admin token (DATACARDS_ADMIN_TOKEN) as a bearer header. |
| /api/cards | GET | List the card tree (all cards, parents and sub-cards). |
| /api/cards | POST | Issue a card from terms JSON. |
| /api/cards/:id | GET | One card's live state + activity. |
| /api/cards/:id/freeze · /unfreeze | POST | Toggle the card (and its tree) off/on. `card` still answers. |
| /api/cards/:id/revoke | POST | Revoke the card and its descendants. URL dies instantly. |
| /api/cards/:id/rotate | POST | Mint a new secret for the same card (URL changes, terms don't). |
| /api/cards/:id | DELETE | Purge the card and its ledger rows. |
| /api/me | GET | DataHub reachability + the connected user (also /health on the bare root). |
Every response carries the card's remaining budget where relevant, and every mutation is recorded in the same access log the agents produce — the steward's dashboard shows humans and agents on one ledger.
Self-Hosting
Three processes, one DATAHUB_GMS_URL pointing at your DataHub GMS:
- datacards-api — the Hono server: REST API, MCP endpoint, OAuth, OTLP export. Storage is a SQLite file (set DATACARDS_DB_PATH); budget windows and ledger are computed there.
- datacards dashboard — the Next.js app (admin token login, card deck, terms composer, activity feed). Set NEXT_PUBLIC_DATACARDS_API and the server's admin token.
- DataHub — your existing GMS. The server only ever talks to it through GraphQL, with scopes injected upstream.
# server DATACARDS_ADMIN_TOKEN=… # steward bearer token (the dashboard login) DATAHUB_GMS_URL=https://datahub.example/graphql DATACARDS_PUBLIC_MCP_BASE=https://datacards.example # mints card URLs OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # optional DATACARDS_DB_PATH=./datacards.db
MCP is transport-agnostic: the same server answers HTTP (/c/:id/mcp) and, when mounted in-process, streamable HTTP or stdio — so a card can be handed to a local CLI agent with nothing but a URL.
DataCards · built for the DataHub Agent Hackathon · forked from GlassPay, the agentic spending-card system.