# LinkML — MCP: 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:

```bash
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:

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

| Argument | Required | Notes |
|---|---|---|
| `graphProjectId` | yes | 32 hex characters (`^[a-f0-9]{32}$`) |
| `sourceFormat` | yes | `jsonschema \| shex \| avro \| jsonld \| sql \| osi \| osi-json \| owl \| linkml \| protobuf \| odcs \| odm` |
| `sourceSchema` | yes | the source schema text |
| `targetFormat` | yes | `jsonschema \| shex \| avro \| jsonld \| sql \| osi \| osi-json \| owl \| linkml \| protobuf \| odcs \| synapse` |
| `targetHintFormat` / `targetHintSchema` | for `inferred` | the schema to map toward |
| `mappingKind` | no | `inferred` (default) `\| explicit \| ai` |
| `guide` | for `explicit` | the mapping-guide JSON as text; free-text guidance when `mappingKind` is `ai` |
| `caseInsensitive` | no | inferred matching ignores label case; default `true` |
| `vendor` | no | **sql output only**: `postgres` (default) `\| mysql \| sqlserver` |
| `synapseOrg` / `synapseName` / `synapseVersion` | no | **synapse 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:

```yaml
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.

```json
{
  "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`:

```sql
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:

```json
{
  "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?":

```json
[
  { "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":

```json
{
  "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_projects` → `get_project_summary` → `export_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.
