LinkML logoMCP

Give an Agent a LinkML Schema: `transform_schema` over MCP

> **Analyst:** Here's our product catalog in LinkML. The warehouse team needs a Postgres table for it. What do we lose?

Give an Agent a LinkML Schema: transform_schema over MCP

Analyst: Here's our product catalog in LinkML. The warehouse team needs a Postgres table for it. What do we lose?

That request is one tool call. The agent does not need to know LinkML's grammar, our internal model, or Postgres type rules — it needs one tool, four required arguments, and the discipline to read the ledger that comes back. This article is that call end to end: the connection, the exact argument list, a real conversation with real payloads, and the failure modes an agent should recognize.

Connecting

CoreModels (by ARAMAI) serves its MCP tools over streamable HTTP at /mcp, secured with OAuth 2.0. Discovery, dynamic client registration, and PKCE all run automatically in a spec-compliant client, so there is no client id to pre-register and no API key to paste. From Claude Code:

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

Then /mcp inside the session to complete the OAuth flow. In a client configured by JSON:

{
  "mcpServers": {
    "coremodels": { "type": "http", "url": "https://coremodels.example.com/mcp" }
  }
}

The /mcp endpoint serves the read-only (Viewer-role) tool set, and transform_schema is one of them. Nothing it does writes to your graph — the whole call is stateless, and the project id in the arguments scopes authorization only.

The tool

transform_schema takes exactly these arguments. Four are required; the input schema is closed (additionalProperties: false), so a client that validates arguments against it rejects a misspelled argument rather than ignoring it.

ArgumentRequiredNotes
graphProjectIdyes32 hex characters (^[a-f0-9]{32}$)
sourceFormatyesjsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm
sourceSchemayesthe source schema text
targetFormatyesjsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse
targetHintFormat / targetHintSchemafor inferredthe schema to map toward
mappingKindnoinferred (default) | explicit | ai
guidefor explicitthe mapping-guide JSON as text; free-text guidance when mappingKind is ai
caseInsensitivenoinferred matching ignores label case; default true
vendornosql output only: postgres (default) | mysql | sqlserver
synapseOrg / synapseName / synapseVersionnosynapse output only: the parts of the registered-schema $id

Worth saying explicitly, because agents guess: LinkML has no format-specific options on this tool. There is no LinkML dialect argument, no version argument, no prefix argument. vendor is ignored unless the target is sql; the synapse* trio is ignored unless the target is synapse. When LinkML is the source or the target, the only knobs that matter are the hint and the mapping kind.

The conversation

The analyst's schema:

id: https://example.org/product-catalog
name: product_catalog
prefixes:
  linkml: https://w3id.org/linkml/
  schema: https://schema.org/
imports:
  - linkml:types
default_range: string

classes:
  Product:
    description: A sellable item in the catalog.
    class_uri: schema:Product
    attributes:
      sku:
        identifier: true
        required: true
      title:
        slot_uri: schema:name
        required: true
      list_price:
        range: float
        required: true
      released_on:
        range: date
      availability:
        range: AvailabilityEnum

enums:
  AvailabilityEnum:
    description: Stock states the catalog publishes.
    permissible_values:
      IN_STOCK:
        meaning: schema:InStock
      OUT_OF_STOCK:
        meaning: schema:OutOfStock

The agent's tool call. Note the hint: the default inferred strategy builds its plan by matching labels against a target vocabulary, and for a straight format conversion the source's own labels are that vocabulary — so the document is passed twice.

{
  "name": "transform_schema",
  "arguments": {
    "graphProjectId": "0123456789abcdef0123456789abcdef",
    "sourceFormat": "linkml",
    "sourceSchema": "id: https://example.org/product-catalog\nname: product_catalog\n... (the YAML above)",
    "targetFormat": "sql",
    "vendor": "postgres",
    "targetHintFormat": "linkml",
    "targetHintSchema": "id: https://example.org/product-catalog\nname: product_catalog\n... (the same YAML)",
    "mappingKind": "inferred"
  }
}

The response is one JSON document with four keys — success, schema, plan, lossiness. The schema:

CREATE TABLE "Product" (
  "sku" VARCHAR(255) NOT NULL,
  "title" VARCHAR(255) NOT NULL,
  "list_price" NUMERIC NOT NULL,
  "released_on" TIMESTAMP,
  "availability" VARCHAR(255)
);

COMMENT ON COLUMN Product.title IS '{"x-maps-to":{"schema":"https://schema.org/name"}}';

The plan — seven operations, each stamped "origin": "Inferred" so a reviewer can see which alignments were guessed rather than declared:

