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:

  1. Edit the field in the shared .proto file. For example, add an optional field to tool.ToolCallResult.
  2. Re-run npx fit-codegen generate --all in every service that imports the changed file. Each one regenerates its own types, bases, clients, and metadata against the new shape.
  3. Keep new fields optional so 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 .proto file. The comment flows through the metadata into the Zod schema's .describe() call. Agents see these descriptions when they discover your tools.
  • fromObject vs new: Always use fromObject to construct typed instances. It applies prototype patches, such as withIdentifier, that the raw constructor does not.
  • Incremental generation saves time. When you iterate on a single service, npx fit-codegen generate --client regenerates only the clients. It does not regenerate the full suite.
  • The codegen is installation-specific. Each project runs its own fit-codegen because it may define custom proto files. Published npm packages never bundle generated code.

What's next