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, and the client sends the old format.
Four libraries -- @forwardimpact/libcodegen,
@forwardimpact/libtype,
@forwardimpact/librpc, and
@forwardimpact/libmcp -- eliminate that drift. Define
the contract once in proto, run fit-codegen, and the
generated artifacts keep every layer consistent: JavaScript types,
typed clients, service base classes, gRPC definitions, and MCP tool
schemas.
This guide walks through the full pipeline, from writing a proto definition to calling the generated service from both gRPC clients and 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, each gated by a flag:
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 --
changes are overwritten on the next run.
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
Expected output (file count varies with 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 without rebuilding service bases or definitions.
Step 3: Use generated types
@forwardimpact/libtype re-exports everything from
generated/types/types.js. Types are organized 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 |
Types with a resource.Identifier field (like
common.Message and tool.ToolFunction) also
receive a withIdentifier prototype method that
auto-populates id.type, id.name, and
id.tokens from the instance content.
Built-in namespaces
Alongside the namespace generated from your own proto package (inventory
above), @forwardimpact/libtype 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 referenced by messages and
tool calls
|
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";
The common, resource, vector,
graph, and span namespaces are reused
across services, so a graph service constructs a
graph.SubjectsQuery and a vector service constructs a
vector request without redefining those shapes.
The metadata export
@forwardimpact/libtype also exports a
metadata object built by codegen from the same proto
files. It is the bridge that lets libmcp register tools
with no hand-written schema:
import { metadata } from "@forwardimpact/libtype";
metadata is keyed by package.Service, then
by method name. Each method entry records the request type and a
description of every field -- its proto type, whether it is
repeated, 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, and librpc clients use the type classes it
points to. Regenerating with
npx fit-codegen generate --metadata keeps it in step
with the proto files. The shared common.proto is
excluded from metadata -- 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 incoming requests using
fromObject and verify before your method
receives them. Each method receives a typed request instance and
returns a response matching 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 carrying the required runtime (and optional
logger / tracer). It adds authentication
(HMAC via the SERVICE_SECRET environment variable),
keepalive configuration, graceful shutdown on
SIGINT/SIGTERM, and 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, converts it with toObject for the
wire format, and converts the response back using
fromObject. Retries are built in (up to 10 retries on
transient errors, exponential backoff starting at 1 second with
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. No glue code, 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}, matching 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, and system fields like filter and
anthropic_api_key are excluded 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, constructs a typed request via fromObject,
calls the gRPC method through the client, and 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
Some message types are needed by more than one service -- a
tool-call envelope, a resource identifier, a token-usage record.
Rather than 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 -- your project root and every installed
@forwardimpact/* package, including
@forwardimpact/libproto -- and 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 has not regenerated yet still reads messages from one that has. Removing or renumbering a field breaks every consumer at once, so add rather than mutate.
Because the shared files live in one package, the edit happens once; the regeneration is what propagates it to each consumer.
When proto definitions change
After editing 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, and
the MCP tool schema describes the new parameters. No manual
synchronization required.
If you add a new RPC method to an existing service, the generated
base class gains a new stub that throws
"not implemented" until you override it.
Existing methods are unaffected.
If you add a new service proto, the codegen discovers it
automatically and generates a complete set of service base, client,
and definition files in a new subdirectory under
generated/services/.
Tips
-
Proto comments become MCP tool parameter descriptions.
Add a comment above each field in your
.protofile and it 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 (likewithIdentifier) that the raw constructor does not. -
Incremental generation saves time. When iterating
on a single service,
npx fit-codegen generate --clientregenerates only clients instead of the full suite. -
The codegen is installation-specific. Each
project runs its own
fit-codegenbecause it may define custom proto files. Generated code is never bundled in published npm packages.
What's next
Expose a Proto Method as an Agent Tool
A new gRPC method becomes an agent tool with one config entry — no glue code, no hand-written schema.
Ship a Service Endpoint
Ship a gRPC service with typed contracts, authentication, retries, and health checks — without reimplementing transport.
Ship an HTTP Service Endpoint
Mount Hono routes on a configured app and call start() — security headers, a health check, body limits, and graceful shutdown come for free.