服务端入口与工具注册架构
这是 MCP 服务端的骨架——它让 AI 客户端(Claude Code、Claude Desktop 之类)通过 MCP 的 stdio 传输调用进 YouTeacher。它是一个刻意扁平的小程序:一个入口把服务器启动起来,加载凭据,把一组固定的工具挂到同一个共享 HTTP 客户端上,再接上传输层。这里没有插件加载器,也没有动态发现——工具集就是一份手写的清单。
它以私有 npm 包(@youteacher/mcp)的形式发布,是一个面向 Node 20 及以上的 ES-module TypeScript 项目。它对外暴露单个可执行文件 youteacher-mcp,运行期只依赖两样东西:官方的 @modelcontextprotocol/sdk,以及做 schema 校验的 zod。构建就是一句 tsc;开发时用 tsx 直接跑入口。
启动流程
入口是一个可执行脚本(#!/usr/bin/env node),它的 main 按顺序做四件事:
- 加载凭据。 管理员 API-key 凭据从一个文件读入,文件位置由一个环境变量给出。这一步包在
try/catch里:文件缺失或无效时,服务器把原因写到stderr并以非零码退出。 - 建一个 auth 客户端。 用这些凭据构造单个
AuthClient,供每一组工具共享——工具自己从不持有凭据、也不自开连接。 - 注册各工具组。 先创建一个
McpServer(命名为youteacher-mcp,带它自己的版本号),再用(server, auth)逐个调用各register*Tool(s)函数:me工具、列出 API-key 的工具,以及 post、category、export、update-self 这几组工具。 - 接上传输层。 挂上
StdioServerTransport并 awaitserver.connect——从这一刻起,进程就通过 stdin/stdout 讲 MCP。
main 外层的 .catch 兜住启动阶段逃逸出来的任何失败:把栈(或消息)打到 stderr,然后非零退出。
快速失败,而不是沉默失败
凭据检查是这里最吃重的一个设计决定,原因写在代码注释里:一个没有有效凭据就起来的服务器,会在每一次调用上都静默地返回 401。那是一种令人困惑的失败——客户端看着是连上了,可什么都不工作。所以服务器在凭据缺失或格式不对时干脆拒绝启动,把一个散布在每次请求上的认证错误,收敛成运维者立刻能看见的一次明确的启动失败。「这凭据不对」这个事实只有一个家——启动那一步——而不是在每个请求里被反复重新发现。
一个共享客户端,多个轻工具组
接线是一颗以 AuthClient 为中心的星。每个工具组都是一个 register* 函数,收下 server 和那唯一的 auth 客户端;这一组的活,就是把 MCP 的工具调用翻译成经由这个客户端发出的 HTTP 请求,再把响应递回去。这样认证和传输的事都归在一处,各工具组就能安心当一个后端端点之上的薄包装。加一组工具,就是多写一个 register* 函数、在 main 里多加一行——套路不变。
把后端响应标准化
内容服务的各工具共用两个小 helper,好让每个包装都以同样的方式,把后端回复变成一个规整的 MCP 结果。
toToolResult 把 auth 客户端的 ApiResponse 转成 MCP 的 CallToolResult 形状。2xx 状态变成单个文本块,装着漂亮打印的 JSON 正文(若正文本身已是字符串,就直接用它)。任何非 2xx 状态变成一个带 HTTP 状态行前缀的文本块,并且带上 isError: true——正是这个标记让 MCP 客户端把这次调用渲染成失败,而不是一次内容奇怪的成功。这个 helper 的返回类型照抄了 SDK 那个开放的索引签名,所以一个更窄的本地接口仍能通过 CallToolResult 的类型检查。
withQuery 从一个 params 对象拼出 path?k=v&… 字符串,跳过任何值为 null 或 undefined 的键。这样每个工具就能把一个部分填写的 filter 对象直接透传——调用方不必手写 if (x != null) params.set(...) 那套仪式,没给的 filter 就干脆不出现在查询串里。要是跳完什么都没剩,就返回不带尾随 ? 的裸 path。
为什么长这样
整个服务器是故意做小的。凭据加载一次、失败就大声报出来;一个客户端替所有人带着认证;工具组是统一的、注册完就不管的包装;两个 helper 保证「成功」和「失败」在客户端看到的每一个工具上长得一样。这个服务器没有的问题,它就没有对应的机械装置。