Protocol Buffers logoMCP

Handing proto3 to an Agent: transform_schema over MCP

"Take our telemetry `.proto` and give me the Postgres table for it — and tell me what we lose."

Handing proto3 to an Agent: transform_schema over MCP

"Take our telemetry .proto and give me the Postgres table for it — and tell me what we lose."

That request is a bad fit for a chat window and a good fit for a tool call. The model should not be transcribing field lists into DDL from memory; it should be calling a converter that knows the proto3 specification, and then reporting the converter's own account of what changed. CoreModels exposes exactly that as an MCP tool named transform_schema, and this article walks the whole round: connecting, the argument list, two real calls with real responses, and what the agent should do with the answer.

Connect once

The MCP endpoint is https://coremodels.example.com/mcp, streamable HTTP, protected by OAuth 2.0. Clients that follow the current MCP spec discover the authorization server from the protected- resource metadata, register themselves dynamically, and run the PKCE authorization-code flow — there is no client id to pre-provision and no API key to paste into a config file.

From Claude Code:

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

then /mcp inside the session to complete the sign-in. A generic JSON-configured client wants:

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

transform_schema is a read-only tool requiring Viewer access, so it is served on this endpoint. It does carry an open-world annotation, and the reason is specific: one of its mapping modes calls an external model API server-side. More on that below.

The argument list, exactly

The tool's input schema sets additionalProperties: false, so an agent cannot invent parameters — a schema-validating client rejects a misspelled argument rather than ignoring it. Four arguments are required:

ArgumentRequiredNotes
graphProjectIdyes32 hex characters; scopes authorization
sourceFormatyesjsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm
sourceSchemayesthe schema text
targetFormatyesthe same list minus odm, plus synapse
targetHintFormat / targetHintSchemanothe schema to map toward; required for inferred
mappingKindnoinferred (default) | explicit | ai
guidenothe SIA mapping-guide JSON for explicit; free-text guidance for ai
caseInsensitivenoinferred matching ignores label case; default true
vendornosql output only: postgres (default) | mysql | sqlserver
synapseOrg / synapseName / synapseVersionnosynapse output only

For Protocol Buffers the practical answer to "what are the format-specific options" is: none. There is no protobuf equivalent of vendor. sourceFormat: "protobuf" decodes a single proto3 file, targetFormat: "protobuf" emits one, proto is accepted as an alias for either, and the only knobs that change the result are the mapping kind and the hint.

Call one: proto3 in, Postgres out

Here is the call the opening request should produce. The whole .proto travels as a JSON string:

{
  "graphProjectId": "0123456789abcdef0123456789abcdef",
  "sourceFormat": "protobuf",
  "sourceSchema": "syntax = \"proto3\";\n\npackage acme.telemetry;\n\nimport \"google/protobuf/timestamp.proto\";\n\nmessage DeviceReading {\n  string device_id = 1;\n  sint32 temperature_c = 2;\n  uint32 battery_pct = 3;\n  google.protobuf.Timestamp read_at = 4;\n  optional string firmware = 5;\n}",
  "targetFormat": "sql",
  "vendor": "postgres",
  "targetHintFormat": "protobuf",
  "targetHintSchema": "syntax = \"proto3\";\n\npackage acme.telemetry;\n\nimport \"google/protobuf/timestamp.proto\";\n\nmessage DeviceReading {\n  string device_id = 1;\n  sint32 temperature_c = 2;\n  uint32 battery_pct = 3;\n  google.protobuf.Timestamp read_at = 4;\n  optional string firmware = 5;\n}",
  "mappingKind": "inferred"
}

The repetition of the source as its own hint is deliberate and is the single rule an agent must learn about this tool: inferred matches the source against a hint and refuses to guess without one. For a straight format conversion the source is the hint, which produces an identity plan. Omit it and the tool answers Could not produce a mapping plan: inference: The inference resolver requires a target IR to match against.

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

{
  "success": true,
  "schema": "CREATE TABLE \"DeviceReading\" (\n  \"device_id\" VARCHAR(255) NOT NULL,\n  \"temperature_c\" INTEGER NOT NULL,\n  \"battery_pct\" INTEGER NOT NULL,\n  \"read_at\" TIMESTAMP,\n  \"firmware\" VARCHAR(255)\n);\n",
  "plan": { "operations": [ /* one operation per mapped construct */ ] },
  "lossiness": [
    { "kind": "SemanticNarrowing",
      "path": "imports",
      "explanation": "import \"google/protobuf/timestamp.proto\" is preserved verbatim but not resolved; types it defines decode as approximations." },
    { "kind": "TypeApproximation",
      "path": "DeviceReading.temperature_c",
      "explanation": "proto3 sint32 encoding semantics are not modelled; approximated as Integer with the verbatim type preserved." },
    { "kind": "TypeApproximation",
      "path": "DeviceReading.battery_pct",
      "explanation": "proto3 uint32 encoding semantics are not modelled; approximated as Integer with the verbatim type preserved." }
  ]
}

