跳到主要内容

命名空间所有权与身份握手

本章讲什么: 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>/*
DNScom.example.*(含子域)你能改 example.com 的 DNScom.example/* + com.example.*
HTTPcom.example/*你能放文件到 example.comcom.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 里放一条只有域名控制者才放得了的记录吗?

具体协议:

  1. 你生成一对密钥,把公钥发布成 example.com 的一条 TXT 记录,格式 v=MCPv1; k=ed25519; p=<base64公钥>
  2. 你用私钥对当前时间戳签名。
  3. POST {domain, timestamp, signed_timestamp}/v0/auth/dns
  4. 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 只是「怎么拿到公钥」不同,验签到发证的逻辑完全一样,于是抽成一个 CoreAuthHandler.ExchangeToken,把「取公钥」做成一个注入的回调 keyFetcher

DNSAuthHandler.ExchangeToken ─┐
├─▶ CoreAuthHandler.ExchangeToken(keyFetcher, allowSubdomains, method)
HTTPAuthHandler.ExchangeToken ┘ │
├─ 验域名+时间戳窗口 ValidateDomainAndTimestamp
├─ keyFetcher() 取若干公钥串
├─ 解析成 PublicKeyInfo ParseMCPKeysFromStrings
├─ 逐个公钥验签 VerifySignatureWithKeys
└─ 通过 → BuildPermissions → 签 JWT

核心入口 CoreAuthHandler.ExchangeToken(internal/api/handlers/v0/auth/common.go:256)。它支持一个域名同时挂多把公钥(密钥轮换期),VerifySignatureWithKeys(common.go:115)逐把尝试,任一把验过即放行;全失败时还会打印每把 key 的短指纹,提示「也许有把旧记录没删」。

支持两种算法:Ed25519 和 ECDSA P-384(ParsePublicKey,common.go:339)。验签逻辑在 PublicKeyInfo.VerifySignature(common.go:185)。

5. 权限怎么匹配:发布时的那一下检查

令牌签好了,里面是若干 Permission{Action, ResourcePattern}。发布时怎么判断「这个令牌能发 io.github.alice/weather 吗」?

isResourceMatch(internal/auth/jwt.go:165)——极简的前缀匹配:

// internal/auth/jwt.go:165 isResourceMatch
func isResourceMatch(resource, pattern string) bool {
if pattern == "*" { // 全局(管理员)
return true
}
if strings.HasSuffix(pattern, "*") { // 通配:前缀匹配
return strings.HasPrefix(resource, strings.TrimSuffix(pattern, "*"))
}
return resource == pattern // 精确
}

所以 io.github.alice/* 能覆盖 io.github.alice/weather(前缀匹配)。HasPermission(jwt.go:156)遍历令牌里所有权限找一条匹配的。

一个边界:通配符的「前缀」语义有微妙处。 因为是纯字符串前缀,io.github.alice/* 的前缀是 io.github.alice/,匹配 io.github.alice/weather ✓。这也是为什么 DNS 既要 com.example/* 又要 com.example.* 两条:前者覆盖 com.example/server,后者覆盖 com.example.api/server(子域)。jwt.go:91 的 denylist 检查正是因为这个双形态,才要对每个黑名单项探两种合成资源(见第 5 章)。


下一章:拿到 JWT 后,一次 publish 在服务端被分成哪几步