CoreModelsCoreModels
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.

"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:

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

ToolRoleEndpointWrites
get_vendor_integration_statusViewerbothnothing
audit_vendor_projectViewerbothnothing
generate_vendor_artifactsViewerbothnothing
import_vendor_projectAdmin/mcp-admin onlythe 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

ArgumentTypeMeaning
graphProjectIdstring, requiredthe governing CoreModels project
vendorstring, required"dbt"
typeNamesarray of stringrestrict output to these models; matched case-insensitively against the dbt model name and the governed type label. Omit for everything eligible
targetVersionstringdefault 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
iriInDescriptionbooleandefault 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
spacesarray of stringscope 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:

# 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.

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

Result:

{ "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.

More on dbt

Why this matters: the dbt guides on coremodels.io. This page is also available as Markdown.