Apache Avro logoMCP

Handing Avro to an Agent: `transform_schema` End to End

Ask a language model to "convert this Avro schema to LinkML" and it will improvise — plausible YAML on a good day, invented field names on a bad one. Connect it to CoreModels over MCP and the same sentence becomes a tool call: typed arguments, a deterministic engine, and a machine-readable account of what the conversion cost. This article is the whole loop for Avro — connection, exact arguments, a real conversion, and what a well-behaved agent does with the answer.

Handing Avro to an Agent: transform_schema End to End

Ask a language model to "convert this Avro schema to LinkML" and it will improvise — plausible YAML on a good day, invented field names on a bad one. Connect it to CoreModels over MCP and the same sentence becomes a tool call: typed arguments, a deterministic engine, and a machine-readable account of what the conversion cost. This article is the whole loop for Avro — connection, exact arguments, a real conversion, and what a well-behaved agent does with the answer.

Connecting

CoreModels serves MCP at /mcp over streamable HTTP with OAuth 2.0. Clients discover the authorization server from the protected-resource metadata and register dynamically, so there is no pre-shared client id to copy around; spec-compliant clients run the PKCE authorization-code flow themselves.

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

Then complete the OAuth flow inside the session. In Claude Desktop or claude.ai it is Settings → Connectors → Add custom connector with the same URL, and a JSON-configured client needs only:

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

transform_schema requires the Viewer role, so it is served on the public /mcp endpoint — the admin endpoint is only needed for tools that write. The tool itself never writes: it is stateless, and the project id it takes scopes authorization only.

The tool contract

The tool is named transform_schema, titled "Transform Schema". Its input schema declares additionalProperties: false, which matters for agents — a misspelled argument is rejected instead of silently ignored.

ArgumentRequiredMeaning
graphProjectIdyesproject id, pattern ^[a-f0-9]{32}$
sourceFormatyesjsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm
sourceSchemayesthe source schema text — for Avro, the .avsc JSON
targetFormatyesjsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse
targetHintFormat / targetHintSchemanothe format and text of the schema to map toward
mappingKindnoinferred (default) | explicit | ai
guidenoexplicit: the SIA mapping-guide JSON; ai: optional free-text guidance
caseInsensitivenoinferred matching ignores label case (default true)
vendornofor sql output: postgres (default) | mysql | sqlserver
synapseOrg / synapseName / synapseVersionnofor synapse output only

Avro needs none of the format-specific options — vendor and the synapse* trio belong to other targets, and Avro output takes no arguments at all. Two Avro facts an agent must carry instead. The source has to be a record-rooted .avsc: a bare enum or a primitive at the root comes back as Could not decode the source schema: $: The root of an Avro schema must be a record. And for a pure format conversion, the idiom is to pass the source as its own targetHintSchema, because the inferred strategy declines rather than guesses when it has nothing to match against.

A conversion, end to end

The scenario: a logistics team publishes a Kafka topic, and someone asks their agent to produce LinkML so the modeling group can review the shape. The file:

{
  "type": "record",
  "name": "ShipmentEvent",
  "namespace": "com.acme.logistics",
  "doc": "A parcel moving through the network.",
  "fields": [
    { "name": "trackingNumber", "type": "string",
      "x-maps-to": { "schema.org": "https://schema.org/trackingNumber" } },
    { "name": "carrier", "type": "string" },
    { "name": "weightKg", "type": "double" },
    { "name": "leg", "type": { "type": "enum", "name": "Leg",
                               "symbols": ["pickup", "linehaul", "delivery"] } },
    { "name": "scannedAt", "type": { "type": "long", "logicalType": "timestamp-millis" } },
    { "name": "exceptionCode", "type": ["null", "string"] }
  ]
}

The tool arguments are an ordinary JSON object; this builds them from the file, escaping included:

jq -n --rawfile s ShipmentEvent.avsc '{
  graphProjectId: "3f2a9c1e5b7d48a0b6c2e4f8091a3d57",
  sourceFormat: "avro",
  sourceSchema: $s,
  targetFormat: "linkml",
  targetHintFormat: "avro",
  targetHintSchema: $s,
  mappingKind: "inferred"
}'

The tool answers with one JSON document carrying success, the produced schema, the executed plan, and the lossiness ledger. For a text target such as LinkML the schema value is a string (for JSON-shaped targets — avro, jsonschema, jsonld, synapse — it is a JSON object). Rendered:

