Keep Types Synced with Proto Definitions
You added a field to a proto file and three hand-maintained type
copies are already stale. The MCP tool schema still describes
yesterday's request shape. The service base class expects
arguments that no longer exist. The client sends the old format.
Four libraries eliminate that drift:
@forwardimpact/libcodegen,
@forwardimpact/libtype,
@forwardimpact/librpc, and
@forwardimpact/libmcp. Define the contract once in
proto. Run fit-codegen. The generated artifacts then
keep every layer consistent: JavaScript types, typed clients,
service base classes, gRPC definitions, and MCP tool schemas.
This guide walks through the full pipeline. You write a proto definition. You then call the generated service from gRPC clients and from MCP-connected agents.
Prerequisites
- Node.js 22+
-
Proto files co-located in
proto/directories (either in your project root or inside installed@forwardimpact/*packages) - Install the codegen CLI:
npm install @forwardimpact/libcodegen
The runtime libraries install as dependencies of the packages that
consume them. @forwardimpact/libtype ships the
generated types. @forwardimpact/librpc ships the
server/client framework. @forwardimpact/libmcp bridges
gRPC methods to MCP tools.
How the pipeline works
The codegen pipeline reads proto files and produces five categories of output. A flag gates each category:
proto/*.proto
|
v
fit-codegen generate --all
|
+---> generated/types/types.js (--type) JavaScript protobuf types
+---> generated/types/metadata.js (--metadata) Field metadata for MCP schemas
+---> generated/services/*/service.js (--service) Service base classes
+---> generated/services/*/client.js (--client) Typed gRPC clients
+---> generated/definitions/ (--definition) gRPC service definitions
Every generated file carries a
@generated by fit-codegen header. Do not edit them. The
next run overwrites your changes.
Step 1: Write a proto definition
Define your service contract in a .proto file inside
your project's proto/ directory. The codegen
discovers all .proto files from installed
@forwardimpact/* packages and your project root
automatically.
// proto/inventory.proto
syntax = "proto3";
package inventory;
import "common.proto";
import "tool.proto";
service Inventory {
rpc ListItems(ListItemsRequest) returns (tool.ToolCallResult);
rpc GetItem(GetItemRequest) returns (tool.ToolCallResult);
}
message ListItemsRequest {
// Category to filter by
optional string category = 1;
// Maximum number of results
optional int32 limit = 2;
}
message GetItemRequest {
// Unique item identifier
string item_id = 1;
}
The common.proto and tool.proto imports
resolve from the installed
@forwardimpact/libproto package, which carries the
canonical shared schemas. The codegen uses every discovered
proto/ directory as an include path, so cross-file
imports work without extra configuration.
Step 2: Run code generation
Generate all artifacts with a single command:
npx fit-codegen generate --all
The expected output follows. The file count varies with the proto count:
Generated 28 files in ./generated/
definitions/ — Service definitions
proto/ — Proto source files
services/ — Service bases and clients
types/ — Protocol Buffer types
Code generation complete (types, services, clients, definitions, metadata).
You can also generate specific artifact categories:
npx fit-codegen generate --type # JavaScript types only
npx fit-codegen generate --service # Service base classes only
npx fit-codegen generate --client # Typed clients only
npx fit-codegen generate --definition # gRPC definitions only
npx fit-codegen generate --metadata # Field metadata for MCP only
Combine flags to generate a subset.
npx fit-codegen generate --type --client generates
types and clients. It does not rebuild service bases or definitions.
Step 3: Use generated types
@forwardimpact/libtype re-exports everything from
generated/types/types.js. It organizes the types by
proto package namespace:
import { inventory, common, tool } from "@forwardimpact/libtype";
// Construct a typed request
const req = inventory.ListItemsRequest.fromObject({
category: "electronics",
limit: 10,
});
// Verify before sending
const error = inventory.ListItemsRequest.verify(req);
if (error) throw new Error(error);
// Convert to plain object for serialization
const plain = inventory.ListItemsRequest.toObject(req);
console.log(plain);
// { category: "electronics", limit: 10 }
Each generated type provides three static methods:
| Method | Purpose |
|---|---|
fromObject |
Create a typed instance from a plain object |
toObject |
Convert a typed instance to a plain object |
verify |
Validate a plain object against the proto schema |
Some types have a resource.Identifier field, like
common.Message and tool.ToolFunction.
Those types also receive a withIdentifier prototype
method. It auto-populates id.type,
id.name, and id.tokens from the instance
content.
Built-in namespaces
Codegen generates a namespace from your own proto package (inventory
above). @forwardimpact/libtype also always exports six
namespaces built from the shared schemas:
| Namespace | What it carries |
|---|---|
common |
Shared envelopes: Empty, Usage,
Message, Choice,
Embedding, Conversation
|
resource |
The Identifier type that messages and tool calls
reference
|
tool |
Tool-call types: ToolFunction,
ToolCall, ToolCallResult,
QueryFilter
|
vector |
Vector-search request and result types |
graph |
Graph-query request and result types |
span |
Distributed-tracing span and event types |
Import any of them the same way:
import { common, resource, vector, graph, span } from "@forwardimpact/libtype";
Services reuse the common, resource,
vector, graph, and
span namespaces. A graph service constructs a
graph.SubjectsQuery. A vector service constructs a
vector request. Neither one redefines those shapes.
The metadata export
@forwardimpact/libtype also exports a
metadata object that codegen builds from the same proto
files. It is the bridge that lets libmcp register tools
with no hand-written schema:
import { metadata } from "@forwardimpact/libtype";
Codegen keys metadata by package.Service,
then by method name. Each method entry records the request type and
a description of every field. That description has the proto type,
the repeated status, and the proto comment:
metadata["inventory.Inventory"]["ListItems"];
// {
// requestType: "inventory.ListItemsRequest",
// fields: {
// category: { type: "string", repeated: false, description: "Category to filter by" },
// limit: { type: "int32", repeated: false, description: "Maximum number of results" }
// }
// }
You rarely read metadata directly.
libmcp consults it at startup to build a Zod schema for
each tool. librpc clients use the type classes it
points to. Run npx fit-codegen generate --metadata to
keep it in step with the proto files. metadata excludes
the shared common.proto. Only service-bearing proto
files contribute entries.
Step 4: Implement the service
The codegen produces a base class in
generated/services/inventory/service.js with stub
methods for each RPC. Extend it with your business logic:
import { services } from "@forwardimpact/librpc";
// The generated base class
const { InventoryBase } = services;
class InventoryService extends InventoryBase {
#items;
constructor(config, items) {
super(config);
this.#items = items;
}
async ListItems(req) {
let results = this.#items;
if (req.category) {
results = results.filter((item) => item.category === req.category);
}
if (req.limit) {
results = results.slice(0, req.limit);
}
return { content: JSON.stringify(results) };
}
async GetItem(req) {
const item = this.#items.find((i) => i.id === req.itemId);
if (!item) return { content: "Not found" };
return { content: JSON.stringify(item) };
}
}
The base class validates and converts each request with
fromObject and verify before your method
receives it. Each method receives a typed request instance and
returns a response that matches the proto's return type.
Step 5: Start the gRPC server
Wrap the service implementation in the librpc
Server and start it:
import { Server } from "@forwardimpact/librpc";
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
const config = { name: "inventory", host: "0.0.0.0", port: 50060 };
const items = [
{ id: "a1", category: "electronics", name: "Sensor" },
{ id: "b2", category: "mechanical", name: "Bearing" },
];
const runtime = createDefaultRuntime();
const service = new InventoryService(config, items);
const server = new Server(service, config, { runtime });
await server.start();
// Server listening on 0.0.0.0:50060
The Server takes the service, its config, and an
options bag that carries the required runtime and the
optional logger and tracer. It adds HMAC
authentication with the SERVICE_SECRET environment
variable. It adds keepalive configuration. It adds graceful shutdown
on SIGINT/SIGTERM. It adds health checks
at the standard gRPC health endpoint. See
Ship a Service Endpoint
for the authentication, keepalive, retry, and tracing details.
Step 6: Call the service from a typed client
The codegen produces a typed client in
generated/services/inventory/client.js. Use it directly
or through the createClient convenience factory:
import { createClient } from "@forwardimpact/librpc";
import { inventory } from "@forwardimpact/libtype";
const client = await createClient("inventory");
const req = inventory.ListItemsRequest.fromObject({
category: "electronics",
});
const result = await client.ListItems(req);
console.log(result.content);
// [{"id":"a1","category":"electronics","name":"Sensor"}]
The generated client validates that the request is an instance of
the expected type. It converts the request with
toObject for the wire format. It converts the response
back with fromObject. The client has retries built in.
It retries up to 10 times on transient errors, with exponential
backoff that starts at 1 second and adds jitter.
For streaming RPCs, use callStream instead of the typed
method:
const stream = client.callStream("WatchItems", req);
stream.on("data", (chunk) => console.log(chunk));
stream.on("end", () => console.log("Stream ended"));
Step 7: Expose the service as MCP tools
@forwardimpact/libmcp reads the codegen metadata and
the tool configuration from config/config.json to
register gRPC methods as MCP tools. You write no glue code and no
hand-written schemas.
Add the tool entries to your config/config.json:
{
"service": {
"mcp": {
"tools": {
"ListItems": {
"method": "inventory.Inventory.ListItems",
"description": "List inventory items, optionally filtered by category."
},
"GetItem": {
"method": "inventory.Inventory.GetItem",
"description": "Look up a single inventory item by its identifier."
}
}
}
}
}
The method value follows the pattern
{package}.{Service}.{Method}. It matches the proto
definition exactly. libmcp uses the generated metadata
to build a Zod schema from the request type's fields. Proto
field types map to Zod validators (string to
z.string(), int32 to
z.number(), bool to
z.boolean()). Repeated fields accept both single values
and arrays. libmcp excludes system fields like
filter and
anthropic_api_key automatically.
In the MCP service, registration is a single call:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerToolsFromConfig } from "@forwardimpact/libmcp";
const server = new McpServer({ name: "my-service", version: "0.1.0" });
registerToolsFromConfig(server, config, {
inventory: inventoryClient,
});
When an agent calls the ListItems tool,
libmcp normalizes the parameters against the field
metadata. It constructs a typed request with
fromObject. It calls the gRPC method through the
client. It returns the result as MCP content.
What each library owns
| Library | Responsibility | Key export |
|---|---|---|
libcodegen |
Reads proto files, runs protobufjs-cli, renders
templates
|
npx fit-codegen |
libtype |
Re-exports generated types and metadata | { common, graph, ... } |
librpc |
gRPC server/client framework with auth, retry, tracing |
Server, Client,
createClient
|
libmcp |
Config-driven gRPC-to-MCP tool registration | registerToolsFromConfig |
The libraries are layered. libcodegen produces files
that libtype and librpc consume.
libmcp depends on both libtype (for
metadata and type classes) and librpc (for the gRPC
clients it wraps).
Manage shared schemas
More than one service needs some message types. Examples are a
tool-call envelope, a resource identifier, and a token-usage record.
You do not redefine those in each proto file. They live once in
@forwardimpact/libproto, which ships three canonical
.proto files and no JavaScript:
| Shared proto | Defines | Imports |
|---|---|---|
resource.proto |
Identifier, the type /
name / parent /
subjects shape every persisted resource carries
|
(none) |
tool.proto |
Tool-call types: ToolFunction,
ToolCall, ToolCallResult,
ToolCallMessage, QueryFilter
|
resource.proto |
common.proto |
Shared envelopes: Empty, Usage,
Message, Choice,
Embedding, Conversation
|
resource.proto, tool.proto |
How imports resolve
When your proto imports a shared file:
import "common.proto";
import "tool.proto";
the codegen resolves it without any path configuration. At
generation time it scans every proto/ directory it can
find. Those directories are your project root and every installed
@forwardimpact/* package,
@forwardimpact/libproto among them. It adds each one to
the include path. An import "tool.proto" then
resolves to the copy inside libproto automatically.
Reference shared types by their package namespace, as in
tool.ToolCallResult or
resource.Identifier.
Declare @forwardimpact/libproto as a direct dependency
only when your own .proto file imports one of these
shared files. A project that imports no shared schema needs no
dependency on it.
Update a shared schema
A shared schema is a contract across every service that imports it. So treat a change to one of these three files as a cross-service change:
-
Edit the field in the shared
.protofile. For example, add an optional field totool.ToolCallResult. -
Re-run
npx fit-codegen generate --allin every service that imports the changed file. Each one regenerates its own types, bases, clients, and metadata against the new shape. -
Keep new fields
optionalso a service that did not regenerate yet still reads messages from one that did. If you remove or renumber a field, you break every consumer at once. So add a field. Do not mutate one.
The shared files live in one package, so you edit once. The regeneration then propagates the edit to each consumer.
When proto definitions change
After you edit a .proto file, re-run the codegen:
npx fit-codegen generate --all
Every downstream artifact updates. The types in
libtype reflect the new fields. The service base class
expects the new request shape. The client sends the new format. The
MCP tool schema describes the new parameters. You synchronize
nothing by hand.
If you add a new RPC method to a service that already exists, the
generated base class gains a new stub. The stub throws
"not implemented" until you override it. The
methods that already exist stay unaffected.
If you add a new service proto, the codegen discovers it
automatically. It generates a complete set of service base, client,
and definition files. Those files go in a new subdirectory under
generated/services/.
Tips
-
Proto comments become the parameter descriptions for MCP
tools.
Add a comment above each field in your
.protofile. The comment flows through the metadata into the Zod schema's.describe()call. Agents see these descriptions when they discover your tools. -
fromObjectvsnew: Always usefromObjectto construct typed instances. It applies prototype patches, such aswithIdentifier, that the raw constructor does not. -
Incremental generation saves time. When you
iterate on a single service,
npx fit-codegen generate --clientregenerates only the clients. It does not regenerate the full suite. -
The codegen is installation-specific. Each
project runs its own
fit-codegenbecause it may define custom proto files. Published npm packages never bundle generated code.
What's next
Expose a Proto Method as an Agent Tool
A new gRPC method becomes an agent tool with one config entry. You write no glue code and no hand-written schema.
Ship a Service Endpoint
Ship a gRPC service with typed contracts, authentication, retries, and health checks. You do not reimplement the transport.
Ship an HTTP Service Endpoint
Mount Hono routes on a configured app and call start(). You get security headers, a health check, body limits, and graceful shutdown with no extra code.