命名空间所有权与身份握手
本章讲什么: MCP 服务器怎么命名,以及整个项目最独特的设计——怎么用密码学证明「这个命名空间归你」,从而防止有人冒名发布
io.github.microsoft/xxx。
1. 名字长什么样:反向 DNS
每个服务器有个全局唯一的名字,格式是 namespace/name,namespace 用反向 DNS:
io.github.alice/weather—— GitHub 用户 alice 的 weather 服务器com.example/my-server—— 拥有 example.com 的人发布的
格式由 schema 和正则双重约束。ServerJSON.Name 的 schema 约束在 pkg/api/v0/types.go:38:
// pkg/api/v0/types.go:38 ServerJSON.Name 字段 tag
Name string `json:"name" minLength:"3" maxLength:"200"
pattern:"^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$"
doc:"Server name in reverse-DNS format. Must contain exactly one forward slash..."`
服务端还有一层语义校验 parseServerName(internal/validators/validators.go:701):明确只能有一个斜杠、两边都非空,否则按命名空间/名字分别给出可读错误。
为什么用反向 DNS? 因为它天然把「名字」和「一个可被证明拥有的东西(域名/GitHub 账号)」绑定。io.github.alice 反过来就是 github.com/alice;com.example 反过来就是 example.com。下面三种握手就是围绕「你能证明你拥有反过来那个东西吗」展开的 。
2. 核心直觉:用「所有权证明」换「短命令牌」
整个授权模型分两步,先理解这个两步分离,后面所有代码才好读:
外部身份(你能证明的东西) Registry 内部令牌
┌────────────────────────┐ ┌──────────────────────────┐
│ GitHub OAuth token │ │ Registry JWT (Ed25519签名)│
│ 或 DNS TXT 记录 + 签名 │ ──换──▶ │ 有效期 5 分钟 │
│ 或 well-known + 签名 │ │ permissions: │
└────────────────────────┘ │ io.github.alice/* 可发布│
└──────────────────────────┘
- 第一步(本章): 各种 auth handler 各自验证一种「所有权证明」,验过后调用同一个
JWTManager签发一个只带特定命名空间发布权的 JWT。 - 第二步(下一章): publish handler 只认这个 JWT,检查它的 permissions 能否覆盖你要发布的名字。
令牌为什么只活 5 分钟?见 internal/auth/jwt.go:69,tokenDuration: 5 * time.Minute——短令牌即使泄露,危害窗口也极小。签名用 Ed25519(NewJWTManager,internal/auth/jwt.go:51),从一个 32 字节种子派生密钥对。
3. 三种(实为多种)所有权证明握手
根据你的命名空间反过来是「GitHub 账号」还是「自有域名」,有不同的证明方式:
| 握手 | 适用命名空间 | 你要证明 | 授予的 permission |
|---|---|---|---|
| GitHub AT(OAuth token) | io.github.<用户/org>/* | 你登录了该 GitHub 账号 | io.github.<name>/* |
| GitHub OIDC(Actions) | io.github.<owner>/* | 你的 CI 跑在该仓库 | io.github.<owner>/* |
| DNS | com.example.*(含子域) | 你能改 example.com 的 DNS | com.example/* + com.example.* |
| HTTP | com.example/* | 你能放文件到 example.com | com.example/* |
| OIDC(通用)/ none | 视配置 | 第三方 IdP / 本地开发 | 视配置 |
下面挑三种最有代表性的讲透。
3.1 GitHub access-token 握手:借 GitHub 当身份后端
- 要解决的小问题: 想发
io.github.alice/*,怎么知道你真是 alice? - 思路: 别自己造账号体系,直接借 GitHub。你给我一个 GitHub OAuth token,我拿它去问 GitHub「这 token 是谁的」,GitHub 说是 alice,那 alice 就拿到
io.github.alice/*。
真实实现 GitHubHandler.ExchangeToken(internal/api/handlers/v0/auth/github_at.go:70):先 getGitHubUser 拿登录名,再 getGitHubUserOrgs 拿所属组织,然后 buildPermissions 把用户和每个 org 都映射成一条发布权。
// internal/api/handlers/v0/auth/github_at.go:179 buildPermissions(节选)
// 用户自己的命名空间
permissions = append(permissions, auth.Permission{
Action: auth.PermissionActionPublish,
ResourcePattern: fmt.Sprintf("io.github.%s/*", username),
})
// 以及每个 org 的命名空间
for _, org := range orgs {
permissions = append(permissions, auth.Permission{
Action: auth.PermissionActionPublish,
ResourcePattern: fmt.Sprintf("io.github.%s/*", org.Login),
})
}
关键细节: 拼进 ResourcePattern 前,用户名和 org 名都过 isValidGitHubName(github_at.go:195,正则 ^[a-zA-Z0-9-]+$)。这是防注入——避免有人在名字里塞奇怪字符把权限模式撑大。一个名字不合法,buildPermissions 直接返回 nil(整批作废)。
OIDC 变体(CI 场景): GitHub Actions 跑发布时没有交互式 OAuth,改用 OIDC token。GitHubOIDCHandler.buildPermissions(internal/api/handlers/v0/auth/github_oidc.go:280)只取 repository_owner claim,授予 io.github.<owner>/*——注释解释为何按 owner 而非具体 repo 授权:很多人 monorepo 里发多个 server,且这和 GHCR 的权限模型一致。
3.2 DNS 握手:像 DNS-01 证书挑战一样
- 要解决的小问题: 我拥有
example.com,想发com.example/*,但我没有 GitHub 组织,怎么证明? - 思路: 跟 ACME/Let's Encrypt 的 DNS-01 挑战同一套路——你能在域名的 DNS 里放一条只有域名控制者才放得了的记录吗?
具体协议:
- 你生成一对密钥,把公钥发布成 example.com 的一条 TXT 记录,格式
v=MCPv1; k=ed25519; p=<base64公钥>。 - 你用私钥对当前时间戳签名。
- POST
{domain, timestamp, signed_timestamp}给/v0/auth/dns。 - registry 查 example.com 的 TXT 记录拿到公钥,验签名。验过 → 你显然控制着该域名 → 发证。
这条「证明记录」的格式由一个共享正则定义(internal/api/handlers/v0/auth/common.go:42):
// internal/api/handlers/v0/auth/common.go:42 MCPProofRecordPattern
var MCPProofRecordPattern = regexp.MustCompile(
`v=MCPv1;\s*k=([^;]+);\s*p=([A-Za-z0-9+/=]+)`)
时间戳的作用是防重放: 签名的内容就是时间戳,服务端要求它在 ±15 秒窗口内(ValidateDomainAndTimestamp,common.go:99)。所以一个截获的旧签名几秒后就失效。
DNS 的层级语义 → 授予子域: DNSAuthHandler.ExchangeToken(internal/api/handlers/v0/auth/dns.go:84)传 allowSubdomains = true,于是 BuildPermissions 既给 com.example/* 也给 com.example.*(common.go:226)。注释点明:这与 DNS 天然的子域归属一致——拥有 example.com 就拥有 api.example.com。
一个体贴的纠错: 用户常按 DKIM 直觉把记录放到 _mcp-auth.example.com 这种 selector 下(而正确位置是域名顶点 apex,像 SPF)。代码专门探测这种「放错位置」并给出明确报错(findMisplacedSelector,dns.go:131,探测 commonWrongSelectors = {"_mcp-auth","_mcp-registry"},见 issue #385/#1103/#1126)。
3.3 HTTP 握手:放文件到 well-known 路径
- 思路: DNS 的变体。不改 DNS,而是把同样的
v=MCPv1;...公钥串放到https://example.com/.well-known/mcp-registry-auth。
DefaultHTTPKeyFetcher.FetchKey(internal/api/handlers/v0/auth/http.go:180)去 GET 那个 URL 取公钥;其余(验签、发证)和 DNS 走同一个 CoreAuthHandler.ExchangeToken。
两点不同:
- 不授子域: HTTP 握手
allowSubdomains = false(http.go:268)——放在example.com的文件不代表你控制api.example.com。 - SSRF 防护极重: 因为是「服务端按用户给的域名去发 HTTPS 请求」,这是经典 SSRF 入口。防护细节(禁内网 IP、DNS rebinding pinning、禁重定向)放在第 5 章讲。
4. 多种证明握手怎么共用一套核心
值得学的工程结构:DNS 和 HTTP 只是「