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

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

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

| Argument | Required | Notes |
|---|---|---|
| `graphProjectId` | yes | 32 hex characters; scopes authorization |
| `sourceFormat` | yes | `jsonschema \| shex \| avro \| jsonld \| sql \| osi \| osi-json \| owl \| linkml \| protobuf \| odcs \| odm` |
| `sourceSchema` | yes | the schema text |
| `targetFormat` | yes | the same list minus `odm`, plus `synapse` |
| `targetHintFormat` / `targetHintSchema` | no | the schema to map toward; required for `inferred` |
| `mappingKind` | no | `inferred` (default) `\| explicit \| ai` |
| `guide` | no | the SIA mapping-guide JSON for `explicit`; free-text guidance for `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 |

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:

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

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

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

```json
{
  "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
`origin` - `Inferred` 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 `guide` -
`autoMatchByMapsTo`, `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.
