api-endpoints
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAdding API endpoints
添加API端点
When to use: Adding a new endpoint to , changing an existing one, or wondering why / / the SDK aren't in sync.
apps/apimcp.jsonopenapi.json适用场景: 为添加新端点、修改现有端点,或排查//SDK不同步的问题。
apps/apimcp.jsonopenapi.jsonBefore 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 ); the goal isn't full surface parity, it's not duplicating logic that the web already implements.
plans/mcp-oauth-api-expansion.mdFor each new endpoint, open . Three cases:
apps/web/src/domains/<entity>/<entity>.functions.ts- The web's server fn already calls a domain use-case (imports from
*UseCase): reuse that use-case in the API route handler. Don't reimplement the logic in@domain/*.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 : 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.
getBetterAuth().api.*
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 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 use-case from the start rather than inline in the route handler.
.functions.ts@domain/*添加新API端点时,请检查Web UI中是否已实现相同的操作或读取逻辑。我们仅提供特定列表的API端点(详见中的清单);目标并非实现功能完全对等,而是避免重复Web已有的逻辑。
plans/mcp-oauth-api-expansion.md对于每个新端点,请打开****。分为三种情况:
apps/web/src/domains/<entity>/<entity>.functions.ts- Web的服务器函数已调用领域用例(从导入
@domain/*):在API路由处理器中复用该用例,不要在*UseCase中重新实现逻辑。apps/api - Web的服务器函数逻辑内联(服务器函数体中直接包含原始仓库调用、校验、副作用):先将其提取为新的领域用例,然后让Web服务器函数和你的API路由都调用该用例。领域用例将成为共享的衔接层。
- Web的服务器函数委托给第三方API(如):API进程无法访问相同的进程内实例。编写一个领域用例来复现该行为(仔细阅读第三方源码,确保你的用例符合其规则),然后让Web和API都指向该用例。添加对等测试,防止迁移过程中出现隐性偏差。
getBetterAuth().api.*
领域用例是Web与API之间的共享衔接层。在两个层面重复逻辑会导致偏差——其中一个层面修复了bug,另一个层面却没有。
如果该实体没有文件(因为UI尚未暴露此操作),则你需要从头设计。这没问题;但不要放弃后续共享的可能性——从一开始就将业务逻辑放在用例中,而不是内联在路由处理器中。
.functions.ts@domain/*What you're really doing
你实际在做什么
Every endpoint in is one declaration that fans out four ways:
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 |
You don't write three configs. You write one. The infra in derives the other surfaces.
apps/api/src/mcp/*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 as user-facing copy. Vague or absent descriptions are bugs.
descriptionapps/api| 呈现形式 | 生成来源 | 使用者 |
|---|---|---|
| HTTP路由(Hono) | | curl、内部服务 |
| OpenAPI操作 | | |
| MCP工具 | | |
| SDK方法 | Fern读取OpenAPI文档 | 终端用户TypeScript代码 |
你无需编写三份配置,只需编写一份。中的基础设施会衍生出其他呈现形式。
apps/api/src/mcp/*这意味着:你在路由和schema字段上添加的描述会被SDK用户和调用MCP的AI Agent读取。将每个视为面向用户的文案。模糊或缺失的描述属于bug。
descriptionRecipe: add a new route file
步骤:添加新路由文件
1. Create apps/api/src/routes/<resource>.ts
apps/api/src/routes/<resource>.ts1. 创建apps/api/src/routes/<resource>.ts
apps/api/src/routes/<resource>.tsts
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
apps/api/src/routes/index.ts2. 在apps/api/src/routes/index.ts
中配置
apps/api/src/routes/index.tsts
import { createWidgetsRoutes, widgetsPath } from "./widgets.ts"
// ...
routes.use(widgetsPath, createTierRateLimiter("low")) // pick a tier — see below
routes.route(widgetsPath, createWidgetsRoutes())The call is plain Hono mount — MCP registration is a side effect of inside . The line is the rate-limit tier — don't skip it (see "Rate limiting" below).
routes.route(...)mountHttpcreateWidgetsRoutes()routes.use(...)ts
import { createWidgetsRoutes, widgetsPath } from "./widgets.ts"
// ...
routes.use(widgetsPath, createTierRateLimiter("low")) // pick a tier — see below
routes.route(widgetsPath, createWidgetsRoutes())routes.route(...)createWidgetsRoutes()mountHttproutes.use(...)3. Regenerate manifests
3. 重新生成清单
bash
pnpm openapi:emit # rewrites apps/api/openapi.json
pnpm mcp:emit # rewrites apps/api/mcp.jsonBoth files are checked in. CI guards against drift, so commit them alongside the route file.
The TS SDK regenerates from via Fern (). Run it locally if your PR is supposed to expose the new method through the SDK — otherwise the next SDK release picks it up.
openapi.jsonpnpm generate:sdkbash
pnpm openapi:emit # rewrites apps/api/openapi.json
pnpm mcp:emit # rewrites apps/api/mcp.json这两个文件都需要提交到版本库。CI会检查是否存在偏差,因此请将它们与路由文件一起提交。
TS SDK会通过Fern从重新生成()。如果你的PR需要通过SDK暴露新方法,请在本地运行该命令;否则下一次SDK发布时会自动包含该方法。
openapi.jsonpnpm generate:sdk4. Tests
4. 测试
- HTTP-level integration tests live in . Test through
apps/api/src/routes/<resource>.test.tsso middleware runs end-to-end.app.fetch() - MCP-level integration tests for new tools live in (see the existing
apps/api/src/mcp/server.test.ts/createApiKey/listApiKeyscases). Add a case there if the route exposes behavior worth pinning at the MCP layer too.revokeApiKey
- HTTP级别的集成测试位于。通过
apps/api/src/routes/<resource>.test.ts进行测试,确保中间件端到端运行。app.fetch() - 新工具的MCP级集成测试位于(参考现有
apps/api/src/mcp/server.test.ts/createApiKey/listApiKeys案例)。如果路由暴露了值得在MCP层面固定的行为,请在此处添加案例。revokeApiKey
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 / property comments).
@param - AI agents read them via the MCP tool's /
inputSchemato decide what to put in a tool call.outputSchema
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().meta().openapi().describe()
vs .meta()
vs .openapi()
.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 — |
TL;DR: prefer / . Use to register named schema components. Reach for for fields ONLY when you need an OpenAPI-only knob like .
.describe().meta().openapi("Name").openapi({...})format: "uri"If you find yourself writing , replace it with — descriptions hidden in the openapi WeakMap are invisible to MCP clients, which silently degrades agent UX.
.openapi({ description }).describe()| API | 使用场景 |
|---|---|
| 字段级描述的默认方式。是 |
| 等同于 |
| 仅用于Schema组件注册——在OpenAPI的 |
| 仅OpenAPI元数据—— |
总结:优先使用/。使用注册命名Schema组件。仅当需要这类OpenAPI专属配置时,才为字段使用。
.describe().meta().openapi("Name")format: "uri".openapi({...})如果你发现自己在写,请替换为——隐藏在openapi WeakMap中的描述对MCP客户端不可见,会悄无声息地降低Agent的用户体验。
.openapi({ description }).describe()Don't leak internal implementation into descriptions
不要在描述中泄露内部实现细节
User-facing descriptions (route , schema , ) 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.
description.describe()openApiResponses({ description })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 : "Delete project" beats "Soft-delete project".
summary面向用户的描述(路由、schema、)会被SDK用户和AI Agent读取。它们不是后端的发布说明。请聚焦于契约,而非实现方式。
description.describe()openApiResponses({ description })具体而言,避免:
- 存储机制:“软删除”、“硬删除”、“标记为已删除”、“从缓存移除”、“写入发件箱”、“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...")路由/操作中使用的动词也遵循相同规则:“Delete project”优于“Soft-delete project”。
summaryRate limiting — every new endpoint group needs a tier
限流——每个新端点组都需要指定层级
createTierRateLimiter(tier)routes.route(prefix, …)ts
routes.use(myPath, createTierRateLimiter("low"))
routes.route(myPath, createMyRoutes())One tier per sub-app — Hono's 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.
routes.use(prefix, …)createTierRateLimiter(tier)routes.route(prefix, …)ts
routes.use(myPath, createTierRateLimiter("low"))
routes.route(myPath, createMyRoutes())每个子应用对应一个层级——Hono的会覆盖该前缀下挂载的所有内容。可以实现方法级别的粒度(在路由文件中为每个端点应用限流),但通常不值得分散代码。
routes.use(prefix, …)Picking a tier
选择层级
Default to . Most CRUD endpoints don't need more — 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.
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. |
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 and bump it later if a specific endpoint shows up in incident traffic.
low默认使用。大多数CRUD端点不需要更高层级——为每个组织每分钟100次请求,足以覆盖SDK轮询、MCP工具调用和人工驱动的仪表板。仅当端点确实需要更严格的限制时才升级层级。
lowlow| 层级 | 配额(每组织/每分钟) | 适用场景 |
|---|---|---|
| 100 | 默认值:基于ID的CRUD操作、有限大小的列表查询、简单查找、账户/设置读取。大多数端点属于此类。 |
| 60 | 带有非平凡副作用的变更操作(发布事件、写入多个表、扇出式缓存失效)。 |
| 15 | 扫描大型数据集的批量读取操作(过滤/搜索/语义/向量加载)。 |
| 3 | 触发工作流的操作:导入、导出、监控告警、发送邮件或入队重型任务的任何操作。 |
不要过于严格。对于低成本端点,更严格的层级并不会显著提升API安全性——只会让合法调用者感到沮丧。如有疑问,选择,如果特定端点在事件流量中出现问题,再后续调整。
lowWhat 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 for the CRUD and for the export — easiest to express by giving the export its own sub-app ().
lowcritical/widgets/export以组中最消耗资源的端点为准,或者拆分该子应用。一个纯CRUD子应用包含一个高成本导出端点时,CRUD操作应使用,导出操作应使用——最简单的方式是为导出操作创建独立的子应用()。
lowcritical/widgets/exportAlways 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 line is part of "wiring up", not optional.
routes.use(myPath, createTierRateLimiter(...))这一步包含在步骤(步骤2)中是有原因的:发布未指定层级的路由组意味着它没有继承任何路由级限制——仅存在全局基于IP的暴力防护。这在PR审查中很容易被忽略。行属于“配置”的一部分,并非可选。
routes.use(myPath, createTierRateLimiter(...))Choosing route names and shapes
选择路由名称和结构
- is camelCase, verb-first, and reads like an SDK method:
name,createApiKey,listProjects. Avoid resource-prefixed names that read awkwardly as SDK calls (assignSavedSearch→ useapiKeysList).listApiKeys - 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.
description - is optional, shorter, and becomes the MCP tool
summary. Falls back totitlewhen omitted.name
- ****采用小驼峰式,以动词开头,读起来像SDK方法:
name、createApiKey、listProjects。避免以资源为前缀的名称,因为作为SDK调用时读起来很别扭(assignSavedSearch→ 使用apiKeysList)。listApiKeys - ****是路由的单行工具/方法简介。将其视为SDK用户或AI Agent发现该操作时看到的第一句话。
description - ****是可选的,更简短,会成为MCP工具的
summary。如果省略,会回退到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: falsets
const internalReindex = widgetEndpoint({
route: createRoute({ ... }),
handler: async (c) => { ... },
tool: false, // ← HTTP route is mounted, MCP tool is skipped
})某些路由不应作为工具——它们对HTTP/SDK客户端有意义,但对AI Agent无意义(例如内部生命周期端点、仅Web回调)。传递:
tool: falsets
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 driftSpot-check both manifests by hand: open and , find your operation, confirm every field has a . If something is missing, it'll silently degrade SDK docs and agent UX — fix it at the Zod schema, not in the JSON output.
apps/api/mcp.jsonapps/api/openapi.jsondescriptionAnd glance at next to your line — there should be a on the line above. If it's missing, your endpoints are uncapped per-org.
apps/api/src/routes/index.tsroutes.route(prefix, …)routes.use(prefix, createTierRateLimiter("…"))打开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 # 无偏差手动抽查两个清单:打开和,找到你的操作,确认每个字段都有。如果有缺失,会悄无声息地降低SDK文档质量和Agent用户体验——请在Zod schema中修复,而非修改JSON输出。
apps/api/mcp.jsonapps/api/openapi.jsondescription同时查看中你的行上方是否有行。如果缺失,你的端点没有按组织设置限流上限。
apps/api/src/routes/index.tsroutes.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.tsfactory; baked-indefineApiEndpoint,prefixregisters with the MCP registry on tool-eligible mounts.mountHttp - — module-global endpoint registry;
apps/api/src/mcp/registry.tsemits the snapshot used by both the runtime MCP transport andcollectToolDescriptors().mcp:emit - — per-request MCP server, dispatches each tool call back through
apps/api/src/mcp/server.tsso the full middleware chain (auth, rate-limit, org-context, validation) re-runs on every inner call.rootApp.fetch() - /
apps/api/scripts/emit-openapi.ts— boot the route registry with stub clients and serialize the manifests.apps/api/scripts/emit-mcp.ts - and
apps/api/src/openapi/schemas.ts— shared boundary primitives (security scheme,apps/api/src/openapi/pagination.ts, common param schemas).Paginated(...)
如果需要调试自动生成流程:
- ——
apps/api/src/mcp/define-endpoint.ts工厂;内置defineApiEndpoint,prefix会在符合工具条件的挂载时向MCP注册表注册端点。mountHttp - ——模块级全局端点注册表;
apps/api/src/mcp/registry.ts会生成运行时MCP传输层和collectToolDescriptors()使用的快照。mcp:emit - ——每请求MCP服务器,将每个工具调用通过
apps/api/src/mcp/server.ts分发回去,以便完整的中间件链(认证、限流、组织上下文、校验)在每次内部调用时重新运行。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、通用参数schema)。Paginated(...)
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.authget populated on protected routes.c.var.organization - testing — Vitest harness layout, for HTTP-level integration tests.
setupTestApi
- code-style——Zod优先契约、命名约定、字面联合枚举。
- architecture-boundaries——Web与API拆分、面向机器的层面不变量。
- authentication——受保护路由上/
c.var.auth的填充方式。c.var.organization - testing——Vitest测试 harness布局、用于HTTP级集成测试的。
setupTestApi