Text formats arrive as a string in schema; JSON formats (jsonschema, avro, jsonld, synapse) arrive as an object. Unescaped, the table is:

CREATE TABLE "DeviceReading" (
  "device_id" VARCHAR(255) NOT NULL,
  "temperature_c" INTEGER NOT NULL,
  "battery_pct" INTEGER NOT NULL,
  "read_at" TIMESTAMP,
  "firmware" VARCHAR(255)
);

Now the second half of the request — tell me what we lose — has a factual answer instead of a guess. Three things changed. The import was kept but not resolved, because we parse one file rather than an include graph. sint32 and uint32 both became INTEGER: zigzag encoding and unsignedness are wire facts a Postgres column has no vocabulary for. The nullability in the DDL is not a guess either — device_id, temperature_c, and battery_pct are plain scalar fields, which in proto3 always carry a value, so they land as NOT NULL, while the explicitly optional firmware and the message-typed read_at do not.

A good agent answer summarizes those three records and stops. It should not add caveats the ledger does not contain, and it should not omit the ones it does.

Call two: the same source, a different target

Follow-ups are cheap because only targetFormat changes. "Now give me the Avro version for the topic" is the same arguments with "targetFormat": "avro" and no vendor:

{
  "type": "record",
  "name": "DeviceReading",
  "fields": [
    { "name": "device_id", "type": "string" },
    { "name": "temperature_c", "type": "long" },
    { "name": "battery_pct", "type": "long" },
    { "name": "read_at", "type": ["null", { "type": "long", "logicalType": "timestamp-millis" }] },
    { "name": "firmware", "type": ["null", "string"] }
  ]
}

Same three lossiness records, because they are properties of leaving proto3 rather than of arriving in SQL. What changed is how presence is expressed: Avro says "may be absent" with a ["null", T] union, and the two fields that get one are exactly the two that were not NOT NULL in the table. The google.protobuf.Timestamp became a timestamp-millis logical type instead of a TIMESTAMP column. One decode, several faithful projections.

Going the other way is the same shape of call: "sourceFormat": "avro", "targetFormat": "protobuf", and the schema string comes back as proto3 text with sequential field numbers minted in declaration order.

Keep the plan

The plan in every response is the list of operations that actually executed, each stamped with an originInferred for a heuristic label match, Explicit for something an author asked for. An agent that is doing a one-off conversion can ignore it. An agent that is setting up a repeatable job should hand it back to the human, because that JSON replays: the HTTP route POST /graph/transform/plan/execute/{projectId} takes the same source plus the plan as a string and produces the same output, deterministically, through the same validation gate.

That is the difference between an agent that converted a schema once and an agent that set up a conversion.

The three mapping kinds, and the one that costs

inferred is the default and needs a hint. explicit takes a SIA mapping guide in guideautoMatchByMapsTo, fieldMappings with {sourceElementIds, targetElementIds, transformName}, taxonomyDirectives, and drops — and unknown keys in that guide are rejected with a path-carrying error rather than silently ignored, because a typo that executes is worse than a typo that fails.

ai asks a server-side Claude proposer for a plan, and then validates it through the identical gate with at most one repair attempt; a rejected repair is a rejection. Two conditions apply and both fail loudly: it requires Editor or Admin membership on the scoping project, and it requires a server-configured Anthropic API key — without one the tool answers that ai is unavailable and suggests explicit or inferred. It also sends the schema content to the Anthropic API server-side, which is why the tool advertises an open-world annotation. If your .proto is proprietary, that is a decision to make deliberately; inferred and explicit never leave the server.

Any other value answers Unknown mappingKind 'x'. Use: inferred | explicit | ai.

When the file is not proto3

Refusals arrive as tool errors with the coder's own words attached, so an agent can act on them rather than retry blindly:

  • Could not decode the source schema: $: Only proto3 is supported; the file declares syntax "proto2".
  • Could not decode the source schema: $: 'required' fields are proto2; only proto3 is supported.
  • Could not decode the source schema: $: Expected ';' but found '}'.

Meanwhile service blocks, extend blocks, map fields, oneof groups, and unresolvable imports never cause a refusal — they decode as far as they can and appear in the ledger. The right agent behavior on a refusal is to report the message verbatim; it names the exact problem, and no amount of rephrasing turns proto2 into proto3.

For the complete tool inventory and the connection walkthrough, see the MCP section of the CoreModels documentation.