api-endpoints

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Adding API endpoints

添加API端点

When to use: Adding a new endpoint to
apps/api
, changing an existing one, or wondering why
mcp.json
/
openapi.json
/ the SDK aren't in sync.
适用场景:
apps/api
添加新端点、修改现有端点,或排查
mcp.json
/
openapi.json
/SDK不同步的问题。

Before you start — reuse the UI's logic via the domain layer

开始之前——通过领域层复用UI逻辑

When you add a new API endpoint, check whether the same action or read is already available in the web UI. The plan only ships a specific list of API endpoints (see the inventory in
plans/mcp-oauth-api-expansion.md
); the goal isn't full surface parity, it's not duplicating logic that the web already implements.
For each new endpoint, open
apps/web/src/domains/<entity>/<entity>.functions.ts
. Three cases:
  • The web's server fn already calls a domain use-case (imports
    *UseCase
    from
    @domain/*
    ): reuse that use-case in the API route handler. Don't reimplement the logic in
    apps/api
    .
  • The web's server fn has the logic inline (raw repository calls, validation, side effects in the server fn body itself): extract it into a new domain use-case first, then have both the web server fn AND your API route call it. The domain use-case becomes the shared seam.
  • The web's server fn delegates to a third-party API like
    getBetterAuth().api.*
    : the API process can't reach the same in-process instance. Write a domain use-case that replicates that behavior (carefully — read the third-party source so your use-case matches its rules), then point both the web and API at the use-case. Adds parity tests so the migration doesn't silently drift.
The domain use-case is the shared seam between web and API. Duplicating logic in both surfaces creates drift — one gets a bug fix the other doesn't.
If the entity doesn't have a
.functions.ts
because the UI doesn't expose this action yet, you're designing fresh. That's fine; just don't lose the option to share later — put the business logic in a
@domain/*
use-case from the start rather than inline in the route handler.
添加新API端点时,请检查Web UI中是否已实现相同的操作或读取逻辑。我们仅提供特定列表的API端点(详见
plans/mcp-oauth-api-expansion.md
中的清单);目标并非实现功能完全对等,而是避免重复Web已有的逻辑。
对于每个新端点,请打开**
apps/web/src/domains/<entity>/<entity>.functions.ts
**。分为三种情况:
  • Web的服务器函数已调用领域用例(从
    @domain/*
    导入
    *UseCase
    ):在API路由处理器中复用该用例,不要在
    apps/api
    中重新实现逻辑。
  • Web的服务器函数逻辑内联(服务器函数体中直接包含原始仓库调用、校验、副作用):先将其提取为新的领域用例,然后让Web服务器函数和你的API路由都调用该用例。领域用例将成为共享的衔接层。
  • Web的服务器函数委托给第三方API(如
    getBetterAuth().api.*
    ):API进程无法访问相同的进程内实例。编写一个领域用例来复现该行为(仔细阅读第三方源码,确保你的用例符合其规则),然后让Web和API都指向该用例。添加对等测试,防止迁移过程中出现隐性偏差。
领域用例是Web与API之间的共享衔接层。在两个层面重复逻辑会导致偏差——其中一个层面修复了bug,另一个层面却没有。
如果该实体没有
.functions.ts
文件(因为UI尚未暴露此操作),则你需要从头设计。这没问题;但不要放弃后续共享的可能性——从一开始就将业务逻辑放在
@domain/*
用例中,而不是内联在路由处理器中。

What you're really doing

你实际在做什么

Every endpoint in
apps/api
is one declaration that fans out four ways:
SurfaceGenerated fromConsumed by
HTTP route (Hono)
route.method
+
route.path
+ handler
curl, internal services
OpenAPI operation
route.name
(→
operationId
),
route.description
, request/response schemas
apps/api/openapi.json
→ Fern → TS SDK (
@latitude/sdk-typescript
)
MCP tool
route.name
,
route.description
, flattened input + 2xx-JSON output schema
apps/api/mcp.json
, runtime
/v1/mcp
transport
SDK methodFern reads the OpenAPI docend-user TypeScript code
You don't write three configs. You write one. The infra in
apps/api/src/mcp/*
derives the other surfaces.
This means: the descriptions you put on routes and on schema fields are read by SDK users AND by AI agents calling the MCP. Treat every
description
as user-facing copy. Vague or absent descriptions are bugs.
apps/api
中的每个端点都是一份声明,衍生出四种呈现形式
呈现形式生成来源使用者
HTTP路由(Hono)
route.method
+
route.path
+ 处理器
curl、内部服务
OpenAPI操作
route.name
(→
operationId
)、
route.description
、请求/响应schema
apps/api/openapi.json
→ Fern → TS SDK(
@latitude/sdk-typescript
MCP工具
route.name
route.description
、扁平化输入+2xx-JSON输出schema
apps/api/mcp.json
、运行时
/v1/mcp
传输层
SDK方法Fern读取OpenAPI文档终端用户TypeScript代码
你无需编写三份配置,只需编写一份。
apps/api/src/mcp/*
中的基础设施会衍生出其他呈现形式。
这意味着:你在路由和schema字段上添加的描述会被SDK用户和调用MCP的AI Agent读取。将每个
description
视为面向用户的文案。模糊或缺失的描述属于bug。

Recipe: add a new route file

步骤:添加新路由文件

1. Create
apps/api/src/routes/<resource>.ts

1. 创建
apps/api/src/routes/<resource>.ts

ts
import { 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
}
ts
import { 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
}

2. Wire it up in
apps/api/src/routes/index.ts

2. 在
apps/api/src/routes/index.ts
中配置

ts
import { createWidgetsRoutes, widgetsPath } from "./widgets.ts"
// ...
routes.use(widgetsPath, createTierRateLimiter("low"))   // pick a tier — see below
routes.route(widgetsPath, createWidgetsRoutes())
The
routes.route(...)
call is plain Hono mount — MCP registration is a side effect of
mountHttp
inside
createWidgetsRoutes()
. The
routes.use(...)
line is the rate-limit tier — don't skip it (see "Rate limiting" below).
ts
import { createWidgetsRoutes, widgetsPath } from "./widgets.ts"
// ...
routes.use(widgetsPath, createTierRateLimiter("low"))   // pick a tier — see below
routes.route(widgetsPath, createWidgetsRoutes())
routes.route(...)
调用是标准的Hono挂载操作——MCP注册是
createWidgetsRoutes()
内部
mountHttp
的副作用。
routes.use(...)
行是限流层级——不要跳过这一步(详见下方“限流”部分)。

3. Regenerate manifests

3. 重新生成清单

bash
pnpm openapi:emit   # rewrites apps/api/openapi.json
pnpm mcp:emit       # rewrites apps/api/mcp.json
Both files are checked in. CI guards against drift, so commit them alongside the route file.
The TS SDK regenerates from
openapi.json
via Fern (
pnpm generate:sdk
). Run it locally if your PR is supposed to expose the new method through the SDK — otherwise the next SDK release picks it up.
bash
pnpm openapi:emit   # rewrites apps/api/openapi.json
pnpm mcp:emit       # rewrites apps/api/mcp.json
这两个文件都需要提交到版本库。CI会检查是否存在偏差,因此请将它们与路由文件一起提交。
TS SDK会通过Fern从
openapi.json
重新生成(
pnpm generate:sdk
)。如果你的PR需要通过SDK暴露新方法,请在本地运行该命令;否则下一次SDK发布时会自动包含该方法。

4. Tests

4. 测试

  • HTTP-level integration tests live in
    apps/api/src/routes/<resource>.test.ts
    . Test through
    app.fetch()
    so middleware runs end-to-end.
  • MCP-level integration tests for new tools live in
    apps/api/src/mcp/server.test.ts
    (see the existing
    createApiKey
    /
    listApiKeys
    /
    revokeApiKey
    cases). Add a case there if the route exposes behavior worth pinning at the MCP layer too.
  • HTTP级别的集成测试位于
    apps/api/src/routes/<resource>.test.ts
    。通过
    app.fetch()
    进行测试,确保中间件端到端运行。
  • 新工具的MCP级集成测试位于
    apps/api/src/mcp/server.test.ts
    (参考现有
    createApiKey
    /
    listApiKeys
    /
    revokeApiKey
    案例)。如果路由暴露了值得在MCP层面固定的行为,请在此处添加案例。

Schema descriptions — the rule that matters most

Schema描述——最重要的规则

Every field in every request/response schema needs a description unless the field name is self-explanatory. Descriptions reach two distinct audiences:
  • SDK users read them as TypeScript JSDoc on the generated SDK methods (Fern emits them as
    @param
    / property comments).
  • AI agents read them via the MCP tool's
    inputSchema
    /
    outputSchema
    to decide what to put in a tool call.
Write each description as one short sentence in present tense, like a microcopy label. Examples:
ts
// 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.
每个请求/响应schema中的字段都需要添加描述,除非字段名称本身一目了然。描述会触达两类不同的受众:
  • SDK用户会在生成的SDK方法的TypeScript JSDoc中看到它们(Fern会将其生成为
    @param
    /属性注释)。
  • AI Agent会通过MCP工具的
    inputSchema
    /
    outputSchema
    读取它们,以决定在工具调用中传入什么内容。
将每个描述写成一句简短的现在时态句子,类似微文案标签。示例:
ts
// 良好——告知Agent该值的用途
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."),

// 欠佳——重复字段名称
name: z.string().describe("The name."),

// 糟糕——非显式字段完全没有描述
filters: filterSetSchema, // ← 是什么结构?什么语义?Agent只能猜测。

.describe()
vs
.meta()
vs
.openapi()

.describe()
vs
.meta()
vs
.openapi()

APIWhen to use
.describe("…")
Default for field-level descriptions. Sugar for
.meta({ description })
. Visible to OpenAPI AND MCP.
.meta({ description, examples, default, ... })
Equivalent to
.describe()
plus JSON-Schema-standard fields (
examples
,
default
,
title
). Visible to both surfaces.
.openapi("Name")
Schema-component registration only — gives the schema a name under
components.schemas
in OpenAPI. Required for Fern to emit reusable types. Has nothing to do with descriptions.
.openapi({ description, format, example, ... })
OpenAPI-only metadata
format
,
example
,
param: { in, name }
, etc. Lives in the openapi-extension WeakMap and does not propagate to MCP. Avoid for descriptions; use only for things that have no Zod-native equivalent.
TL;DR: prefer
.describe()
/
.meta()
. Use
.openapi("Name")
to register named schema components. Reach for
.openapi({...})
for fields ONLY when you need an OpenAPI-only knob like
format: "uri"
.
If you find yourself writing
.openapi({ description })
, replace it with
.describe()
— descriptions hidden in the openapi WeakMap are invisible to MCP clients, which silently degrades agent UX.
API使用场景
.describe("…")
字段级描述的默认方式。是
.meta({ description })
的语法糖。对OpenAPI和MCP均可见。
.meta({ description, examples, default, ... })
等同于
.describe()
加上JSON Schema标准字段(
examples
default
title
)。对两个层面均可见。
.openapi("Name")
仅用于Schema组件注册——在OpenAPI的
components.schemas
下为schema命名。Fern生成可复用类型时需要此操作。与描述无关。
.openapi({ description, format, example, ... })
仅OpenAPI元数据——
format
example
param: { in, name }
等。存储在openapi-extension WeakMap中,不会传播到MCP。描述避免使用此方法;仅当需要Zod原生不支持的OpenAPI专属配置时才使用。
总结:优先使用
.describe()
/
.meta()
。使用
.openapi("Name")
注册命名Schema组件。仅当需要
format: "uri"
这类OpenAPI专属配置时,才为字段使用
.openapi({...})
如果你发现自己在写
.openapi({ description })
,请替换为
.describe()
——隐藏在openapi WeakMap中的描述对MCP客户端不可见,会悄无声息地降低Agent的用户体验。

Don't leak internal implementation into descriptions

不要在描述中泄露内部实现细节

User-facing descriptions (route
description
, schema
.describe()
,
openApiResponses({ description })
) are read by SDK users and AI agents. They aren't release notes for our backend. Keep them about the contract, not how we implement it.
Concretely, avoid:
  • Storage mechanics: "soft-deletes", "hard-deletes", "marks as deleted", "removes from cache", "writes to outbox", "RLS-scoped", "via the admin connection". Just say "deletes" / "revokes" / "creates".
  • Side-effect details on related data: "Traces remain in storage but the project no longer appears in lists.", "The associated rows are kept for auditing." If the caller can't observe it through the API, don't mention it.
  • Internal table or column names, queue names, worker names, event-bus topics.
  • Comments about why the code is structured a certain way — those belong in code comments, not in
    description:
    .
Examples:
ts
// 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...")
Same rule for the verbs used in route/operation
summary
: "Delete project" beats "Soft-delete project".
面向用户的描述(路由
description
、schema
.describe()
openApiResponses({ description })
)会被SDK用户和AI Agent读取。它们不是后端的发布说明。请聚焦于契约,而非实现方式。
具体而言,避免:
  • 存储机制:“软删除”、“硬删除”、“标记为已删除”、“从缓存移除”、“写入发件箱”、“RLS范围”、“通过管理员连接”。只需说“删除”/“撤销”/“创建”。
  • 相关数据的副作用细节:“跟踪记录仍保留在存储中,但项目不再显示在列表中”、“关联行保留用于审计”。如果调用者无法通过API观察到,就不要提及。
  • 内部表或列名、队列名、工作器名、事件总线主题。
  • 关于代码结构原因的注释——这些属于代码注释,而非
    description:
    中的内容。
示例:
ts
// 糟糕——泄露软删除及无关实体的保留行为
description: "Soft-deletes a project by slug. Traces remain in storage but the project no longer appears in lists."
// 良好
description: "Deletes a project by slug."

// 糟糕——描述实现机制
description: "Revokes an API key by setting deletedAt and busting the Redis cache."
// 良好
description: "Revokes an API key."

// 糟糕——泄露我们并未实际删除行的事实
deletedAt: z.string().nullable().describe("ISO-8601 timestamp at which the project was soft-deleted...")
// 良好
deletedAt: z.string().nullable().describe("ISO-8601 timestamp at which the project was deleted...")
路由/操作
summary
中使用的动词也遵循相同规则:“Delete project”优于“Soft-delete project”。

Rate limiting — every new endpoint group needs a tier

限流——每个新端点组都需要指定层级

createTierRateLimiter(tier)
is keyed on the authenticated org id (not IP), so one tenant's traffic doesn't eat another's quota. Apply it at the parent router, before the matching
routes.route(prefix, …)
call:
ts
routes.use(myPath, createTierRateLimiter("low"))
routes.route(myPath, createMyRoutes())
One tier per sub-app — Hono's
routes.use(prefix, …)
covers everything mounted under that prefix. Method-level granularity is possible (apply the limiter inside the route file per endpoint) but rarely worth the code spread.
createTierRateLimiter(tier)
基于已认证的组织ID(而非IP)进行限流,因此一个租户的流量不会占用另一个租户的配额。在父路由上应用它,在匹配的
routes.route(prefix, …)
调用之前
ts
routes.use(myPath, createTierRateLimiter("low"))
routes.route(myPath, createMyRoutes())
每个子应用对应一个层级——Hono的
routes.use(prefix, …)
会覆盖该前缀下挂载的所有内容。可以实现方法级别的粒度(在路由文件中为每个端点应用限流),但通常不值得分散代码。

Picking a tier

选择层级

Default to
low
. Most CRUD endpoints don't need more —
low
is 100 req/min/org, which comfortably covers SDK polling, MCP tool calls, and human-driven dashboards. Step up only when the endpoint genuinely warrants tighter limits.
TierQuota (per org / min)Pick this when…
low
100The default: id-keyed CRUD, list of bounded size, simple lookups, account/settings reads. Most endpoints land here.
medium
60Mutations with non-trivial side effects (publishing events, writing to multiple tables, cache invalidation that fan-outs).
high
15Bulk reads with filter / search / semantic / vector load that scan large data sets per request.
critical
3Workflow-kicking ops: imports, exports, monitor-issue, anything that sends email or enqueues a heavy job.
Don't be harsh. A tighter tier doesn't make the API safer in any meaningful way for cheap endpoints — it just frustrates legitimate callers. When in doubt, pick
low
and bump it later if a specific endpoint shows up in incident traffic.
默认使用
low
。大多数CRUD端点不需要更高层级——
low
为每个组织每分钟100次请求,足以覆盖SDK轮询、MCP工具调用和人工驱动的仪表板。仅当端点确实需要更严格的限制时才升级层级。
层级配额(每组织/每分钟)适用场景
low
100默认值:基于ID的CRUD操作、有限大小的列表查询、简单查找、账户/设置读取。大多数端点属于此类。
medium
60带有非平凡副作用的变更操作(发布事件、写入多个表、扇出式缓存失效)。
high
15扫描大型数据集的批量读取操作(过滤/搜索/语义/向量加载)。
critical
3触发工作流的操作:导入、导出、监控告警、发送邮件或入队重型任务的任何操作。
不要过于严格。对于低成本端点,更严格的层级并不会显著提升API安全性——只会让合法调用者感到沮丧。如有疑问,选择
low
,如果特定端点在事件流量中出现问题,再后续调整。

What if a sub-app mixes cheap and expensive endpoints?

如果子应用混合了低成本和高成本端点怎么办?

Size to the heaviest endpoint in the group, OR split the sub-app. A pure-CRUD sub-app with one expensive export endpoint deserves
low
for the CRUD and
critical
for the export — easiest to express by giving the export its own sub-app (
/widgets/export
).
以组中最消耗资源的端点为准,或者拆分该子应用。一个纯CRUD子应用包含一个高成本导出端点时,CRUD操作应使用
low
,导出操作应使用
critical
——最简单的方式是为导出操作创建独立的子应用(
/widgets/export
)。

Always add the line

务必添加该行代码

This is in the recipe (step 2) for a reason: shipping a route group without a tier means it inherits no per-route limit at all — only the global IP-keyed brute-force guard. That's an easy oversight in PR review. The
routes.use(myPath, createTierRateLimiter(...))
line is part of "wiring up", not optional.
这一步包含在步骤(步骤2)中是有原因的:发布未指定层级的路由组意味着它没有继承任何路由级限制——仅存在全局基于IP的暴力防护。这在PR审查中很容易被忽略。
routes.use(myPath, createTierRateLimiter(...))
行属于“配置”的一部分,并非可选。

Choosing route names and shapes

选择路由名称和结构

  • name
    is camelCase, verb-first, and reads like an SDK method:
    createApiKey
    ,
    listProjects
    ,
    assignSavedSearch
    . Avoid resource-prefixed names that read awkwardly as SDK calls (
    apiKeysList
    → use
    listApiKeys
    ).
  • description
    on the route is the single-line tool/method blurb. Treat it as the first sentence an SDK user or AI agent sees when discovering the operation.
  • summary
    is optional, shorter, and becomes the MCP tool
    title
    . Falls back to
    name
    when omitted.
  • **
    name
    **采用小驼峰式,以动词开头,读起来像SDK方法:
    createApiKey
    listProjects
    assignSavedSearch
    。避免以资源为前缀的名称,因为作为SDK调用时读起来很别扭(
    apiKeysList
    → 使用
    listApiKeys
    )。
  • **
    description
    **是路由的单行工具/方法简介。将其视为SDK用户或AI Agent发现该操作时看到的第一句话。
  • **
    summary
    **是可选的,更简短,会成为MCP工具的
    title
    。如果省略,会回退到
    name

Opting out of MCP per-route

按路由选择退出MCP

Some routes shouldn't be tools — they make sense for HTTP/SDK clients but not for AI agents (e.g. internal lifecycle endpoints, web-only callbacks). Pass
tool: false
:
ts
const internalReindex = widgetEndpoint({
  route: createRoute({ ... }),
  handler: async (c) => { ... },
  tool: false, // ← HTTP route is mounted, MCP tool is skipped
})
某些路由不应作为工具——它们对HTTP/SDK客户端有意义,但对AI Agent无意义(例如内部生命周期端点、仅Web回调)。传递
tool: false
ts
const internalReindex = widgetEndpoint({
  route: createRoute({ ... }),
  handler: async (c) => { ... },
  tool: false, // ← 挂载HTTP路由,跳过MCP工具注册
})

Verification checklist

验证清单

Run before opening the PR:
bash
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 drift
Spot-check both manifests by hand: open
apps/api/mcp.json
and
apps/api/openapi.json
, find your operation, confirm every field has a
description
. If something is missing, it'll silently degrade SDK docs and agent UX — fix it at the Zod schema, not in the JSON output.
And glance at
apps/api/src/routes/index.ts
next to your
routes.route(prefix, …)
line — there should be a
routes.use(prefix, createTierRateLimiter("…"))
on the line above. If it's missing, your endpoints are uncapped per-org.
打开PR前运行以下命令:
bash
pnpm --filter @app/api typecheck
pnpm --filter @app/api test
pnpm openapi:emit && git diff --exit-code apps/api/openapi.json   # 无偏差
pnpm mcp:emit && git diff --exit-code apps/api/mcp.json           # 无偏差
手动抽查两个清单:打开
apps/api/mcp.json
apps/api/openapi.json
,找到你的操作,确认每个字段都有
description
。如果有缺失,会悄无声息地降低SDK文档质量和Agent用户体验——请在Zod schema中修复,而非修改JSON输出。
同时查看
apps/api/src/routes/index.ts
中你的
routes.route(prefix, …)
行上方是否有
routes.use(prefix, createTierRateLimiter("…"))
行。如果缺失,你的端点没有按组织设置限流上限。

Where the machinery lives

机制所在位置

If you need to debug the auto-generation pipeline:
  • apps/api/src/mcp/define-endpoint.ts
    defineApiEndpoint
    factory; baked-in
    prefix
    ,
    mountHttp
    registers with the MCP registry on tool-eligible mounts.
  • apps/api/src/mcp/registry.ts
    — module-global endpoint registry;
    collectToolDescriptors()
    emits the snapshot used by both the runtime MCP transport and
    mcp:emit
    .
  • apps/api/src/mcp/server.ts
    — per-request MCP server, dispatches each tool call back through
    rootApp.fetch()
    so the full middleware chain (auth, rate-limit, org-context, validation) re-runs on every inner call.
  • apps/api/scripts/emit-openapi.ts
    /
    apps/api/scripts/emit-mcp.ts
    — boot the route registry with stub clients and serialize the manifests.
  • apps/api/src/openapi/schemas.ts
    and
    apps/api/src/openapi/pagination.ts
    — shared boundary primitives (security scheme,
    Paginated(...)
    , common param schemas).
如果需要调试自动生成流程:
  • apps/api/src/mcp/define-endpoint.ts
    ——
    defineApiEndpoint
    工厂;内置
    prefix
    mountHttp
    会在符合工具条件的挂载时向MCP注册表注册端点。
  • apps/api/src/mcp/registry.ts
    ——模块级全局端点注册表;
    collectToolDescriptors()
    会生成运行时MCP传输层和
    mcp:emit
    使用的快照。
  • apps/api/src/mcp/server.ts
    ——每请求MCP服务器,将每个工具调用通过
    rootApp.fetch()
    分发回去,以便完整的中间件链(认证、限流、组织上下文、校验)在每次内部调用时重新运行。
  • apps/api/scripts/emit-openapi.ts
    /
    apps/api/scripts/emit-mcp.ts
    ——使用存根客户端启动路由注册表并序列化清单。
  • apps/api/src/openapi/schemas.ts
    apps/api/src/openapi/pagination.ts
    ——共享边界原语(安全方案、
    Paginated(...)
    、通用参数schema)。

Related skills

相关技能

  • code-style — Zod-first contracts, naming conventions, literal-union enums.
  • architecture-boundaries — web vs API split, machine-facing surface invariants.
  • authentication — how
    c.var.auth
    /
    c.var.organization
    get populated on protected routes.
  • testing — Vitest harness layout,
    setupTestApi
    for HTTP-level integration tests.
  • code-style——Zod优先契约、命名约定、字面联合枚举。
  • architecture-boundaries——Web与API拆分、面向机器的层面不变量。
  • authentication——受保护路由上
    c.var.auth
    /
    c.var.organization
    的填充方式。
  • testing——Vitest测试 harness布局、用于HTTP级集成测试的
    setupTestApi