Guide: Build a hybrid app (REST + MCP)

The payoff of "servers are components over one container": run a REST API and an MCP server in the same process, often backed by the same Zod schemas and the same services. One business rule, two surfaces — an HTTP endpoint for apps and an MCP tool for agents — with no duplicated logic.

Working examples: examples/hello-hybrid (pnpm -F hello-hybrid start) and examples/hello-client.

One process, two servers

import {RestApplication} from '@agentback/rest';
import {MCPComponent} from '@agentback/mcp';

const app = new RestApplication(); // binds servers.RestServer
app.component(MCPComponent); // binds servers.MCPServer

app.restController(GreetingController); // REST surface
app.service(EchoTools); // MCP surface (an MCP_SERVERS extension)

app.configure('servers.RestServer').to({port: 3000});
app.configure('servers.MCPServer').to({name: 'hello-hybrid', version: '1.0.0'});

await app.start(); // BOTH servers start; each discovers its own bindings by tag

app.start() walks the lifecycle and starts every servers.* binding. The REST server finds controller-tagged bindings; the MCP server finds mcpServer-tagged ones. They share the container, so they also share any service you bind.

Architecture diagram — text source below
diagram source (mermaid)
graph TD
  subgraph App["one RestApplication (Context)"]
    Svc["services.* (shared business logic)"]
    Ctl["controllers.* [controller]"]
    Tool["tools [mcpServer]"]
  end
  Ctl --> RS["RestServer → HTTP + /openapi.json + /explorer"]
  Tool --> MS["MCPServer → stdio + /mcp-inspector"]
  Ctl -.@inject.-> Svc
  Tool -.@inject.-> Svc

Share logic, not just process

Put the real work in a service and inject it into both a controller and a tool. The HTTP route and the MCP tool become thin adapters over one implementation:

@injectable({scope: BindingScope.SINGLETON})
class Forecaster {
  forecast(city: string) {
    return {city, forecast: 'sunny'};
  }
}

@api({basePath: '/weather'})
class WeatherController {
  constructor(@inject('services.Forecaster') private fc: Forecaster) {}
  @get('/{city}', {path: CityPath, response: Forecast})
  async get(input: {path: {city: string}}) {
    return this.fc.forecast(input.path.city);
  }
}

@mcpServer()
class WeatherTools {
  constructor(@inject('services.Forecaster') private fc: Forecaster) {}
  @tool('forecast', {input: CityIn, output: Forecast})
  forecast(input: {city: string}) {
    return this.fc.forecast(input.city);
  }
}

Both can even reuse the same response schema (Forecast) — REST validates it as an HTTP response, MCP validates it as tool output. One artifact, two boundaries; see Schema-first decorators.

Mount both UIs

import {installExplorer} from '@agentback/rest-explorer';
import {installInspector} from '@agentback/mcp-inspector';

await installExplorer(app, {title: 'Hybrid REST'}); // /explorer
await installInspector(app, {title: 'Hybrid MCP'}); // /mcp-inspector
await app.start();

Now /explorer browses the REST API and /mcp-inspector exercises the tools — both served by the same process.

A type-safe client with no codegen

Because the contract is the Zod schema, a TypeScript consumer can import the exact same schemas and get a fully typed client with no generated SDK and no spec round-trip@agentback/client.

Share the schemas

Keep schemas in a module the server and client both import. In a monorepo, export them from the server package (e.g. hello-rest/schemas) — but never from the server's main entry, or importing the schemas would also boot the server.

// schemas.ts — imported by server controllers AND client route handles
export const HelloPath = z.object({name: z.string().min(1)});
export const Greeting = z.object({greeting: z.string()});

Define route handles

import {createClient, routeGroup} from '@agentback/client';
import {Greeting, HelloPath, EchoIn, EchoOut} from 'hello-rest/schemas';

const greet = routeGroup('/greet');
const hello = greet.get('/hello/{name}', {path: HelloPath, response: Greeting});
const echo = greet.post('/echo', {body: EchoIn, response: EchoOut});

const client = createClient({baseURL: 'http://localhost:3000'});

const out = await hello.call(client, {path: {name: 'Alice'}});
//    ^^^ inferred {greeting: string}, validated against Greeting at runtime

Authenticated calls

The headers option is a (possibly async) function, so token refresh is natural:

const {token} = await login.call(anon, {
  body: {username: 'alice', roles: ['admin']},
});
const authed = createClient({
  baseURL,
  headers: () => ({authorization: `Bearer ${token}`}),
});
await me.call(authed); // sends the bearer token

Drift is caught at the call site: change a shared schema and TypeScript flags every client call that no longer matches — no regeneration step. (Non-TypeScript consumers still get /openapi.json for standard codegen.)

When to go hybrid

Reach for one process serving both when:

Keep them separate when the surfaces have independent scaling, security boundaries, or release cadences — the framework supports both; it's a binding/ component decision, not a rewrite.

Next