id: https://coremodels.example.com/ns/avroschema
name: avro-schema
prefixes:
  linkml: https://w3id.org/linkml/
  schema: https://schema.org/
imports:
  - linkml:types
default_range: string

classes:
  ShipmentEvent:
    attributes:
      trackingNumber:
        slot_uri: schema:trackingNumber
        required: true
      carrier:
        required: true
      weightKg:
        range: float
        required: true
      leg:
        range: Leg
        required: true
      scannedAt:
        range: datetime
        required: true
      exceptionCode:

enums:
  Leg:
    permissible_values:
      pickup:
      linehaul:
      delivery:

Every Avro construct found its LinkML expression. The double became range: float; the timestamp-millis logical type became range: datetime; the enum became a LinkML enum with its permissible values; the ["null", "string"] union became an attribute that is simply not required. And the x-maps-to annotation — an IRI riding as a custom attribute, which Avro tolerates and CoreModels reads — became slot_uri: schema:trackingNumber, with the prefix declared for it. That is the single most valuable thing to know about annotating .avsc files: meaning you write once travels into every format that has a slot for it. The ledger for this call is empty.

The second ask, and the first honest answer

Same conversation, next request: "we also need a proto for the consumer team." The agent changes one argument, targetFormat: "protobuf", and gets:

syntax = "proto3";

message ShipmentEvent {
  string trackingNumber = 1;
  string carrier = 2;
  double weightKg = 3;
  Leg leg = 4;
  string scannedAt = 5;
  optional string exceptionCode = 6;
}

enum Leg {
  pickup = 0;
  linehaul = 1;
  delivery = 2;
}

This time the ledger is not empty:

[
  { "kind": "TypeApproximation",
    "path": "Element[ShipmentEvent.scannedAt]",
    "explanation": "proto3 has no date-time scalar; DateTime degrades to string." }
]

One entry, one field, one sentence. The agent should say it out loud: "scannedAt arrives as a string in the proto — proto3 has no date-time scalar." Everything else crossed intact, including the optional on the nullable field and the enum with its ordinals.

What a good agent does with the response

Surface the ledger, always. The tool succeeds whenever the transform ran; approximations are declared, not hidden. An agent that relays the schema without relaying the ledger discards exactly the information the tool exists to produce. One sentence per entry — what changed, where, why — is the right amount of prose.

Read origin as a confidence signal. Operations stamped Inferred are label-and-type matches: heuristics, surfaced honestly. When the request is high-stakes — a schema that feeds a contract, a migration that will run unattended — the agent should show the plan and invite correction instead of treating inference as intent.

Escalate mappingKind deliberately. inferred is the default and has no side effects. explicit takes an authored mapping guide as JSON text in guideautoMatchByMapsTo, fieldMappings ({sourceElementIds, targetElementIds, transformName}), taxonomyDirectives, drops — and is right when the user can state the mapping precisely. ai asks a server-side Claude proposer for a plan, which is useful when source and target differ in both shape and vocabulary, but it carries real preconditions: the server must hold an Anthropic API key, the caller needs Editor or Admin membership on the scoping project, and the schema content is sent to the Anthropic API server-side. The tool declares that as an open-world interaction. The proposal earns no shortcut — the same validation gate accepts or rejects it, with at most one repair attempt, and any self-reported confidence is advisory only. An agent should state all of that before choosing ai on someone's proprietary schema.

Keep the plan. It is a replayable artifact: POST graph/transform/plan/execute/{projectId} runs it again against the same source, through the same gate, for the same output. Offering it back to the user is what turns a chat answer into a pipeline step.

The project-resident counterpart

transform_schema converts schemas the agent is holding. When the model already lives in a CoreModels project, the companion tool export_avro produces the .avsc straight from the graph: required graphProjectId, optional spaceId to scope to one space, optional nodeIds to pick specific types. Between the two, an agent moves Avro in both directions — inbound through the engine with a full plan and ledger, outbound from the governed model.

The complete pattern, then: user pastes an .avsc; agent calls transform_schema with the source as its own hint; agent reads lossiness and reports the conversion with its caveats; agent offers the plan as an artifact worth keeping. The MCP quickstart in the CoreModels documentation covers connection details, the full tool inventory, and the admin endpoint for write tools.