Your Next Tours 分享搭建带约 100 个工具和 OAuth 的远程 MCP 服务器的实践经验
MCP for tour guides: building a remote MCP server with ~100 tools and OAuth
Your Next Tours 开发者分享为导游应用搭建远程 MCP 服务器的完整实践,让导游把自己的 Claude、ChatGPT 或 Cursor 连到账号,通过工具从 PDF 行程生成节目内容,LLM 费用由用户自己的订阅承担。
作者亲历搭建带 OAuth 和约 100 个工具的远程 MCP 服务器,踩坑细节如 405 处理、scope 对齐和 PII 脱敏可直接迁移到同类项目。
我参与开发 Your Next Tours,这是一款导游用来通过手机向团队进行实时音频直播的应用。游客扫描二维码即可在浏览器中收听。在带团之前,导游会在我们的面板中准备大量材料:逐日行程、站点、信息卡片、客人名单,有时还有一个小型公司网站。
这些材料大多已经存在于其他地方,通常是一份 PDF 行程单。因此我们构建了一个 MCP 服务器,让导游可以将自己的 Claude、ChatGPT 或 Cursor 连接到他们的账户。导游把 PDF 拖进聊天窗口,模型便通过工具构建行程,而无需导游重新输入。
本文介绍该服务器的搭建方式以及我们遇到的问题,并附上最终采用的代码。其中大部分内容适用于任何背后有真实用户数据的远程 MCP 服务器。
搭建
- 端点:
https://api.yournext.tours/api/mcp/guide - 传输方式:Streamable HTTP,无状态(不使用会话,因此可以在 PM2 集群上正常运行)
- 认证:针对 claude.ai 和 ChatGPT 使用 OAuth 2.1 + PKCE,或针对仅发送请求头的客户端使用个人 API 密钥
- 授权服务器:自托管的 Ory Hydra。Hydra 将登录和授权委托回我们现有的账户系统,因此密码、Google/Apple 登录和 2FA 都保持原样。我们很早就决定不自己编写 OAuth 服务器。
- 注册表名称:
tours.yournext/guide - 约 100 个工具,每个工具都位于某个作用域之后
LLM 成本由用户自己的订阅承担,这也是这件事对我们来说值得做的部分原因:我们的应用内助手由我们付费,而这个不用。
1. 用 405 而不是 404 响应 GET
对于无状态服务器,没有 SSE 流,因此我们没有 GET 处理程序,Fastify 返回了 404。Cursor 和 Gemini CLI 将其显示为“Failed to open SSE stream”,并认为服务器已损坏。
MCP SDK 客户端将 405 视为“此服务器没有流,继续”。因此修复方法是对 GET 和 DELETE 显式返回 405:
// Stateless Streamable HTTP: no SSE stream and no sessions.
// The SDK client only treats 405 as "no stream, fine"; 404 surfaces as an error.
for (const method of ["GET", "DELETE"] as const) {
app.route({
method,
url: "/api/mcp/guide",
handler: async (_request, reply) =>
reply
.code(405)
.header("Allow", "POST")
.send({ error: { code: "METHOD_NOT_ALLOWED", message: "Use POST" } }),
});
}
2. 401 必须是真正的 401
Claude 通过 401 响应上的 WWW-Authenticate 头发现你的授权服务器。有几个细节很重要:
- 状态码必须是 401。同样的头出现在 200 上会被忽略。
- 在质询中包含
scope。否则客户端会请求scopes_supported中的所有内容。 - 仅在 OAuth 实际配置时才发送质询。将客户端指向你并未提供的元数据文档会导致其失败,并报“server unreachable”。
export function unauthorizedChallenge(): string {
const parts = [
`resource_metadata="${headerSafe(resourceMetadataUrl())}"`,
`scope="${headerSafe(OFFERED_SCOPES.join(" "))}"`,
];
return `Bearer ${parts.join(", ")}`;
}
还有一点:在令牌到达数据库之前验证其 sub。Hydra 的 subject 是自由文本。一个格式错误的 subject 到达 Postgres 时变成了 UUID 转换错误,返回为 400,而由于客户端从未看到 401,它就从未开始重新授权。
3. 某些客户端从授权服务器而不是你这里获取作用域
我们提供 RFC 9728 资源元数据,其中 scopes_supported 被刻意收窄:对 tours、content、trips 和 website 的读/写权限。暴露客人 PII 或向客人发送电子邮件的作用域被有意排除在外。
Gemini CLI 忽略了该文档。它根据授权服务器 openid-configuration 中的 scopes_supported 构建其作用域请求。Hydra 在那里的默认值大致是 openid offline offline_access,因此同意屏幕出现时没有任何可授予的内容,也没有签发可用的令牌。
修复方法是配置,而不是代码:保持 AS 公布的作用域与你的资源元数据完全一致(加上 offline_access)。也不要公布完整目录。同意屏幕通常会预先勾选所有请求的作用域,因此 PII 作用域距离被发送给 LLM 只有一次点击之遥。
两个让我们耗费时间的相关 Hydra 设置:
- 使用
strategies.scope: exact时,动态注册的客户端如果在注册时没有传递作用域,就会被锁定到默认集合,之后请求website:write会失败并报invalid_scope。将oidc.dynamic_client_registration.default_scope设置为完整目录。 - Hydra 不提供
/.well-known/oauth-authorization-server(RFC 8414)。SDK 客户端会回退到 OIDC discovery,但严格的 RFC 8414 客户端不会。我们在 nginx 中把该路径代理到openid-configuration。
4. 让写入工具声明其注解
Claude 和 ChatGPT 的目录审查会检查每个工具上的 title、readOnlyHint 和 destructiveHint,客户端用它们来决定何时请求确认。
规范中缺失 destructiveHint 时的默认值是 true。我们的第一个版本在缺失时填入 false,这意味着一个被遗忘的注解会悄悄地把删除工具标记为无害。我们改了它,现在忘记一个就会编译失败:
type WriteAnnotations = Required<
Pick<ToolAnnotations, "destructiveHint" | "openWorldHint">
> &
Pick<ToolAnnotations, "idempotentHint">;
interface ReadTool extends ToolBase {
access?: "read";
annotations?: never; // read tools are always readOnly, no overrides
}
interface WriteTool extends ToolBase {
access: "write";
annotations: WriteAnnotations; // no annotations, no compile
}
type Tool = ReadTool | WriteTool;
readOnlyHint 派生自 access,所以两者不会不一致。由于类型可以通过强制转换绕过,启动时的断言会在运行时检查同样的事情,而一个黄金测试会固定每个工具的注解。
我们对 destructiveHint: true 的规则:任何删除、覆盖或清除现有数据,更改公开内容(发布网站),向其他人发送内容,或使已分享的链接失效的操作。添加、重新排序和复制不是破坏性的。
5. 每个工具的 scope,包括读取
每个工具都需要一个 scope,读取也包括在内。只有 tours:read 的密钥不仅在写入工具上被拒绝,它在 tools/list 中根本看不到这些工具。这减少了模型尝试它做不到的事情。
我们会再次做出的两个决定:
编辑从不通知访客。trips:write 可以更改行程、其站点和访客名单,而且它从不发送通知。延迟和取消公告会通过电子邮件通知访客,需要单独的 trips:announce scope。一个连续修复十个站点的模型不能发送十封电子邮件,而且没有办法静默取消行程:取消就意味着公告。
访客名单默认被掩码。读取行程名册会将个人数据发送给第三方 LLM 提供商。大多数问题(“有多少人加入了?”)不需要它,所以默认响应被掩码:
maskName("Ahmet Yilmaz"); // "A*** Y***"
maskEmail("ahmet@gmail.com"); // "***@gmail.com"
maskPhone("+905551234567"); // "***4567"
需要明确的是,这不是访问控制。拥有名册 scope 的客户端可以传入 full: true。重点是,拉取完整 PII 成为一个明确的选择,会出现在审计日志中,而不是意外发生的事情。
6. 告诉模型它在和谁说话
工具按 scope、组织角色和计划进行过滤。当组织工具从列表中缺失时,模型为它编造了一个理由。现在服务器将账户、组织、角色、计划和授予的 scope 放入 initialize 响应的 instructions 字段中,并附带一条不要编造 URL 的规则(已发布的站点只存在于 <subdomain>.yournext.tours)。有了这些,模型可以给出实际原因而不是猜测。
7. 模型用虚构内容填充空字段
当被要求“构建我的公司网站”时,模型用编造的内容填充了每个部分,而没有问一个问题。修复方法是让数据告诉模型该问什么。get_website_overview 现在返回一个带有规则和缺失内容列表的 assistantGuide:
export interface IntakeQuestion {
topic: string;
ask: string; // the question for the user, rephrased in their language
offer: string; // what to write, and where, once they answer
}
第一条规则告诉模型每次问用户两到三个问题,不要自己填充这些主题。其他规则禁止编造价格、许可证号、评论或地址。这些文本会发送给模型,所以是英文的;模型用用户的语言提问。
8. 模型可以据此行动的错误
我们的工具错误过去是像“Tool error: validation.error”这样的自由文本,对模型毫无帮助。现在每个工具错误都使用与我们的 HTTP API 相同的 JSON 信封:code、messageKey、message、details,以及一个 hint,说明用户可以在面板的哪个位置修复它。例如,details 列出了网站发布前缺少的内容。有了这些,模型就能修复问题或向用户解释问题。
试试看
如果你经营旅游业务,或者只是想看看它的表现:
- 在 Claude 或 ChatGPT 中添加自定义连接器(ChatGPT 目前需要开启开发者模式的付费计划)。
- 粘贴
https://api.yournext.tours/api/mcp/guide。 - 登录并选择权限范围。
设置指南:https://yournext.tours/ai-assistant-integration/
如果你的客户端对接它时出问题,或者你用不同方式解决了其中某个问题,欢迎在评论中告诉我。
来源:Google AI:DEV 作者专属(RSS) · dev.to
