← All posts

One tool surface for humans, agents, and MCP - generated, not maintained

How Rezee exposes the same API to its UI, external agents over MCP, and first-party agents - 65 MCP tools mechanically derived from the same TypeBox route schemas, so the tool an agent calls and the endpoint a human hits are the same code.

Jun 4, 2026 · 8 min read · Kash Gohil

Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs - built for teams and their agents. Agents act through tools, and the tempting mistake is to build a second API for them: a hand-written set of "agent tools" that drifts from the real endpoints the moment either changes. Rezee doesn't have an agent API. It has one API, and the MCP tool surface is generated from it - the same TypeBox route schemas that define the REST endpoints, validate their inputs, and produce the OpenAPI spec. This post proves that by generating the tools from makima's actual spec and showing the derivation is mechanical.

Why one surface instead of an agent API?

Because two surfaces is a synchronization bug waiting to happen. If an agent's create_issue tool is defined separately from the POST /issues endpoint, then the day someone adds a required field to issues, the endpoint enforces it and the tool doesn't - and the agent starts failing in a way no test covers, because the tool's schema said the old shape was fine. The only way to keep an agent tool honest is to make it the same object as the endpoint, not a copy that agrees with it today.

Rezee's routes are Elysia handlers with TypeBox schemas. That one schema already does triple duty: it validates requests at runtime, it types the handler at compile time, and @elysiajs/openapi derives the OpenAPI spec from it. Adding MCP is adding a fourth consumer of the same schema, not a new schema.

Show me the derivation

Here is the real create-issue route in makima (services/makima/src/modules/issues/index.ts), TypeBox and all:

body: t.Object({
	title: t.String({ minLength: 1, maxLength: 255 }),
	body: t.Optional(t.String()),
	status: t.Optional(
		t.Union([
			t.Literal("backlog"),
			t.Literal("todo"),
			t.Literal("in_progress"),
			t.Literal("done"),
			t.Literal("cancelled"),
		]),
	),
	assignee: t.Optional(t.String()),
	repo: t.Optional(t.String()),
}),
detail: { tags: ["Issues"], summary: "Create a workspace issue" },

And here is the MCP tool our generator emits for it, run against makima's live OpenAPI spec (scripts/bench/mcp-surface):

{
  "name": "post_workspaces_by_ws_issues",
  "description": "Create a workspace issue",
  "scope": "issues:write",
  "inputSchema": {
    "type": "object",
    "properties": {
      "ws": { "type": "string" },
      "title": { "minLength": 1, "maxLength": 255, "type": "string" },
      "status": {
        "type": "string",
        "enum": ["backlog", "todo", "in_progress", "done", "cancelled"]
      },
      "assignee": { "type": "string" },
      "repo": { "type": "string" }
    },
    "required": ["ws", "title"]
  }
}

Look at what carried through untouched: the title length bounds, the required set, and the status enum - the exact five statuses, in order. Nobody typed that enum into a tool definition. It is the same t.Union of literals the REST endpoint validates against, because the tool's inputSchema is that schema, walked out of the OpenAPI operation the schema produced. When someone adds a sixth status, the endpoint, the OpenAPI docs, and the agent's tool all learn about it in the same commit, because there is only one place to change.

How much of the API becomes tools?

We ran the generator against the real spec: 83 API operations became 65 agent tools. The 18 that didn't are the ones that shouldn't: the Internal tag (secret-gated service-to-service routes - denji and kobeni calling makima, never an agent), Auth (agents don't register or log in - they carry tokens), and Meta (health checks). Everything a person can do to issues, PRs, code, pipelines, chat, and docs, an agent can drive through the identical handler.

65 agent tools, derived from the REST schema 65 agent tools, derived from the REST schema write toolsread tools 0510 10346433324235013121212 reposissuesPRswschatdocspipelineswebhookstokenssshsecrets tools

The read/write split in that chart isn't decorative - it's the second thing the generator derives. Every tool gets a scope inferred from its method and resource: a GET on issues is issues:read, a POST is issues:write. Those are the exact capability strings an agent's token is scoped to, so "this agent may comment on issues but not merge PRs" is expressible because the tool surface and the permission surface are generated from the same operations. One derivation, two guarantees.

The trace an external agent and a first-party agent leave

The strongest version of "one surface" is that the two kinds of agent are indistinguishable below the tool call. When someone's own Claude drives Rezee over MCP, the MCP server receives post_workspaces_by_ws_issues, validates the arguments against the generated inputSchema, and calls POST /api/workspaces/{ws}/issues with a scoped token. When our first-party beam runtime does the same task, it calls the same endpoint with its own scoped token. Both hit the same Elysia handler, the same TypeBox validation, the same authorization check, the same row insert:

external agent ─(MCP)→  mcp server ─┐
                                    ├─→  POST /api/.../issues  →  handler + TypeBox + authz
beam (first-party) ─────────────────┘

There is no privileged internal path - no db.insert shortcut that first-party agents get and external ones don't - because a shortcut is exactly how the two surfaces would drift. The MCP server is a thin adapter (protocol framing, tool listing, argument marshaling); the tools it lists are generated; the calls it makes are the public API. An external agent is not a second-class citizen, and a first-party agent is not a privileged one. They are the same citizen with different return addresses.

What doesn't derive cleanly?

Honesty about the seams, because "just generate it" hides real work:

  • Tool names. We derive names from each route's operationId (Elysia emits a unique one per route), which gives stable, collision-free names but ugly ones - post_workspaces_by_ws_issues, not create_issue. A production tool surface wants human-legible names and descriptions written for the model, which is the one piece of curation on top of the generation. The schema is generated; the prose is authored.
  • Path parameters leak REST-ness. ws (workspace slug) shows up as a required tool argument because it's a path param. An agent shouldn't have to think about URL structure - a good MCP layer injects the workspace from the token's context rather than making the model pass it. That's a deliberate override on top of the mechanical derivation, not a failure of it.
  • Multi-call workflows aren't single tools. "Open a PR that closes issue ACME-12" is two endpoints; the generator makes two tools, and stitching them is the agent's job (or a composed higher-level tool we write by hand). Generation gives you the primitives, not the workflows.
  • Not every endpoint should be a tool even after filtering. Some reads are noisy for a model (paginated firehoses); some writes are foot-guns. The generator's output is a starting set, curated down, not shipped raw. The win is that curation starts from a correct, complete list instead of a hand-built partial one.

FAQ

Isn't generating tools from OpenAPI a well-known pattern?

The pattern is known; the discipline is the point. Plenty of systems have an OpenAPI spec and a separately-maintained set of agent tools, and they drift. The claim here isn't novelty - it's that the tool surface has no independent existence: it's a pure function of the route schemas, regenerated from the spec, so it cannot describe an endpoint that doesn't exist or miss a field that does. The value is the absence of a second source of truth, not the presence of a generator.

Why expose 65 tools instead of a few high-level ones?

The generated set is the complete, correct floor; the curated set is built by trimming and composing from it. Starting from "every endpoint, mechanically" means the curation question is "which of these should an agent not have," which is answerable, rather than "what did I forget to expose," which isn't. A few high-level tools ship on top, hand-written for legibility - but on a foundation that can't silently diverge from the API.

Do scopes actually stop a tool call, or just label it?

They stop it, at the same authorize layer every git and API request already passes through. The generated scope is enforced per call against the agent's token - a chat:read-only token calling post_workspaces_by_ws_issues is rejected before the handler runs. The generation gives every tool a scope; the scoped-tokens post is how that scope is checked.