This is a concrete walkthrough of the gRPC agent workflow: reflect the server, fall back to its published schema when reflection is off, register that schema once, then introspect, generate a client, and infer.
What you need
- An OpenVINO Model Server reachable over gRPC. OVMS speaks the
KServe v2 / Open Inference Protocol (
inference.GRPCInferenceService). This tutorial usesovms-host:9000; substitute your own. - JDK 21+ to build and run ProtoMolt.
- grpcurl — a command-line gRPC client, used here to show the reflection result plainly before the agent comes in.
Get ProtoMolt
Clone and build the MCP server that an agent will talk to:
git clone https://github.com/ai-pipestream/protomolt.git
cd protomolt
./gradlew :protomolt-mcp:installDist
The launcher is now at
mcp/core/build/install/protomolt-mcp/bin/protomolt-mcp.
Pick a directory for it to use as a registry — it does not need to
exist; the store creates and initializes the Git repository on first use.
Try reflection first
The best case is that the server describes itself. Ask it with grpcurl:
grpcurl -plaintext ovms-host:9000 list
output
Failed to list services: server does not support the reflection API
OVMS — like NVIDIA Triton and many other production servers — does
not enable gRPC server reflection. ProtoMolt’s reflect tool
reports the same thing, but as a structured result rather than an error, which
is exactly what lets an agent decide to fall back:
// reflect { "target": "ovms-host:9000" }
{ "ok": false, "error": "Reflection stream failed: UNIMPLEMENTED" }
That ok: false is the fork in the road. When reflection works,
you already have the schema. When it does not, you fetch the schema from
where the project publishes it — which for KServe is a Git repository.
Bring the schema in from Git
The KServe gRPC contract lives in the
kserve/open-inference-protocol
repository as a single self-contained .proto. ProtoMolt gathers
.proto sources straight from Git — this is the
“point it at the repo” step. In Java:
ProtoGatherer gatherer = GitProtoGatherer.builder()
.repo("https://github.com/kserve/open-inference-protocol.git")
.ref("main")
.paths("specification/protocol/open_inference_grpc.proto")
.build();
// Publish it into your registry so every consumer shares one copy.
var store = GitSchemaRegistryStore.builder().repositoryDir(Path.of("/srv/schemas.git")).build();
var server = new SchemaRegistryServer(SchemaRegistryServerConfig.defaults(), store,
ActionCatalog.defaults(ActionContext.create()));
server.start();
new ConfluentSchemaPublisher(URI.create("http://localhost:8081"))
.publish(gatherer.gather(), PublishOptions.defaults())
.throwIfFailed();
The KServe schema is now a subject in your registry, versioned like any other. An agent reads it as a resource; a human resolves it by type name. You never wrote a line of it.
If you just want to try the flow without a registry, you can also paste the
.proto text inline as the sources schema on any tool
call — the registry is the durable, shareable version of the same thing.
Start the agent
Point your MCP client at the launcher with the registry mounted. For Claude Code:
claude mcp add protomolt -- \
/path/to/protomolt/mcp/core/build/install/protomolt-mcp/bin/protomolt-mcp \
--registry-git /srv/schemas.git
The agent now has thirteen tools and can browse your registry — including the KServe schema you just published — as resources.
Introspect the server and its models
First confirm the server is alive and identify it (grpc-invoke
with the KServe schema resolved from the registry):
// grpc-invoke { "target": "ovms-host:9000",
// "method": "inference.GRPCInferenceService/ServerMetadata",
// "schema": { "type": "inference.ServerMetadataRequest" ... },
// "request": {} }
{ "ok": true, "status": "OK",
"responses": [{ "name": "OpenVINO Model Server", "version": "2026.1.0..." }] }
Then ask a model to describe its own tensor interface — this is where the agent learns what the model actually takes and returns:
// grpc-invoke ... "method": ".../ModelMetadata", "request": { "name": "embedding_minilm" }
{ "ok": true, "responses": [{
"name": "embedding_minilm", "platform": "OpenVINO", "versions": ["1"],
"inputs": [
{ "name": "attention_mask", "datatype": "INT64", "shape": ["-1", "-1"] },
{ "name": "input_ids", "datatype": "INT64", "shape": ["-1", "-1"] }],
"outputs": [
{ "name": "sentence_embedding", "datatype": "FP32", "shape": ["-1", "384"] },
{ "name": "token_embeddings", "datatype": "FP32", "shape": ["-1", "-1", "384"] }]
}]}
The agent now knows the embedding model takes token tensors and returns a 384-dimensional vector — discovered live, from the running server.
Generate a native client
For anything tensor-heavy, hand-authoring message JSON is the wrong tool.
generate-stubs produces a real client from the same schema, no
protoc required:
// generate-stubs { "schema": { "type": "inference.ModelInferResponse" ... },
// "generators": ["python"] }
{ "ok": true, "files": [{ "name": "..._pb2.py", "generator": "python", "content": "..." }] }
The same call with ["java", "grpc-java"] yields a complete Java
gRPC client; cpp, csharp, ruby,
php, kotlin, and objc are all
available. Every libprotoc generator runs as WebAssembly inside the JVM
— a live call instead of a build step.
Run inference: text to embedding
The MiniLM pipeline is two models — a tokenizer and the embedder:
tokenizer_minilm: a string in (Parameter_1,BYTES) →input_ids,attention_mask,token_type_ids(INT64).embedding_minilm: those token tensors in →sentence_embedding(FP32, 384) out.
An agent chains them with two ModelInfer calls. Tokenize first:
// grpc-invoke ... "method": ".../ModelInfer",
// "request": { "model_name": "tokenizer_minilm",
// "inputs": [{ "name": "Parameter_1", "datatype": "BYTES", "shape": ["1"],
// "contents": { "bytes_contents": ["<base64 of your text>"] } }] }
{ "ok": true, "responses": [{
"outputs": [
{ "name": "input_ids", "datatype": "INT64", "shape": ["1", "13"] },
{ "name": "attention_mask", "datatype": "INT64", "shape": ["1", "13"] }, ... ],
"rawOutputContents": [ "<base64 packed int64s>", ... ] }]}
Then feed input_ids and attention_mask into the
embedder and read sentence_embedding. The result, for the text
"ProtoMolt makes any gRPC service agent-native":
output
sentence_embedding: shape [1, 384], 384 dims
first 6 dims: [-0.0568, -0.0415, -0.0492, -0.1137, 0.0086, -0.0807]
L2 norm: 0.9999
A real, normalized MiniLM sentence embedding — produced end to end through the MCP server, against the running OpenVINO backend.
One honesty note on tensors
KServe servers return tensor data in rawOutputContents:
little-endian packed bytes, ordered to match the outputs
list, not decoded into JSON arrays. Over grpc-invoke you
unpack them yourself (an int64 is 8 bytes, an
fp32 is 4).
That unpacking is precisely what a generated client does for you automatically — which is why, past a quick introspection or a one-off call, the natural move is step 6: generate the stub and do tensor I/O in a real client.
Where you land
With the KServe schema registered once, the OpenVINO server is a first-class citizen of your agent’s world:
- Introspection — the agent reads any model’s tensor contract from the live server.
- Inference — the agent calls
ModelInferdirectly, or generates a native client to do it at scale. - Reuse — the same registered schema gives every other language in your fleet a generated client, and every registered gRPC service the same treatment.
Nothing here was OpenVINO-specific past the model names: any KServe or Triton server works identically, and any gRPC service at all — reflection-enabled or schema-published — becomes agent-operable the same way.