# dbt — MCP: Four Tools and a Ledger: dbt Contracts from an Agent's Seat

"Generate the contracts for `stg_orders` and `stg_customers`, and tell me if anything won't apply cleanly." That sentence is an afternoon of dbt property-file maintenance, and an agent connected to CoreModels over MCP can answer it with real tool calls instead of plausible-looking YAML. Four vendor integration tools give the agent the same governance surface a human gets over HTTP: the same role checks, the same read-only guarantees, and the same honest ledger of what could not be represented.

# Four Tools and a Ledger: dbt Contracts from an Agent's Seat

"Generate the contracts for `stg_orders` and `stg_customers`, and tell me if anything won't apply cleanly." That sentence is an afternoon of dbt property-file maintenance, and an agent connected to CoreModels over MCP can answer it with real tool calls instead of plausible-looking YAML. Four vendor integration tools give the agent the same governance surface a human gets over HTTP: the same role checks, the same read-only guarantees, and the same honest ledger of what could not be represented.

That ledger is the part worth designing around. An agent generating dbt contracts will hit cases it must not resolve on its own — chiefly a model whose properties already live in a file CoreModels does not own. The generator refuses to overwrite that file and records why. A well-behaved agent relays the record and stops.

## Connecting, and confirming what is actually enabled

CoreModels serves MCP over stateless streamable HTTP with OAuth 2.0 (dynamic client registration and PKCE, so there is no client id to pre-provision). From Claude Code:

```bash
claude mcp add --transport http coremodels https://coremodels.example.com/mcp
claude mcp add --transport http coremodels-admin https://coremodels.example.com/mcp-admin
```

`/mcp` serves read-only, Viewer-role tools; `/mcp-admin` additionally serves the write tools. The endpoint split is a serving decision — the enforced boundary is the per-project role check, which applies no matter which endpoint minted the token. One honest caveat before you write any agent logic against this: a deployed MCP server exposes a configured subset of the tool catalog, and the vendor integration tools are not guaranteed to be in it. A production connector may be provisioned with the general graph read tools only — `list_projects`, `get_project_summary`, `search_nodes` — and no vendor verbs at all. Enumerate the tool list on the connection you actually have, or ask the operator, before assuming `generate_vendor_artifacts` is callable. An agent that plans four steps and discovers at step three that the tool is absent has wasted the human's turn.

## The four dbt tools

| Tool | Role | Endpoint | Writes |
|---|---|---|---|
| `get_vendor_integration_status` | Viewer | both | nothing |
| `audit_vendor_project` | Viewer | both | nothing |
| `generate_vendor_artifacts` | Viewer | both | nothing |
| `import_vendor_project` | Admin | `/mcp-admin` only | the graph, additively |

All four take `graphProjectId`, the 32-character hex project id (the input schema enforces `^[a-f0-9]{32}$`). All but status take `vendor`; for everything here the value is `"dbt"`. The two artifact-consuming tools — audit and import — take `artifacts` (name to raw content) and/or `artifactUrls` (name to https URL, fetched server-side), where `manifest` is required and `catalog` and `semantic_manifest` are optional. Generate consumes no artifacts at all: it reads the governed graph.

## `generate_vendor_artifacts`, argument by argument

| Argument | Type | Meaning |
|---|---|---|
| `graphProjectId` | string, required | the governing CoreModels project |
| `vendor` | string, required | `"dbt"` |
| `typeNames` | array of string | restrict output to these models; matched case-insensitively against the dbt model name and the governed type label. Omit for everything eligible |
| `targetVersion` | string | default emits the modern `data_tests:` key; `"1.7"` (or any `1.x` below 1.8) emits the legacy `tests:` key and records a ledger note saying dbt 1.8 and newer read `data_tests:`. An unparseable value stays modern and records a note |
| `layout` | `"model"` \| `"folder"` \| `"single"` | `model` (default) writes one property file per model, colocated beside its own `.sql`; `folder` writes one `_coremodels__models.yml` per model directory; `single` writes the one repo-wide `models/coremodels_contracts.yml`, kept for back-compat |
| `iriInDescription` | boolean | default true: bound ontology IRIs are also appended to each column `description`, because description is the only slot `persist_docs` carries into the warehouse column comment. Set false to suppress that; the structural `meta.coremodels.maps_to` block rides either way |
| `spaces` | array of string | scope the read to specific space ids when the governed estate does not live in the project's main space |

Colocation comes from each model's recorded source path, captured at import. A model at `models/staging/stg_orders.sql` produces `models/staging/stg_orders.yml`. Governed-first types with no dbt origin fall back to `models/<model>.yml`.

## What comes back

Two keys. `artifacts` is a list of `{ name, kind, content }` — `name` is the repo-relative path the file belongs at, `kind` is `"yaml"` for dbt, `content` is the file text. `lossiness` is the ledger: a list of `{ kind, path, explanation }`, where `kind` is `StructuralDrop` or `SemanticNarrowing` and `path` names the model or the `model.column` the record is about. A single artifact's `content` looks like this:

```yaml
# Generated by CoreModels — governed model contracts.
# Meaning changes belong in CoreModels; regenerate this file rather than editing it.
version: 2

models:
  - name: stg_orders
    description: "One row per order accepted by the storefront."
    config:
      contract:
        enforced: true
      materialized: table
    meta:
      coremodels:
        maps_to:
          - standard: "schema.org"
            uri: "https://schema.org/Order"
    columns:
      - name: order_id
        description: "Storefront order identifier. [schema.org: https://schema.org/orderNumber]"
        data_type: varchar(36)
        constraints:
          - type: not_null
        data_tests:
          - unique
          - not_null
        meta:
          coremodels:
            maps_to:
              - standard: "schema.org"
                uri: "https://schema.org/orderNumber"
      - name: customer_id
        data_type: varchar(36)
        data_tests:
          - relationships:
              to: ref('stg_customers')
              field: customer_id
      - name: order_status
        data_type: text
        data_tests:
          - accepted_values:
              values: ["placed", "shipped", "returned"]
        meta:
          coremodels:
            vocabulary: "Order Status"
```

`data_type` is present on every column because an enforced contract requires it — the vendor-native type recorded at import when the model round-tripped from dbt, a warehouse-neutral fallback otherwise. `accepted_values` comes from a governed taxonomy, so one vocabulary produces the same list in every model that uses that field. When a reference's target field is not recorded and the target has no unique-tested column, the `relationships` test is omitted and the ledger says why, rather than a guessed `field: id` that fails against the real warehouse.

## A session

**Human:** Generate the CoreModels contracts for `stg_orders` and `stg_customers` in the analytics project, and flag anything I need to deal with by hand.

**Agent:** Calling `generate_vendor_artifacts`.

```json
{ "graphProjectId": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "vendor": "dbt",
  "typeNames": ["stg_orders", "stg_customers"],
  "layout": "model" }
```

Result:

```json
{ "artifacts": [
    { "name": "models/staging/stg_orders.yml",    "kind": "yaml", "content": "# Generated by CoreModels..." },
    { "name": "models/staging/stg_customers.yml", "kind": "yaml", "content": "# Generated by CoreModels..." } ],
  "lossiness": [
    { "kind": "StructuralDrop", "path": "stg_customers",
      "explanation": "This model already has properties in 'models/staging/_stg__models.yml'. Remove its block there, or dbt will report a duplicate patch for the generated 'models/staging/stg_customers.yml'." },
    { "kind": "SemanticNarrowing", "path": "stg_customers.region_id",
      "explanation": "The reference's target field is not recorded and the target has no unique-tested column; relationships test omitted." } ] }
```

**Agent:** Two files, both under `models/staging/`, each colocated with its model's `.sql`. Two ledger items, and one of them needs you rather than me.

`stg_customers` already has a property block in `models/staging/_stg__models.yml`. dbt refuses two property blocks for one model, so the generated file will not parse until that block is removed. I have not touched that file and will not: it may carry properties for models this project does not govern, and deciding what leaves it is a review, not a cleanup. Suggested order — open a branch, drop the `stg_customers` block from `_stg__models.yml`, add both generated files, and let CI compile the manifest.

The second item is smaller. `region_id` in `stg_customers` is a governed reference, but no target field is recorded and the target model has no unique-tested column, so no `relationships` test was emitted. Adding a `unique` test on the target's key column and re-importing would make that test generate itself next time. Want me to write both files into the working tree so you can review the diff?

## The lines the agent does not cross

Generation is Viewer-role and writes nothing — not to the graph, not to the repo. The tool returns file content; the human's own branch-and-PR flow lands it. CoreModels never connects to a warehouse, never runs dbt, and never reads a live dbt platform API: artifacts in, artifacts out. Import is the only write in the set. It requires Admin membership on the target project and is served on `/mcp-admin` only, which gives you a clean failure mode — an agent connected to the public endpoint does not see the tool at all. Import is additive: already-governed nodes are never mutated, and the tool's own description points the agent at `audit_vendor_project` to see drift instead of expecting a re-import to reconcile anything.

Two more boundaries are worth stating outright, because agents fill gaps confidently. Generate emits model contract property files only — it does not produce a sources file, scaffold staging models, or lay out a project; dbt-codegen occupies that ground. And when no eligible models match, generation returns an error rather than an empty file, so "it produced nothing" is never mistaken for "everything is fine." Ephemeral models are skipped with a ledger record, since they cannot carry contracts at all.

## Why the loop is worth handing to an agent

The everyday sequence is short enough to run unattended: `get_vendor_integration_status` with `vendor: "dbt"` to see whether the estate is imported and when; `audit_vendor_project` against the latest build's manifest, where `errorCount > 0` is the same fail signal CI uses and findings arrive under stable codes — `contract-not-enforced`, `contract-column-missing-type`, `key-column-untested`, `source-no-freshness`, alongside drift codes like `field-type-drift` and `enum-narrowed`; then `generate_vendor_artifacts` scoped to the models under discussion.

What makes it safe is that every answer is grounded in the same graph a human governs, and every gap is named in-band. Importing a manifest alone gets you an accurate description of your estate and nothing more; the value shows up once someone writes the descriptions, defines the vocabularies, and binds the ontology terms — and then publishes that meaning back into the repo as contracts an agent can regenerate on request and a human still merges.

For the export recipe, the HTTP equivalents of every call above, and the CI gate these tools pair with, see the dbt quickstart that ships with the CoreModels integration docs.
