谈谈 API 接口自描述与权限配置系统

2026年05月16日7 次阅读0 人喜欢
API权限系统MCPTypeScript踩坑

起因

说实话,之前项目的 API 权限管理挺乱的。接口描述散落在 api-registry.ts 里,每次新增接口都得去那个文件手动注册,漏了就出问题。MCP 工具的开关也是硬编码的,想动态调整根本没戏。

最近花时间搞了一套 API 接口自描述系统,把这些问题一并解决了。本文记录下踩坑点,避免后续踩同样的坑。

方案设计

核心思路很简单:每个 route.ts 文件自己声明自己

typescript 复制代码
// src/app/api/post/create/route.ts
import type { ApiDescriptor } from '@/types/api-descriptor';
import { POST_CREATE } from '@/constants/permissions';

export const descriptor: ApiDescriptor = {
  code: 'post_create',
  name: '创建文章',
  module: 'post',
  method: 'POST',
  permissionCode: POST_CREATE,
  inputSchema: {
    type: 'object',
    properties: {
      title: { type: 'string', description: '文章标题' },
      content: { type: 'string', description: '文章内容' },
    },
    required: ['title', 'content'],
  },
};

export async function POST(request: NextRequest) {
  // 原有逻辑不变
}

一个文件有多个接口?用 xxxDescriptor 命名就行:

typescript 复制代码
export const getDescriptor: ApiDescriptor = { /* GET */ };
export const updateDescriptor: ApiDescriptor = { /* PUT */ };
export const deleteDescriptor: ApiDescriptor = { /* DELETE */ };

扫描脚本

写了个脚本 pnpm sync:api-registry,扫描所有 route.ts 文件,自动同步到数据库。

一开始想用字符串解析的方式提取 descriptor,结果发现根本没法处理导入的权限码常量。后来改成动态 import,直接读取模块导出,代码从 238 行缩减到 155 行,还省了一堆重复定义。

踩坑记录

踩坑 1:MCP 工具列表动态过滤

改完后台接口管理页面后,想把 MCP 工具列表也做成动态的 — 不同权限的用户看到不同的工具。

一开始以为 MCP 协议限制了,HTTP 形式每次请求会重建连接所以 tools/list 是静态的。后来发现 HTTP 形式的 MCP 是无状态的,每次 POST 请求都会重新 createMcpServer,完全可以提前认证获取用户权限,然后只注册有权限的工具。

typescript 复制代码
// src/app/api/mcp/route.ts
async function createMcpServer(headers: Headers) {
  const token = getTokenFromRequest(headers);
  const user = token ? await validateTokenWithPermissions(token) : null;

  const allMcpEntries = await getMcpEnabledEntries();
  const mcpEntries = user
    ? allMcpEntries.filter(entry =>
        !entry.permissionCode || user.permissions.includes(entry.permissionCode)
      )
    : allMcpEntries;
  // 只注册有权限的工具...
}

结论:HTTP 形式的 MCP 不存在连接保持的问题,每次请求独立处理,动态过滤完全可以做。

踩坑 2:数据库才是 Source of Truth

之前 getMcpEnabledEntries() 读的是代码里的 API_REGISTRY 常量。结果在后台手动添加的接口怎么配置都不生效,工具列表永远是代码里写死的那些。

改成从数据库读取后,后台管理的配置才能实时生效。代码里的 API_REGISTRY 现在只负责提供 handler(业务逻辑),配置数据以数据库为准。

踩坑 3:同步脚本误杀接口

同步脚本有个「禁用代码中找不到的接口」的逻辑。问题出在 API_REGISTRY 中有 handler 的接口(如 post_create),它们的 descriptor 是在 api-registry.ts 里声明的而不是 route.ts 里,扫描路由文件时扫不到。

结果同步脚本认为这些接口「代码中不存在」,把它们全部禁用了(status=0, mcp_enabled=0)。排查了很久才发现是 notIn: codes 的范围不对。

修复:禁用逻辑增加条件,保留有 handler 的接口不被误杀。

踩坑 4:没有 handler 的接口 MCP 开关不能只是关闭

后台可以给任何接口开启 MCP 开关,但如果代码里没有实现 handler,开了也没用,还会让用户看到有工具名但调用失败,很困惑。

解决方案:

  • 数据库加 mcp_available 字段,标记代码中是否有 handler
  • 同步脚本维护这个字段
  • 前端 Switch 根据这个字段 disabled,不能启用
  • 后端 API 拦截,禁止启用没有 handler 的接口

踩坑 5:同步脚本进程不退出

脚本扫描路由时通过动态 import 加载各种模块,有些模块会初始化 Redis 连接。脚本结束时只关闭了 Prisma 连接,Redis 连接没关,导致进程挂起。

解决process.exit(0) 强制退出。

踩坑 6:Prisma query 日志刷屏

开发环境 Prisma 默认打印所有 SQL 查询,调试时很吵。

src/lib/prisma.ts 里原来是写死 log: ['query', 'error', 'warn'],改成支持环境变量控制:

typescript 复制代码
log: process.env.PRISMA_HIDE_QUERY_LOG === 'true'
  ? ['error', 'warn']
  : (process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']),

.env 里加 PRISMA_HIDE_QUERY_LOG=true 就能屏蔽。也可以直接改成 log: ['error']

总结

踩坑的核心教训:当「配置」和「代码」两套体系并存时,一定要明确哪个是 Source of Truth,不能两边都当真。这次就是两边不一致导致各种奇怪的问题。

加载评论中...