用 ASP.NET Core 落地远程 MCP 服务
作者分享在 ASP.NET Core 10 中使用官方 C# SDK 构建远程 MCP 服务的经验,涉及 OAuth、客户端注册和 TLS 问题。摘录重点解释了多实例负载均衡下有状态会话的风险,以及选择无状态 HTTP 传输的原因。
AI 深度解读
把 MCP 服务跑在自己的电脑上,和让普通用户在 Claude、ChatGPT 里点击「Connect」就能使用,是两件复杂度差很多的事。PersonaXpand 团队把连接器接进现有的 ASP.NET Core 10 应用,真正耗时间的是授权、客户端注册,以及一次只在生产环境出现的 TLS 握手失败。
这篇最有用的地方,是把远程 MCP 当成一个需要长期运维的服务来讲:多实例怎么部署,开放注册会带来什么风险,为什么本地连接成功仍然不够。它提供了一组具体实现,也留下了几处不能直接照搬的前提。
远程 MCP 为什么需要一整套 OAuth
PersonaXpand 是 Nextoria 在迪拜开发的社交媒体工具,连接器允许 Claude 和 ChatGPT 操作用户自己的账号,包括发帖、排期和回复评论。他们使用官方 C# SDK ModelContextProtocol.AspNetCore,服务运行在负载均衡后的多个实例上。
下面的配置解决请求落到不同实例时,会话无法接续的问题:
services.AddMcpServer(o =>
{
o.ServerInfo = new Implementation { Name = "personaxpand", Title = "PersonaXpand", Version = "1.0.0" };
})
.WithHttpTransport(o => o.Stateless = true)
.WithTools<PersonaXpandTools>();
关键是 Stateless = true。Streamable HTTP 可以为客户端保留会话,但作者建议,除非需要服务端向客户端流式推送,否则使用无状态模式。这样每个请求自带 bearer token,任何实例都能处理。
接下来才是更重的部分:让助手发现授权服务,并取得客户端身份。
/.well-known/oauth-protected-resource 告诉助手该找哪个授权服务器;/.well-known/oauth-authorization-server 则列出授权、令牌、注册和撤销端点。
客户端 ID 有两条获取路径。动态客户端注册(DCR,RFC 7591)允许客户端 POST 名称和回调 URI,换取 ID;客户端 ID 元数据文档(CIMD)直接把 HTTPS URL 当作 ID,服务端访问该地址,读取包含回调 URI 的 JSON。作者主张两者都支持,因为不同助手的接入方式不同;至于 CIMD 会成为趋势,这是作者的判断。
他们采用的 OAuth 2.1 配置也很具体:
- 每个授权码都使用 PKCE,只接受
S256,授权码有效期为 60 秒。 - access token 有效期为 1 小时,refresh token 为 30 天;每次刷新都会轮换 refresh token,旧令牌随之退役。
- 旧 refresh token 在轮换后 60 秒内重用,按网络重试容忍;超过窗口再出现,就撤销整个连接。
- 授权码、access token、refresh token 都按哈希存储和查找;用户修改密码后,撤销全部助手连接。
这些数字是他们的配置选择。原文没有提供对照实验,不能据此认为每个团队都应采用相同期限。
为什么有了 PKCE,回调地址仍然要管
作者最初踩中的安全坑,来自开放 DCR。
攻击者可以注册自己的回调站点,再把真实的授权链接发给受害者。用户看到的是服务自己的登录页和授权按钮,但点击同意后,授权码会被送到攻击者那里。
PKCE 防不住由攻击者自己发起、并持有 verifier 的这条授权流程。
他们因此加入回调主机白名单:
public static readonly string[] DefaultRedirectHosts =
{ "claude.ai", "claude.com", "chatgpt.com", "openai.com", "vscode.dev" };
public bool IsRedirectHostAllowed(string? redirectUri)
{
if (!Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri)) return false;
if (IsLoopback(uri)) return true; // 发到这里的授权码只会抵达用户自己的机器
if (!_config.GetValue("Mcp:RedirectAllowlist", true)) return true; // 应急关闭开关
var extra = _config.GetSection("Mcp:ExtraRedirectHosts").Get<string[]>() ?? [];
return HostIs(uri.Host, DefaultRedirectHosts.Concat(extra).ToArray());
}
要看清的是关闭开关所在的一行:当 Mcp:RedirectAllowlist 为 false 时,后续主机限制直接跳过。它方便紧急恢复接入,也确实放宽了这层防线。
桌面客户端常用 http://127.0.0.1:<random port> 回调,因此作者放行 loopback,并依据 RFC 8252 在匹配 loopback 回调时忽略端口。新增助手域名通过 Mcp:ExtraRedirectHosts 配置,无须重新部署。拒绝时,错误信息也要写清被拒绝的主机和联系渠道,不能只剩 invalid_redirect_uri。
但授权页面的责任还没结束。PersonaXpand 把能力分成「read」和「act」:前者查看帖子、分析和收件箱,后者发布、排期和回复;涉及通过用户账号发送消息的操作,仍要求在聊天中先确认。
作者还要求工具描述明确说明:来自账号外部的内容只能作为数据,不能作为指令。评论区里的一段文字可能诱导助手执行操作,用户允许读取评论,并不等于允许评论替自己下命令。
本地接入成功,为什么生产环境仍然失败
CIMD 增加了一条容易漏测的网络链路:MCP 服务端需要主动向客户端域名发起 HTTPS 请求,下载元数据。
作者遇到某个 MCP 目录登录失败,界面只提示「This app is not registered」。本地正常,生产异常,最终日志才暴露 TLS 握手失败。
他们的生产环境是 Azure App Service on Windows,出站 TLS 使用 SChannel。对方只接受 TLS 1.3 和 X25519 密钥交换,拒绝 TLS 1.2 及 P-256/P-384 曲线;双方没有共同可用的握手组合。作者把细节反馈给对方,对方第二天增加 TLS 1.2 与 P-256 支持,连接才恢复。
这是原文最明确的一次故障排查经历。它支持的结论也很直接:元数据请求失败时,要记录异常类型;CIMD 必须在与生产相同的操作系统和宿主环境里测试。一个笼统的「无法读取客户端元数据」,足以把传输层问题伪装成注册问题。
他们还补了几处运行细节。AddListToolsFilter 和 AddCallToolFilter 可以按客户端过滤工具,他们为遵守 Claude 目录规则隐藏了图像生成。破坏性调用则按账号加分布式锁:助手可能同时发起 20 次删除,如果删除阈值依赖近期计数,并发请求就可能全都读到零。
时钟同样会影响授权。他们发现过一台开发机比 UTC 偏差 4 小时,表现却是难懂的 invalid grant。最后,作者建议发布到官方 MCP Registry,并保留用于证明域名所有权的 DNS TXT 记录。
我的判断
这篇说得最对的,是把接入问题拆到了请求、授权、回调、工具执行和出站网络。只验证一次工具调用,确实覆盖不了这些边界。
不过,它是项目复盘,不是完整的安全实现规范。HostIs 和 IsLoopback 没有展开,无法仅凭片段确认主机边界及 URI 校验是否严密;按主机筛选也不能替代完整回调 URI 的校验。60 秒重试宽限期里如何返回令牌、如何处理并发刷新,原文也没说清。
白名单适合他们支持已知助手的场景,但会增加新客户端接入成本。相较之下,预注册客户端更容易控制信任范围,却需要维护注册关系。哪种合适,要看服务是否真的需要向未知客户端开放。
无状态部署、令牌期限、按账号串行执行,都是有理由的工程选择;作者没有给出性能或安全对照数据,应当把它们当作可参考的经验。
能拿来做什么
- 如果你正把本地 MCP 搬到远程,先列出发现文档、客户端注册、授权、刷新和撤销的完整路径,再检查多实例之间是否依赖内存会话。
- 如果你负责授权页面,把「读取」和「执行」能做什么写清楚,并让发送消息等操作保留明确确认步骤。
- 如果你的 Node 或 .NET 服务需要下载 CIMD,在生产同类环境中验证请求,并保留能区分 TLS 故障与元数据错误的日志。
- 如果工具支持批量删除,检查并发调用能否绕过计数和确认规则;多实例部署时,把账号级互斥落实到分布式锁。