{
  "operations": [
    { "kind": "TypeMapping", "origin": "Inferred",
      "sourceTypeId": "Product", "targetTypeId": "Product", "targetLabel": "Product" },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ProductSku"], "targetElementIds": ["ProductSku"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ProductTitle"], "targetElementIds": ["ProductTitle"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ProductListPrice"], "targetElementIds": ["ProductListPrice"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ProductReleasedOn"], "targetElementIds": ["ProductReleasedOn"] },
    { "kind": "ElementMapping", "origin": "Inferred",
      "sourceElementIds": ["ProductAvailability"], "targetElementIds": ["ProductAvailability"] },
    { "kind": "TaxonomyMapping", "origin": "Inferred",
      "sourceTaxonomyId": "AvailabilityEnum", "targetTaxonomyId": "AvailabilityEnum",
      "targetTreatment": "InlineEnum" }
  ]
}

And the lossiness — the direct answer to "what do we lose?":

[
  { "kind": "ConstraintRelaxation",
    "path": "Element[ProductAvailability]",
    "explanation": "Postgres has no inline enum; emitted as VARCHAR (the allowed-value constraint is not enforced)." }
]

A good agent reports all three parts, and reports them in that order of consequence:

The DDL is ready. One thing does not survive: availability becomes VARCHAR(255) because Postgres has no inline enum, so the IN_STOCK / OUT_OF_STOCK restriction will not be enforced by the database. schema:name on title was preserved as a column comment. Want me to add a CHECK constraint, or emit MySQL instead — its ENUM type carries the values natively?

That last offer is a real second call: change "vendor" to "mysql" and the ledger comes back empty, because MySQL emits ENUM('INSTOCK', 'OUTOFSTOCK'). Same tool, same arguments, one word different — which is exactly the kind of iteration agents are good at and humans find tedious.

The other LinkML tool

transform_schema is stateless. When the schema lives in a CoreModels project, the agent wants export_linkml instead — "Export project data as a LinkML (YAML) schema string":

{
  "name": "export_linkml",
  "arguments": {
    "graphProjectId": "0123456789abcdef0123456789abcdef",
    "spaceId": "fedcba9876543210fedcba9876543210",
    "nodeIds": ["a1b2c3d4e5f60718293a4b5c6d7e8f90"]
  }
}

Only graphProjectId is required. spaceId scopes the export to one space and is validated before anything runs — an unknown id comes back as spaceId '...' is invalid, you have to provide a valid spaceId. nodeIds restricts the export to particular type nodes; omit it to export every type. A useful pattern in practice: list_projectsget_project_summaryexport_linkml, so the agent discovers what exists before asking for it.

Failure modes worth teaching

No hint with inferred. The most common agent mistake, and it fails loudly rather than silently producing an empty schema: Could not produce a mapping plan: inference: The inference resolver requires a target IR to match against. The fix is one argument — pass the source as its own hint for a straight conversion, or pass the real target schema when projecting onto someone else's model.

A source that is not LinkML. The decoder is deliberately hard to break; unknown keys are preserved rather than rejected. What does fail is genuinely malformed input, with a message that says which: The document is not valid YAML: ..., or The document is valid YAML but is not a LinkML schema (none of id, name, prefixes, classes, slots, or enums is present).

Reaching for mappingKind: "ai". It exists, and it is gated the same way everything else is: a proposal from Claude goes through the identical validation plan gate with at most one repair attempt, and a rejected repair stays rejected. Two preconditions the agent should surface rather than retry blindly — it requires Editor or Admin membership on the scoping project, and it sends the schema content to the Anthropic API server-side. Without a server-configured key the tool declines honestly: mappingKind=ai requires a server-configured Anthropic API key (Transform:Anthropic:ApiKey or ANTHROPIC_API_KEY). Use 'explicit' or 'inferred' instead. For a format conversion like the one above, inferred is the right call and never leaves the server.

Treating success: true as "clean". It means the call ran. The ledger is the only statement about fidelity, and an agent that skips it is guessing on the user's behalf.

Why this shape

Every tool on the endpoint declares a title and an explicit read-only hint, and transform_schema additionally declares an open-world hint — honest advertising for the one path (mappingKind: "ai") that can leave the server. The rest of the surface is deterministic: decode, plan, gate, execute, encode. An agent gets a converted schema, the plan that produced it, and an itemized account of the difference — enough to explain its own work, and enough for a human to approve or reject it.

For the full tool inventory and the OAuth details, see the MCP quickstart in the CoreModels docs.