你寫好一個 MCP server,想讓別人裝得到。在很長一段時間裡,這件事的作法是發一個 PR 去改別人家 repo 的 README。

modelcontextprotocol/servers 這個 repo 是 2024 年 11 月 19 日建立的。它的 README 第一句到今天都還寫著這裡收的是 reference implementations,以及指向社群自建 server 的連結。那份清單當時就是目錄本身:你的 server 在上面,它就存在;不在上面,它就只是你電腦裡的一包程式。

守門人是誰?有 merge 權的人。

於是各家開始自己弄一份。官方後來在設計文件裡把這層叫 subregistry,說 Smithery、PulseMCP 這類服務的價值在策展、評分與額外 metadata,資料則從官方 registry 做 ETL 撈過來。問題是在官方 registry 還不存在的那段時間,這個 ETL 沒有來源。同一個 server 在三份清單上可以有三種寫法、三個名字、三組安裝指令,而且沒有任何一份說得出「這包東西真的是作者本人發的」。

2025 年 2 月,有人開了一個 repo

modelcontextprotocol/registry 建立於 2025 年 2 月 5 日。它的 docs/design/ 底下躺著兩份簡報,檔名分別標著 dev-summit-2025-05dev-summit-2025-10,一份講目標、一份講進度。

設計文件裡有一句話決定了後面所有東西的形狀:MCP registry 是 metaregistry。它存的是 metadata,不存 package 的程式碼或 binary,真正的檔案還是躺在 npm、PyPI、Docker Hub 上。文件自己舉的對照很清楚,registry 說的是「weather-server v1.2.0 在 npm 的 weather-mcp」,而那包程式碼在 npm 那邊。

這句話讀起來像技術細節,其實決定了它要賣什麼。一個不存程式碼的 registry,它唯一的產品是一句話:這個名字對應的那包東西,是那個人發的。這句話要是不可信,整個服務就只剩一個搜尋框。所以發佈流程的重點從來不是上傳,是舉證。

名字不是取的,是證明的

2025 年 7 月 9 日,server.json 的 schema 出了初版,changelog 那一節的內文就一句話:Initial release of the server.json schema。兩個月後的 2025 年 9 月 8 日,registry 打上 v1.0.0 的 tag。

一份 server.json 大概長這樣,這段是官方 repo 範例的節錄:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.modelcontextprotocol.anonymous/brave-search",
"title": "Brave Search",
"description": "MCP server for Brave Search API integration",
"version": "1.0.2",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "@modelcontextprotocol/server-brave-search",
"version": "1.0.2",
"transport": { "type": "stdio" },
"environmentVariables": [
{ "name": "BRAVE_API_KEY", "isRequired": true, "isSecret": true }
]
}
]
}

$schema 那串日期會滾動,2025-12-11 是官方範例現在用的版本。官方的需求文件在 Overview 列了四條額外驗證規則,前三條管「這份檔案講的事情是不是真的」,第四條管 _meta 怎麼保存,性質不一樣,等一下單獨說。

第一關是命名空間。文件寫得很直白:要發到 com.example/server,你得先證明 example.com 是你的。

兩條路。走 GitHub 登入,你的名字 MUST 是 io.github.username/*io.github.orgname/*;走域名,MUST 是 com.example.*/* 這種反向 DNS 形式。順序跟直覺相反:你選哪一條認證方式,就框死了名字能長什麼樣。

域名那條底下還有兩種做法,一筆 DNS TXT,或在自己網站上放一個 /.well-known/mcp-registry-auth 檔。TXT 長這樣:

1
example.com. IN TXT "v=MCPv1; k=ed25519; p=<公鑰的 base64>"

這裡有個坑,官方文件特地拉了一個警告框出來講:TXT 必須放在網域的 apex,也就是 example.com 本身,不能放在 _mcp-auth.example.com 這種 selector 底下。原話說這套跟的是 SPF 那種擺法,DKIM 那種 selector 擺法在這裡不適用。放錯位置不會有人告訴你放錯,registry 根本看不到那筆記錄,你收到的是一個看不出所以然的簽章錯誤。門牌釘在大門上郵差找得到,釘在信箱內側就找不到,差別只有位置。同一個警告框還附帶一句:換金鑰的時候要把舊的 TXT 刪掉,留著的話會被先試到,然後驗證失敗。

