Concept: Schema-first decorators

This is the idea that makes REST and MCP feel like the same framework: you attach a Zod schema to a decorator, and that one schema becomes the runtime validator, the TypeScript type of your handler's input, the OpenAPI/MCP contract, and the rendered docs.

Packages: @agentback/openapi (REST verb decorators + OpenAPI emission) and @agentback/mcp (@tool/@resource/@prompt). Both follow the same shape.

One artifact, many views

Architecture diagram — text source below
diagram source (mermaid)
graph LR
  Z["z.object({ name: z.string().min(1) })"]
  Z --> V["runtime validator<br/>(rejects bad input)"]
  Z --> T["TS type<br/>z.infer&lt;typeof Schema&gt;"]
  Z --> O["OpenAPI 3.1 schema<br/>z.toJSONSchema()"]
  Z --> M["MCP inputSchema /<br/>outputSchema"]
  Z --> D["Swagger UI / MCP Inspector<br/>rendered docs"]

There is no second source of truth. Change the schema and the handler's parameter type changes (TS error if your code disagrees), the OpenAPI document changes, the MCP tool definition changes, and the validation changes — together, in one edit. This is the framework's core bet; the design thesis explains why it matters so much for AI-led and large teams.

REST: schemas on the verb decorator

import {z} from 'zod';
import {api, get, post} from '@agentback/openapi';

const HelloPath = z.object({name: z.string().min(1).max(64)});
const Greeting = z.object({greeting: z.string()});
const EchoIn = z.object({text: z.string().min(1).max(280)});
const EchoOut = z.object({echoed: z.string(), at: z.string()});

@api({basePath: '/greet'})
class GreetingController {
  @get('/hello/{name}', {path: HelloPath, response: Greeting})
  async hello(input: {path: z.infer<typeof HelloPath>}) {
    return {greeting: `Hello, ${input.path.name}!`};
  }

  @post('/echo', {body: EchoIn, response: EchoOut})
  async echo(input: {body: z.infer<typeof EchoIn>}) {
    return {echoed: input.body.text, at: new Date().toISOString()};
  }
}

The verb decorators (@get, @post, @put, @patch, @del) take a path and an options object. The schema slots are:

Option Validates Appears in OpenAPI as
path URL path parameters path parameters
query query string query parameters
headers request headers (use lowercase keys) header parameters
body request body request body
response the handler's return value the 200 (or status) response
status — (a number) overrides the default 200; 204 sends no body

MCP: schemas on @tool

import {z} from 'zod';
import {mcpServer, tool} from '@agentback/mcp';

const ForecastIn = z.object({
  city: z.string(),
  days: z.number().int().min(1).max(7),
});
const ForecastOut = z.object({city: z.string(), forecast: z.string()});

@mcpServer()
class WeatherTools {
  @tool('get_forecast', {
    description: 'Forecast for a city',
    input: ForecastIn,
    output: ForecastOut,
  })
  async getForecast(input: z.infer<typeof ForecastIn>) {
    return {city: input.city, forecast: 'sunny'};
  }
}

@tool's input/output play the same role as REST's body/response: the input is validated and typed via z.infer; if output is declared, the return type is constrained at compile time, validated at runtime, and handed to the MCP SDK so structured-content clients consume it directly.

The handler signature

This is the one rule worth memorizing, and it's identical in spirit for REST and MCP.

Slot 0 = the validated input bundle (when any schema is declared)

For REST, slot 0 is an object with only the keys you declared:

@post('/things/{id}', {path: IdPath, body: ThingBody, query: ThingQuery})
async create(input: {
  path: z.infer<typeof IdPath>;
  body: z.infer<typeof ThingBody>;
  query: z.infer<typeof ThingQuery>;
}) { … }

For MCP, slot 0 is the inferred input directly:

@tool('add', {input: AddIn})
async add(input: z.infer<typeof AddIn>) { … }

The decorator's typing enforces this at compile time. If your parameter type disagrees with the declared schemas, you get a TypeScript error at the decorator line, with the mismatch surfaced precisely.

Slot 0 is yours when no schemas are declared

@get('/whoami')
async whoami(@inject(SecurityBindings.USER) user: UserProfile) { … } // valid

@tool('ping')
async ping() { … } // valid

@inject lives at slot 1+

When you declare schemas, dependencies inject at the second parameter onward — slot 0 is reserved for the input bundle:

@post('/echo', {body: EchoIn, response: EchoOut})
async echo(
  input: {body: z.infer<typeof EchoIn>},        // slot 0
  @inject('services.Clock') clock: Clock,        // slot 1+
) {
  return {echoed: input.body.text, at: clock.now()};
}

Putting @inject at slot 0 alongside a schema throws at decoration time with the offending class+method named.

Validation, in and out

Startup checks

Some coherence is verified when app.start() runs, not at request time:

This turns a class of "wrong at runtime, in production" bugs into "wrong at boot, in the first test."

Where it's wired (for the curious)

You don't need to touch those to use the framework, but it's a short read if you want to extend the dispatcher — see Composition & extensibility.

Next