Loading...
Loading...
Adding or changing routes in `apps/api`. One source of truth (`defineApiEndpoint` + a Zod schema) becomes an HTTP endpoint, an OpenAPI operation, an MCP tool, and a TS SDK method — descriptions and contracts must be written with all four readers in mind.
npx skill4agent add latitude-dev/latitude-llm api-endpointsapps/apimcp.jsonopenapi.jsonplans/mcp-oauth-api-expansion.mdapps/web/src/domains/<entity>/<entity>.functions.ts*UseCase@domain/*apps/apigetBetterAuth().api.*.functions.ts@domain/*apps/api| Surface | Generated from | Consumed by |
|---|---|---|
| HTTP route (Hono) | | curl, internal services |
| OpenAPI operation | | |
| MCP tool | | |
| SDK method | Fern reads the OpenAPI doc | end-user TypeScript code |
apps/api/src/mcp/*descriptionapps/api/src/routes/<resource>.tsimport { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"
import { defineApiEndpoint } from "../mcp/index.ts"
import { errorResponse, jsonBody, jsonResponse, openApiResponses, PROTECTED_SECURITY } from "../openapi/schemas.ts"
import type { OrganizationScopedEnv } from "../types.ts"
// Step 1: export the mount path as a const. `routes/index.ts` imports this so
// the path is declared exactly once.
export const widgetsPath = "/widgets"
// Step 2: bind the endpoint factory to the Env type AND the path.
const widgetEndpoint = defineApiEndpoint<OrganizationScopedEnv>(widgetsPath)
// Step 3: define the boundary schemas. EVERY field gets `.describe(...)` if its
// purpose isn't obvious from the name. Descriptions land in BOTH `openapi.json`
// (visible to SDK users via Fern-generated docstrings) and `mcp.json` (visible
// to AI agents listing the tools). See the schema-description rules below.
const WidgetSchema = z
.object({
id: z.string().describe("Stable identifier; safe to use as a primary key in client storage."),
name: z.string().describe("Human-readable label, unique within an organization."),
createdAt: z.string().describe("ISO-8601 timestamp of creation."),
})
.openapi("Widget") // ← this `.openapi("Name")` registers the schema as a named OpenAPI component. Different from `.openapi({ description })`.
const CreateWidgetBody = z
.object({
name: z.string().min(1).describe("Display name for the new widget. Must be non-empty."),
})
.openapi("CreateWidgetBody")
// Step 4: declare each operation with `defineApiEndpoint`.
const createWidget = widgetEndpoint({
route: createRoute({
method: "post",
path: "/",
name: "createWidget", // ← camelCase. Becomes OpenAPI `operationId` AND MCP tool name.
summary: "Create widget", // ← short label; falls through to MCP tool `title`.
description: "Creates a widget in the caller's organization. Returns the persisted record.",
tags: ["Widgets"],
security: PROTECTED_SECURITY,
request: { body: jsonBody(CreateWidgetBody) },
responses: openApiResponses({ status: 201, schema: WidgetSchema, description: "Widget created" }),
}),
handler: async (c) => {
const { name } = c.req.valid("json")
// ... use-case call ...
return c.json({ id: "...", name, createdAt: new Date().toISOString() }, 201)
},
})
const listWidgets = widgetEndpoint({
route: createRoute({
method: "get",
path: "/",
name: "listWidgets",
summary: "List widgets",
description: "Returns every widget in the caller's organization, ordered by creation date.",
tags: ["Widgets"],
security: PROTECTED_SECURITY,
responses: {
200: jsonResponse(z.object({ widgets: z.array(WidgetSchema) }).openapi("WidgetList"), "List of widgets"),
401: errorResponse("Unauthorized"),
},
}),
handler: async (c) => c.json({ widgets: [] }, 200),
})
// Step 5: export a factory that mounts every endpoint onto a fresh sub-app.
// `mountHttp` registers each tool-eligible endpoint with the MCP registry
// using the prefix baked in at factory time (step 2), so MCP picks them up.
export const createWidgetsRoutes = () => {
const app = new OpenAPIHono<OrganizationScopedEnv>()
for (const ep of [createWidget, listWidgets]) ep.mountHttp(app)
return app
}apps/api/src/routes/index.tsimport { createWidgetsRoutes, widgetsPath } from "./widgets.ts"
// ...
routes.use(widgetsPath, createTierRateLimiter("low")) // pick a tier — see below
routes.route(widgetsPath, createWidgetsRoutes())routes.route(...)mountHttpcreateWidgetsRoutes()routes.use(...)pnpm openapi:emit # rewrites apps/api/openapi.json
pnpm mcp:emit # rewrites apps/api/mcp.jsonopenapi.jsonpnpm generate:sdkapps/api/src/routes/<resource>.test.tsapp.fetch()apps/api/src/mcp/server.test.tscreateApiKeylistApiKeysrevokeApiKey@paraminputSchemaoutputSchema// Good — tells the agent what the value is FOR
name: z.string().describe("Human-readable label, unique within an organization."),
nextCursor: z
.string()
.nullable()
.describe("Opaque cursor for the next page. `null` when there are no more pages."),
// Not great — restates the field name
name: z.string().describe("The name."),
// Bad — no description at all on a non-obvious field
filters: filterSetSchema, // ← what shape? what semantics? agent has to guess..describe().meta().openapi()| API | When to use |
|---|---|
| Default for field-level descriptions. Sugar for |
| Equivalent to |
| Schema-component registration only — gives the schema a name under |
| OpenAPI-only metadata — |
.describe().meta().openapi("Name").openapi({...})format: "uri".openapi({ description }).describe()description.describe()openApiResponses({ description })description:// Bad — leaks soft-delete + retention behavior of an unrelated entity
description: "Soft-deletes a project by slug. Traces remain in storage but the project no longer appears in lists."
// Good
description: "Deletes a project by slug."
// Bad — describes the mechanism
description: "Revokes an API key by setting deletedAt and busting the Redis cache."
// Good
description: "Revokes an API key."
// Bad — leaks that we don't actually delete the row
deletedAt: z.string().nullable().describe("ISO-8601 timestamp at which the project was soft-deleted...")
// Good
deletedAt: z.string().nullable().describe("ISO-8601 timestamp at which the project was deleted...")summarycreateTierRateLimiter(tier)routes.route(prefix, …)routes.use(myPath, createTierRateLimiter("low"))
routes.route(myPath, createMyRoutes())routes.use(prefix, …)lowlow| Tier | Quota (per org / min) | Pick this when… |
|---|---|---|
| 100 | The default: id-keyed CRUD, list of bounded size, simple lookups, account/settings reads. Most endpoints land here. |
| 60 | Mutations with non-trivial side effects (publishing events, writing to multiple tables, cache invalidation that fan-outs). |
| 15 | Bulk reads with filter / search / semantic / vector load that scan large data sets per request. |
| 3 | Workflow-kicking ops: imports, exports, monitor-issue, anything that sends email or enqueues a heavy job. |
lowlowcritical/widgets/exportroutes.use(myPath, createTierRateLimiter(...))namecreateApiKeylistProjectsassignSavedSearchapiKeysListlistApiKeysdescriptionsummarytitlenametool: falseconst internalReindex = widgetEndpoint({
route: createRoute({ ... }),
handler: async (c) => { ... },
tool: false, // ← HTTP route is mounted, MCP tool is skipped
})pnpm --filter @app/api typecheck
pnpm --filter @app/api test
pnpm openapi:emit && git diff --exit-code apps/api/openapi.json # no drift
pnpm mcp:emit && git diff --exit-code apps/api/mcp.json # no driftapps/api/mcp.jsonapps/api/openapi.jsondescriptionapps/api/src/routes/index.tsroutes.route(prefix, …)routes.use(prefix, createTierRateLimiter("…"))apps/api/src/mcp/define-endpoint.tsdefineApiEndpointprefixmountHttpapps/api/src/mcp/registry.tscollectToolDescriptors()mcp:emitapps/api/src/mcp/server.tsrootApp.fetch()apps/api/scripts/emit-openapi.tsapps/api/scripts/emit-mcp.tsapps/api/src/openapi/schemas.tsapps/api/src/openapi/pagination.tsPaginated(...)c.var.authc.var.organizationsetupTestApi