另一個安靜失敗的地方在組織命名空間。GitHub 登入永遠給你個人的 io.github.<你的帳號>/*;要拿 org 的名字,你必須是那個 org 的 Owner,一般成員不算。在 CI 裡用 PAT 的話,classic PAT 要給 read:org,fine-grained PAT 要給 Organization permissions 底下 Members 的唯讀權限。少給了會怎樣?文件說它照樣讓你發到個人命名空間,org 那條「silently unavailable」。沒有錯誤訊息,只有一個你沒預期的名字。

這兩個坑長得不一樣,失敗的樣子卻是同一種:安靜的。

TXT 放錯位置,registry 不會告訴你它放錯了。它只是查不到那筆記錄,然後回你一個看不出所以然的簽章錯誤。PAT 少給 read:org,也沒有任何一行字提醒你權限不夠,發佈照樣成功,只是名字變成你個人的那一個。兩種都不是報錯,是缺席。

那要怎麼知道自己中了?只能反過來查結果。TXT 那條,拿 dig 之類的工具自己查一次 apex 上的記錄,確認新的那筆在、舊金鑰那筆已經刪乾淨。org 那條,發佈完去看名字究竟落在 io.github.<帳號>/ 還是 io.github.<org>/。這兩件事都得你主動去看,因為錯的那一邊不會出聲。

光在 server.json 裡寫套件名,不算數

第二關要你在套件本身留一個對得上的記號,設計理由在 registry 的 issue #96:防冒名。這關的邏輯很好懂,你可以在 server.json 的 identifier 填上任何一個 npm 上存在的套件名,但你沒辦法去改那個套件的 package.json

每種 registryType 的作法不一樣,第三關的白名單也綁在一起看比較省事:

registryType 只收這些來源 所有權怎麼證
npm https://registry.npmjs.org package.json 裡的 mcpName 必須等於 server 名字
pypi https://pypi.org README 裡有 mcp-name: <server 名字>,可以藏在 HTML 註解
nuget https://api.nuget.org/v3/index.json 同樣看 README 裡那個字串
cargo https://crates.io 同樣要 mcp-name:,但不能藏在註解裡
oci docker.ioghcr.ioquay.io*.pkg.dev*.azurecr.iomcr.microsoft.com image 上帶 io.modelcontextprotocol.server.name annotation
mcpb https://github.comhttps://gitlab.com 的 releases 下載 URL 必須含 mcp 字串,且要附 fileSha256

cargo 那一格要單獨講,因為同一招搬過去會失效。PyPI 跟 NuGet 保留 README 的 HTML 註解,<!-- mcp-name: io.github.username/widget-mcp --> 藏在裡面驗得過。crates.io 在 markdown 轉 HTML 的時候會把註解整段拿掉,而驗證器看的正是轉完的那份 HTML。你打開自己的 repo,那行字在 README 裡好端端的;打開 crates.io 上那一頁,它不在上面。官方建議改成看得見的一行 bullet,放在 Links 那一段。

mcp-name: 這個字串本身也有規矩:後面必須接一個邊界,換行、空白、HTML tag 或註解的 --> 都可以。黏在句尾的句號前面,寫成 …/database-query-mcp. 這樣,就配不到。至於表格最後一列的 fileSha256,registry 自己根本不驗它——驗的是 MCP client,在安裝之前,所以這一欄在第二關沒有舉證效力,它服務的是下游的安裝端。

上面每一條規則都是官方文件寫的。我沒有跑過 mcp-publisher publish,沒做過 DNS TXT 或 GitHub 那套登入,也沒有去讀 registry 的 Go 原始碼確認驗證實際上怎麼實作。時間點取自 repo 的 release tag 與 changelog 的日期小節。

第三關管的是東西放在哪

第三關最短:只收信得過的公開 registry,私有 registry 跟鏡像站一律不收。完整清單就是上面表格中間那一欄,沒有 Maven,沒有 Go module。

為什麼要管這個。前兩關證明的是你是你、東西是你的,第三關證明的是「別人也能自己驗一次」。一份指向私有 registry 的 server.json,除了作者以外沒有人驗得動,那前兩關的舉證就退回成單方面宣稱。這關在保護的是驗證本身。

回頭說第四條。_meta 裡只有 io.modelcontextprotocol.registry/publisher-provided 這個 key 會被保留,其他 key 在發佈時 silently dropped,不會存也不會被 API 回傳。整包上限 4KB,也就是 4096 bytes,超過會直接失敗並告訴你實際大小。它跟前面那兩個坑是同一個家族:你的 metadata 沒有被拒絕,它只是不見了。

v1.0.0 之後那三個月,schema 改了四次

tag 打上去八天後,2025 年 9 月 16 日,所有欄位名從 snake_case 換成 camelCase。changelog 的原話是「All existing server.json files must be updated.」registry_typeregistryTypeis_secretisSecret,一路到底。

9 月 29 日再一刀,把 status_meta 裡的 io.modelcontextprotocol.registry/official 從發佈者手上收回去,理由是那兩個本來就該由 registry 管、不該讓你自己填。10 月 11 日跟 10 月 17 日兩次調整 package 格式,把 version 改成選填:OCI 的版本本來就寫在 identifier 的 tag 裡,MCPB 給的是直接下載 URL,再填一個 version 只是重複。12 月 11 日加了 URL 樣板變數,讓同一份 server 定義可以套多個租戶的 endpoint,也就是現在範例上那個 2025-12-11

這種改法對一個還在 preview 的東西算合理,對已經發佈過的人不太好受。後來就穩下來了,2026 年 8 月 6 日發的 v1.8.1 是目前最新的一版。

再回頭看開場那個 repo。modelcontextprotocol/servers 的 README 現在最上面壓了一個 IMPORTANT 方塊,寫著要找 MCP server 清單請去 MCP Registry,這個 repo 只留 steering group 維護的少數參考實作;底下還有一整段 Archived,Brave Search、GitHub、Slack、PostgreSQL 那些都搬走了。那份清單自己把自己除役了。

三道關要花的力氣不小,你得動 DNS、動 README、動 Dockerfile 的 LABEL,才換到一次發佈。我認為划算,因為這個成本是一次性的,而它換掉的是每一個安裝的人都要自己判斷一次「這包東西是不是本人發的」。什麼情況下我會改口?如果白名單哪天放寬到任何 registryBaseUrl 都收,或者域名驗證被拿掉、只剩 GitHub 登入一條路,那它就退回成一個搜尋索引,我不會再建議誰為了它去改 README。

文件每一頁最上面到現在都還掛著同一個提示框:registry 仍在 preview,正式上線之前,breaking change 或者資料重置都可能發生。schema 的 changelog 最頂端也還留著一個 Draft (Unreleased) 區塊,裡面躺著一條改動,讓 transport 的 url 可以用樣板變數開頭,等一個日期把它收編進去。

「資料重置」這四個字,放在一份已經有人拿去發正式套件的規格上,看久了有點刺眼。下一個日期會是哪一天,到時候那句話會不會真的被用上,現在還沒有人說。