替既有網站加入 MCP:從 Entra 登入踩坑到 OAuth 動態註冊的完整實戰紀錄

最近我替公司內部的會議錄音與轉錄網站加入 MCP,讓 Claude Code、Codex 這類 AI 工具可以查詢我自己的會議、逐字稿、摘要與筆記。網站已經有 Microsoft Entra ID 登入與使用者白名單,我希望沿用現有認證,不要為了 MCP 再建立一套帳號,也不要新增 Entra ID 的 App registration。

實際做下去,卻從端點網址、HTML 解析錯誤、OAuth resource、localhost callback,一路碰到 Codex 的傳輸設定與 Dynamic Client Registration。最讓我在意的是,第一版明明已經連得上,重新安裝時卻還要貼一大段 TOML。我問了一句「只能這麼麻煩嗎?能否支援 dynamic registration?」,最後改變的就不只是設定範例,而是 MCP 的授權架構。

這篇整理我和 Codex 完成這次功能的完整過程。我會保留走錯的路、當時的誤解、實際錯誤訊息,以及最後為什麼要加入獨立 OAuth 授權層。讀完之後,不只知道最後那一行指令怎麼下,也能分清楚網站登入、客戶端註冊、使用者授權和資料存取之間的關係。

從終端機的連線障礙,經過公司身分驗證與獨立授權入口,連到私人會議資料的概念插畫

我的前端是 Vanilla JavaScript 與 Bun,後端是 .NET 10 Azure Functions isolated worker,搭配 Azure Static Web Apps、Cosmos DB、Blob Storage 與 Microsoft Entra ID。當次測試使用 Codex CLI 0.163.0、Claude Code 2.1.307 與 MCP Inspector 2.12.0。


先交代原本的網站,以及我真正要解決的問題

這個網站原本已經能錄音、上傳檔案、轉錄、產生摘要與管理會議。瀏覽器使用 MSAL 登入 Entra ID,取得網站 API 的 Access Token,再呼叫後端。後端不只驗證權杖,還會查詢 Cosmos DB 中的白名單,確認使用者已啟用,才讓他讀寫自己的資料。

我的需求不是讓 AI 取得整個後台的能力,而是開放四個唯讀工具:

工具 用途 主要參數
list_meetings 分頁列出自己的會議 offset、limit
search_meetings 搜尋標題、逐字稿、摘要與筆記 query、offset、limit
get_meeting 取得單場會議的基本資訊 meetingId
get_meeting_content 分段讀取逐字稿、摘要或筆記 meetingId、contentType、offset、limit

有幾個邊界,我一開始就不打算放寬:

  • 使用者只能查自己的會議,不能靠猜測 meetingId 讀別人的資料。
  • MCP 不提供管理員模擬其他使用者的功能,即使網站後台本來有這個能力。
  • 工具不回傳私人 Blob 路徑、媒體下載網址、服務金鑰或後端內部設定。
  • 逐字稿與筆記是外部資料,可能包含惡意提示文字,不能因為它來自工具回應就當成系統指令。
  • MCP 不新增寫入工具,不讓模型直接刪會議或修改筆記。

讓 AI 看得到工具,與讓使用者有權讀取資料,是兩個必須分別實作的問題。 tools/list 能列出工具,只能證明探索機制有工作,不能證明資料授權正確。

這次的開發也不是一口氣寫到最終架構。我先讓既有認證模式可以連線,再發現安裝體驗不符合需求,才把動態註冊做進服務端。


先把容易混淆的名詞拆開

在這次排錯中,同樣一句「認證失敗」,其實可能發生在完全不同的層次。先把名詞對齊,後面的錯誤才不會全部混在一起。

名詞 在這次實作中的意思
MCP Model Context Protocol,讓 AI 客戶端探索及呼叫外部工具的協定;不是另一套公司帳號系統。
MCP Host/Client/Server 使用者操作的 AI 應用程式是 Host,裡面的 Client 負責 MCP 連線;我新增的會議服務是 Server。日常說的「Codex 連 MCP」,會跨過這幾個角色。
STDIO Client 啟動本機處理序,透過標準輸入與標準輸出交換 MCP 訊息。設定的是可執行命令,不是遠端 HTTP 網址。
Streamable HTTP 透過 HTTP 傳送 MCP 訊息的傳輸方式。名稱有 Streamable,不表示每個回應都必須是 SSE。
Authentication 認證,確認使用者是誰,例如公司帳號登入。
Authorization 授權,判斷某個使用者、某個客戶端,能讀取哪些會議。
App registration Entra 中的應用程式登記,描述應用程式、回呼與權限;不等於每個 MCP 客戶端都一定要新增一筆。
Client ID OAuth 用來識別客戶端的值。它不是密碼,也不代表客戶端已通過品牌或身分審核。
Public Client 無法妥善保管共用 Client Secret 的客戶端,例如安裝在各人電腦上的 CLI。Public 不表示資料公開。
Confidential Client 能在可信任的服務端保護自身憑證的客戶端,與 Public Client 的安全前提不同。
Access Token 呼叫受保護資源時出示的存取權杖。持有 Bearer Token 的人通常就能使用它,所以不能公開。
Refresh Token 用來換取新 Access Token 的更新權杖,不拿來直接呼叫會議工具。
Scope 權限範圍。第一版使用 Entra 的 access_as_user,新版 MCP 授權層只提供 meetings.read。
Resource/Audience 分別從請求與權杖驗證角度界定權杖要用在哪個資源,不能只看使用者登入成功。
Redirect URI/Callback 登入或授權結束後,瀏覽器返回客戶端的位置。CLI 常在本機暫時開一個 HTTP listener 接收結果。
PKCE Proof Key for Code Exchange,讓換取權杖的客戶端證明自己持有發起流程時產生的隨機秘密。
DCR Dynamic Client Registration,客戶端透過註冊 API 取得 Client ID;不是自動替使用者核准資料存取。
PRM Protected Resource Metadata,告訴客戶端這個資源要去哪個授權服務登入。
Issuer 發行權杖或提供授權服務的識別網址,新版服務使用固定設定值,不能任由請求的 Host 決定。

另外有一個貫穿全文的差別:不新增 Entra ID 的 App registration,不等於不能建立 MCP 客戶端註冊資料。 最後我把兩者分開處理,才讓使用者只填網址,同時保留既有公司帳號。


第一版:沿用 Entra 權杖,先把唯讀工具做出來

第一版採用 Azure Functions 的 MCP 擴充套件,透過工具觸發器接上既有後端。當時用到的套件包括:

<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp"
                  Version="1.6.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp.Sdk"
                  Version="1.0.0-preview.4" />

這是本次使用的組合,不是建議永遠鎖在這個版本。尤其第二個套件當時使用 preview 版,後面遇到的型別轉換與結果封裝問題,都要放回這個版本背景理解。官方入口可以參考 Azure Functions MCP bindings。

擴充套件提供的遠端端點是:

https://meetings-api.example.com/runtime/webhooks/mcp

以下以替代網域表示當時使用的 Function App 端點:

https://meetings-api.example.com/runtime/webhooks/mcp

第一版的工具授權沒有信任模型傳進來的 owner。後端從已驗證權杖取出使用者的 oid,再用這個身分決定 Cosmos partition,會議資料放在 user:<oid> 分割區內。即使客戶端帶入別人的會議 ID,也不會因此改變查詢的使用者分割區。

網站原本有管理員模擬使用者功能,但 MCP 另外拒絕 X-Impersonate-User-Id。客戶端自己加上的 X-MS-* principal 類型標頭,也不會取代權杖驗證。這是把網站後台能力與 AI 工具能力分開的具體做法。

先釐清 Anonymous 到底關掉哪一層

第一版 host.json 有這段設定:

{
  "extensions": {
    "mcp": {
      "system": {
        "webhookAuthorizationLevel": "Anonymous"
      }
    }
  }
}

這裡的 Anonymous 是移除擴充套件端點要求 Function 系統金鑰的那一層。當時遠端仍由 App Service Authentication,也就是 EasyAuth,檢查 Entra 認證,工具內還會驗證權杖與白名單。

不要只看到一個 Anonymous,就推論整個服務可以匿名讀取。也不要反過來以為 EasyAuth 已登入,工具就不必檢查資料擁有者。 必須確認這個設定作用在哪一層,再看其他層是否真的補上。


第一次誤解:把網站網域換上去,不代表它就是 MCP 端點

我原本希望端點可以是:

https://meetings.example.com/mcp

這個網址比較容易記,也和原網站一致。不過現有前端架在 Azure Static Web Apps,後端是另外連結的 Function App。Static Web Apps 的 API 代理有自己的路由限制,不會因為我在網址後面加上 /mcp,就自動把請求送進 Functions MCP 擴充套件。

排錯時還出現過這個組合:

https://meetings.example.com/runtime/webhooks/mcp

路徑看起來和 Functions 一樣,主機卻仍是網站。Claude 顯示的錯誤是:

Status: failed
Issue: Error POSTing to endpoint
Auth: not authenticated
SDK auth failed: JSON Parse error: Unrecognized token '<'

這個 < 很有提示性:客戶端期待 JSON,卻讀到 HTML。 在當時環境,請求落到網站路由,沒有到預期的 MCP 服務。這不是少打一個 OAuth scope,也不是把 JSON parser 換掉就能解決。

但要注意,Unrecognized token '<' 本身不是唯一根因。其他環境也可能是登入頁、反向代理錯誤頁或 HTML 的 404。判斷方式應該是查看 HTTP status、Content-Type、重新導向,以及少量不含機密的回應開頭。例如先對候選 MCP 網址送出初始化:

curl -i --max-time 20 \
  -X POST 'https://<candidate-host>/<candidate-path>' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"endpoint-check","version":"1.0"}}}'

對受保護的正確端點,未登入時回傳 401 可以是預期行為;真正要看的是它是否附帶可用的授權探索資訊,而不是一定要看到 200。

Microsoft 的 Static Web Apps API 限制 說明 API 路由前綴是 /api,代理請求也有 45 秒限制。因此當時我選擇直接使用 Function App 網址,沒有新增 Front Door、沒有為了 /mcp 調整 DNS。

這裡也要修正一個容易講過頭的說法:不是 Azure 永遠不能提供同網域 MCP,也不是 Azure Front Door 是唯一解。 當時只是現有網站的 /mcp 沒有接上後端。後來新增 /api/mcp,又是另一個路由條件;是否能完整經過網站代理,仍須連同根路徑 metadata、認證與實際客戶端一起驗證,不能只憑路徑猜測。


第二次誤解:沿用網站登入,不代表 CLI 已經有可用的登入狀態

我提出「直接沿用目前網站認證」時,真正想保留的是公司帳號、既有註冊與使用者白名單。這個需求合理,但它不代表外部 CLI 可以直接拿走瀏覽器裡的 MSAL 快取。

第一版讓 MCP 客戶端自己跑 OAuth Authorization Code + PKCE,取得發給既有 API 的 Entra Access Token。換句話說,沿用的是認證體系,不是共用某一份瀏覽器儲存空間。

當時為了沿用現有註冊,我們保留原本網站的 SPA redirect URIs,再加入實際工具使用的 public client callback。Inspector、Claude Code、Codex 的 callback 都不一樣,不能把同一個網址隨意貼到每個客戶端。

Resource 與 identifierUris 不一致,遇到 AADSTS9010010

有一次 OAuth 流程已經走到 Entra,仍因 resource 比對失敗而中斷,錯誤碼是:

AADSTS9010010

MCP 客戶端會表明它想存取的資源。第一版用的是完整 MCP 端點 URL,但既有 API 註冊原本的 Application ID URI 是 api://<api-client-id>。在這個案例裡,Entra 的 resource 比對沒有接受那個 MCP 網址。

最後的處理是保留原有 api://...,另外把完整舊 MCP 端點加入既有 API 的 identifierUris。這次既有 API 已使用 v2 權杖,沒有為此新增註冊或改掉原 scope。具體判斷可參考 Microsoft 的 MCP 與 Entra ID 整合說明。

Resource Indicator 的基礎定義在 RFC 8707。我會把它理解成:客戶端不只說「我要登入」,還會說「這次登入拿到的授權,是要用在這個資源」。

還有一個文件背景要交代:App Service 的 MCP 授權文件建議為 MCP 使用獨立的 identity provider registration;另一份 Entra MCP 文件則說明,既有受保護 API 可以重用註冊與 scope。這次是在不新增註冊的專案條件下採用後者,再於新版隔離 MCP 權杖,不應把它寫成所有組織都應共用同一個應用程式註冊的通則。

這個修正只屬於直接使用 Entra 的舊端點。不要看到這段就把新版 /api/mcp 也機械式加進同一份 Entra 設定。新版已經改由自己的 MCP 授權服務發行專用權杖。

localhost 與 127.0.0.1 的失敗,不能直接推論要補 Secret

舊版 Codex callback 測試還遇到:

AADSTS7000218

當時流程要求提供 client_secret 或 client_assertion。表面上看像是少了一個 secret,但本機 CLI 的 Public Client 流程不應該靠散布共用 secret 解決。

我們回頭核對既有註冊的平台類型與完整 callback。當次測試中,改用已加入 public client 設定的 http://localhost:6277/... 後成功;先前使用 127.0.0.1 的組合則失敗。

這是那一組註冊、平台與 callback 的實測結果,不是 localhost 天生比 127.0.0.1 安全,也不是 Entra 一律不能使用 127.0.0.1。 面對這類錯誤,要核對客戶端實際送出的 client_id、redirect URI、平台與 grant flow,而不是一看到缺 secret 就建立一把。


工具列得出來,真正查資料時卻又壞了

認證接通後,還有幾個問題藏在 SDK 與資料層。這段也說明為什麼「MCP Inspector 可以 Connect」不足以當作驗收完成。

綁定型別與 GUID 參數轉換

在當時的 Azure Functions MCP 擴充套件組合裡,觸發器參數要使用 ToolInvocationContext。最初用字串接 invocation 的方式拿不到預期內容。

接著又發現,預設參數轉換會把看起來像 GUID 或日期的字串轉成其他型別。對會議 ID 與搜尋字串而言,我要的是原始字串,而不是讓 SDK 替我推測型別。

最後在這個版本中,透過 FunctionContext.BindingContext.BindingData["invocation"] 取得原始 JSON,再依工具定義解析參數。這是針對當時套件行為做的修正,不應解讀成所有 MCP SDK 都需要繞過型別轉換。

structuredContent 被包成文字,結果結構與預期不同

工具需要回傳可供程式讀取的結構化資料,同時保留文字內容。當時使用 CallToolResult,但 result schema 的 metadata 經過擴充套件處理後,結果標記沒有如預期保留,導致內容又被包了一層文字。

修正方式是在核心擴充套件的 metadata transformer 之後,再套用對應的結果 schema transformer,讓輸出維持預期的 structuredContent 與文字 JSON。

這類問題的驗證方式,不是只看畫面有沒有出現文字,而是實際檢查 JSON-RPC 結果:Client 應該能取得結構化欄位,不需要先拆開一層文字再自己猜格式。

Cosmos 兩欄排序需要相容索引

另一個問題是在正式環境查詢時才發現。原本為了讓同時間的資料排序穩定,查詢寫成:

ORDER BY c.createdAt DESC, c.id ASC

兩個欄位的排序需要對應的 Cosmos 複合索引。現有資料庫沒有適用索引,結果列表與搜尋查詢失敗。當時單元測試使用讀取介面的替身,不會替正式資料庫驗證索引。

最後我先沿用網站既有的排序:

ORDER BY c.createdAt DESC

這個選擇避免了額外索引異動,但也有明確代價:建立時間完全相同的資料,不保證彼此的相對順序;而 offset 分頁期間如果有新增或刪除,頁面內容也可能移動。我把這個限制記錄下來,沒有把它包裝成穩定快照分頁。

文字分段也要定義單位

逐字稿可能很長,所以 get_meeting_content 不是一次送出全文。這版每次最多回傳 10,000 個 UTF-16 code units,並提供 nextOffset。

這裡說的是 UTF-16 單位,不是 10,000 個中文字,也不是 10,000 個模型 Token。實作會避免把 surrogate pair 切成兩半,例如某些表情字元的代理配對;但這不等於完整保證每個 grapheme cluster 都不被切開。

Client 應直接沿用伺服器回傳的 nextOffset,不要自己用模型 Token 數推算下一段。如果分段期間文字有更新,還要留意 updatedAt,必要時重新讀取。


Codex 的第一個錯誤:有 OAuth,不代表支援動態註冊

接著來到整件事最重要的轉折。我用正確的 HTTP 形式新增 MCP:

codex mcp add duotify-meetings \
  --url https://meetings-api.example.com/runtime/webhooks/mcp

Codex 回應:

Added global MCP server 'duotify-meetings'.
Detected OAuth support. Starting OAuth flow…
Error: Registration failed: Dynamic registration failed: Registration failed: Dynamic client registration not supported

當時最容易產生的誤解,是把 Detected OAuth support 當成「伺服器已具備完整自動登入能力」。其實這只能證明客戶端探索到 OAuth,還不能證明授權服務支援客戶端打算使用的註冊方式。

我查核 App Service 的 MCP 授權文件 後,確認 Microsoft Entra ID 不提供這裡需要的 Dynamic Client Registration。Entra 可以當 OAuth 授權服務,和它是否提供 DCR,是兩個不同問題。

另外,第一行 Added global MCP server 已經表示設定寫進去了。後面的登入失敗,不會自動把那筆設定回復掉。所以再次執行 add 前,要知道自己是在新增、覆寫,還是只需要重新登入。

DCR 到底省掉什麼工作

依 RFC 7591,客戶端可以送出註冊 metadata,例如名稱、redirect URIs、grant types 與 token endpoint 的認證方式,再取得 Client ID。

它省掉的是預先手動交換這些註冊資料的工作,不是跳過使用者登入與同意。任何人知道註冊端點,都不應因此取得會議資料。

OpenAI 的 MCP 設定文件 也區分預先設定 Client ID、DCR,以及 Client ID Metadata Documents,也就是 CIMD。CIMD 用文件描述客戶端 metadata,是另一種方案;本次實作選擇 DCR,沒有宣稱一併支援 CIMD。DCR 也不是 MCP 所有版本中唯一的註冊途徑。


Codex 的第二個錯誤:少了 –url,就變成本機命令

在排錯過程中,我移除設定後,換了一種寫法:

codex mcp remove duotify-meetings
codex mcp add duotify-meetings https://meetings-api.example.com/runtime/webhooks/mcp
codex mcp login duotify-meetings

結果新增成功,登入卻出現:

Error: OAuth login is only supported for streamable HTTP servers.

這次不是 DCR,也不是 Entra。問題是 add 指令少了 --url。 當次 Codex 把位置參數當成啟動 STDIO server 的命令,把那個 HTTPS 網址寫進命令設定。

因此同一個看起來像網址的字串,在兩種指令中意思不同:

寫法 Codex 得到的設定 適用情境
codex mcp add name --url https://... 遠端 Streamable HTTP 本文的會議 MCP
codex mcp add name -- some-command 啟動本機 STDIO 處理序 本機可執行的 MCP server
少了 --url,直接把網址放在命令位置 網址被當成本機命令 本次錯誤設定

Added global MCP server 只表示資料成功寫入,沒有保證傳輸方式符合我的意圖。這也是我後來要求文件必須附完整指令,不能只寫「加入這個 URL」的原因。


能用的暫時方案,為什麼還是不夠好

第一版最後確實連通了。我們讓支援預先指定 Client ID 的客戶端,沿用既有網站註冊,再補上對應 callback 與 scope。

舊版 Codex 設定的輪廓如下。這是歷史排錯範例,不是新版安裝方式:

[mcp_servers.duotify-meetings]
url = "https://meetings-api.example.com/runtime/webhooks/mcp"
scopes = ["api://<api-client-id>/access_as_user"]

[mcp_servers.duotify-meetings.oauth]
client_id = "<existing-frontend-client-id>"
callback_port = 6277
callback_url = "http://localhost:6277/callback/<server-specific-callback-id>"

這裡的 callback ID 不能從別人的範例直接照抄;它和客戶端對該 MCP 網址產生的 callback 有關。若使用的 Codex 版本或網址改變,也必須重新核對。

另一個操作細節是 CODEX_HOME。Codex 預設讀取 ~/.codex/config.toml,但 Agent 執行環境可能指定另一個目錄。如果改的是 Agent 自己那份設定,使用者終端機不會跟著生效。當時就是要區分這兩種設定來源,才沒有把「已修好」建立在錯誤的檔案上。

Claude Code 也有一個容易誤判的狀況:完成登入後,狀態仍顯示需要認證。當次追查發現,它保留了先前這個服務的失敗快取;只移除 ~/.claude/mcp-needs-auth-cache.json 中對應的 duotify-meetings 項目,再檢查就顯示 Connected。這是當次版本的觀察,不是建議讀者每遇到登入問題就清除整份設定與憑證。

這些補救做法都能解決眼前問題,但安裝體驗仍然有幾個缺點:

  • 使用者得知道 Client ID、scope 與 callback 是什麼。
  • callback 必須在現有 Entra 設定中預先準備。
  • 刪掉再新增時,容易把手動補上的 OAuth 設定覆蓋掉。
  • 換一個客戶端,就可能重新遇到平台與 callback 差異。

更重要的是,第一版雖然只有唯讀 MCP 工具,使用的卻是網站 API 的 access_as_user 權杖。工具唯讀,不等於那張權杖只能唯讀。 持有人仍可能拿它去呼叫原本 scope 允許的網站 API。這不能只靠把工具描述寫成 read-only 解決。

所以我後來不再把「提供一份更長的設定範例」當成終點,而是要求:只輸入 MCP 網址,就能完成客戶端註冊與公司帳號授權。


最後的架構:保留公司登入,另外發出 MCP 專用權杖

要做到一行 add,我們沒有讓 Entra 突然支援 DCR,也沒有替每個外部客戶端呼叫 Microsoft Graph 建立新的 App registration。

最後在既有後端加入自有 OAuth 授權層:動態客戶端註冊、授權要求、一次性授權碼、Grant 與權杖資料,都存進原本的 Cosmos DB。公司帳號登入仍由網站既有 MSAL 與 Entra 處理。

這裡有兩條分開的信任路徑:

  1. 網站身分路徑:瀏覽器透過既有 Entra 登入,取得網站 API 權杖;後端驗證並查白名單,確認目前是誰。
  2. MCP 授權路徑:這個使用者在網站明確同意某個客戶端讀取會議,授權層才發出只能在新 MCP 端點使用的專用權杖。

MCP 客戶端拿到的是不透明的 MCP Token,不是瀏覽器的 Entra Access Token。後端直接呼叫既有的會議讀取服務,不需要把網站權杖轉交給模型,也沒有把同一張權杖原封不動送往另一個下游 API。

為什麼最後換成 /api/mcp

原本 /runtime/webhooks/mcp 的 HTTP 握手由 Azure Functions MCP 擴充套件處理。我們這次沒有在那個入口內硬塞新的認證模式,而是新增由應用程式自己處理的 HTTP MCP 端點,重用原本的會議服務。

項目 舊端點 新端點
路徑 /runtime/webhooks/mcp /api/mcp
MCP HTTP 入口 Functions MCP 擴充套件 自有 HTTP Function
Client registration 預先提供既有 Entra Client ID 自有 OAuth DCR
公司帳號登入 客戶端直接走 Entra OAuth 經既有網站登入,再確認 MCP 授權
客戶端持有的 Token Entra API Access Token MCP 專用不透明 Token
權限 原網站的 access_as_user 固定 meetings.read
固定 callback 的維護 要對照 Entra 註冊 DCR 記錄客戶端當次提供的 callback
舊設定相容性 保留 建議新安裝使用

不是 MCP 協定強迫一定要叫 /mcp。 路徑是服務部署的選擇;真正重要的是客戶端連到正確入口,探索 metadata 時也得到同一套授權設定。

新端點目前以無狀態的 Streamable HTTP 形式回傳 JSON,處理 initialize、ping、tools/list、tools/call,不提供 SSE 訂閱或 HTTP session。當次實作明確支援 2025-03-26、2025-06-18、2025-11-25 這三個協定版本,沒有因為文章日期較晚,就宣稱已支援所有較新的協定版本。


從第一次 401 到拿到 MCP Token,實際會走哪些步驟

執行新增指令後,客戶端會依序探索授權服務、註冊 callback,再開啟瀏覽器取得使用者同意。

1. 未登入先回 401,提供資源探索位置

客戶端呼叫新 MCP 端點,沒有 Access Token 時,服務會回傳 401,並附上類似以下的標頭。主機名稱在這段以佔位符表示:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://meetings-api.example.com/.well-known/oauth-protected-resource/api/mcp", scope="meetings.read", error="invalid_token"
Cache-Control: no-store

PRM 文件的內容輪廓如下:

{
  "resource": "https://meetings-api.example.com/api/mcp",
  "authorization_servers": [
    "https://meetings-api.example.com"
  ],
  "scopes_supported": ["meetings.read"],
  "bearer_methods_supported": ["header"]
}

這份文件是公開的指路資訊,不含使用者資料或權杖。它讓客戶端知道該去哪個授權服務,細節可參考 RFC 9728。

2. 從授權服務 metadata 找到註冊與 Token 端點

新版另外提供:

/.well-known/oauth-authorization-server

其中包含固定 issuer、authorization_endpoint、token_endpoint、registration_endpoint,以及以下能力宣告:

{
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"]
}

none 的意思是這個 Public Client 不以共用 secret 向 Token Endpoint 認證,不代表 Token Endpoint 不驗證授權碼、PKCE、Client ID 或 callback。

3. 客戶端向 register 註冊自己的 callback

客戶端向 /api/oauth/register 送出 JSON,輪廓如下:

{
  "client_name": "Codex",
  "redirect_uris": [
    "http://127.0.0.1:49152/callback/<callback-id>"
  ],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

伺服器驗證 metadata 後回傳 201 與 Client ID。本版只接受 HTTPS callback,或明確允許的 loopback HTTP 主機 localhost、127.0.0.1、[::1];拒絕 fragment、網址內嵌帳密,以及會干擾回應的 code、state、error 查詢參數。

註冊後的授權要求,必須使用該次登記的完整 redirect URI。DCR 會把客戶端當次選定的本機 port 一起記下來,所以不需要在 Entra 為每個 CLI listener 預先登記一份回呼。

客戶端產生 state、PKCE verifier 與 S256 challenge,開啟 /api/oauth/authorize。後端先檢查 Client ID、redirect URI、scope、resource 與 PKCE,再建立十分鐘有效的授權要求。

瀏覽器接著被導向網站:

https://meetings.example.com/#mcp-authorize=<opaque-request-id>

這個 fragment 不是 Entra Token,也不是 MCP Access Token。網站先暫存在 sessionStorage,移除網址上的 fragment,再繼續既有登入流程,避免登入跳轉後丟失原本的 MCP 授權要求。Hash fragment 不會作為 HTTP request target 送到伺服器,但這個暫存識別值仍不應隨意公開。

登入完成後,網站顯示客戶端名稱、callback、可讀取的資料,以及同意或拒絕按鈕。客戶端名稱是註冊者自己填的,所以畫面也提醒:請確認這是自己剛發起的連線,不能光看到 Codex 三個字就認定它已被驗證。

本次 MCP 授權視窗的桌面版畫面,顯示客戶端、回呼網址、讀取範圍與允許或拒絕按鈕;此圖為本機視覺驗證畫面

網站送出同意時,還要帶既有網站 API 的 Entra Bearer Token。後端重新確認使用者、拒絕模擬身分,並檢查瀏覽器 Origin。這不是只靠網址中帶了一個 request ID 就發出會議權限。

5. 同意後回傳一次性 code,客戶端再用 PKCE 換 Token

同意後,服務把兩分鐘有效的 Authorization Code 送回已登記的 callback,同時原樣帶回 state。客戶端負責確認 state 和它發起流程時相同,再把 code 與原本的 verifier 送進 /api/oauth/token。

S256 的關係可以寫成:

code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))

驗證端要確認 Client ID、完整 redirect URI 與 challenge 都符合。只攔截到 code,不應該足以換出權杖。 verifier 不會先經過瀏覽器 callback 傳回,這是 PKCE 要解決的風險;原理可參考 RFC 7636。

完成交換後,客戶端拿到 MCP 專用 Access Token 與 Refresh Token,再以 Authorization: Bearer ... 呼叫新 MCP 端點。


把設定變簡單,服務端就要承擔更多授權責任

做出 /register 回傳一個 Client ID,其實只是最前面的一步。這次真正新增的工作,是後面那套有限制、可過期、可撤銷的授權狀態。

網站已登入,不表示使用者已允許新出現的 MCP 客戶端。最後的實作每次發起授權都顯示確認畫面,沒有用公司 SSO 成功就直接略過這一步。

這和 MCP 安全文件提到的 Confused Deputy 與 Token Passthrough 有關:一個原本擁有權限的服務,不能在沒有適當授權的情況下,替不可信任的客戶端借出那份權限。本次不是替下游 API 轉送權杖的代理,但仍保留逐客戶端、逐次明確同意的界線。

使用不透明 Token,不把它當 JWT 解碼

新版 Access Token 是隨機不透明字串。客戶端不能靠解碼取得權限,後端則用雜湊索引查詢對應紀錄,再檢查 Grant、期限與使用者是否啟用。

權杖原文不存進 Cosmos。資料庫記的是 SHA-256 digest 建立的索引,以及必要的 Client ID、Grant ID、owner 與期限。本版的權杖秘密來自密碼學安全亂數,不是人類容易猜的密碼;不要把這個設計誤套成人類密碼可以只用快速 SHA-256 儲存。

這個選擇的取捨也很明確:每次查詢會需要讀取授權狀態,增加資料庫請求;好處是撤銷與停用帳號後,後續請求可以立刻拒絕,不必只等 JWT 的到期時間。

權杖與 Grant 的期限分開計算

項目 本次實作期限 作用
瀏覽器授權要求 10 分鐘 防止舊確認要求長期留存
Authorization Code 2 分鐘,單次使用 完成 callback 後換取權杖
Access Token 最長 1 小時 呼叫四項 MCP 工具
Refresh Token 每次使用即輪替,受 Grant 期限限制 換取下一張 Access Token
Grant 30 天絕對期限 使用者授權給這個連線的生命週期
動態客戶端註冊 30 天 本版客戶端註冊紀錄的有效時間

Grant 的絕對期限不會因為 refresh 而不斷往後延。還有一個實作限制:Client registration 本身也有到期時間,所以較早建立的 Client ID,可能讓後來建立的 Grant 在未滿三十天前就因客戶端到期而不能使用。這不是無限期維持登入的設計。

本次只實測初次 DCR、登入、更新與撤銷,沒有等三十天實測每個 CLI 對註冊到期的自動恢復。因此到期後若既有登入失敗,應重新註冊;對這次 Codex 設定,可移除同名服務再用新版指令新增,不保證單純 login 一定替換過期 Client ID。

Refresh Token 重複使用,撤銷整個連線

Refresh Token 一旦使用成功就標成已使用,下一次回應會產生新的 Refresh Token。若又拿舊 Token 來換,服務會把整個 Grant 撤銷,連同已發出的 Access Token 都不再接受。

這也代表客戶端要避免對同一個 Refresh Token 同時發出多個更新要求。重複請求可能來自被竊取後重放,也可能是客戶端自身的並行錯誤;伺服器不會因為「也許只是重試」就放行第二次使用。

TTL 只負責清理,不能代替有效期限驗證

Cosmos 的 TTL 讓過期資料自動清除,但實體刪除不是應用程式判斷有效性的唯一依據。因此每次讀取仍檢查 expiresAt,不能因為資料還查得到,就把它視為有效。

接著還有跨執行個體的競爭問題。如果兩個 Functions worker 同時讀到同一個未使用 code,不能讓兩個都成功換 Token。

這裡使用 ETag 加上 IfMatchEtag。後來檢查實作時,又補了一個修正:一次性資料必須用條件式 replace,不能使用有機會在項目不存在時變成新增的 upsert。

概念上的程式碼如下:

var response = await container.ReplaceItemAsync(
    document,
    document.Id,
    new PartitionKey("mcp-oauth"),
    new ItemRequestOptions
    {
        IfMatchEtag = expectedETag
    },
    cancellationToken);

如果另一個請求先更新,ETag 不符就失敗;如果紀錄已由 TTL 刪除,也不能重新建回來再視為交換成功。這個補強發佈為 v0.10.1。Cosmos 的背景知識可參考 Optimistic Concurrency Control。

這不表示整個多筆權杖發行流程因此就成為單一資料庫交易。若消耗 code 後,建立後續紀錄失敗,使用者可能需要重新登入。這版優先保證舊憑證不會被第二次兌換,沒有宣稱提供完整的交易式恢復機制。

公開入口加上限制,不能只相信客戶端自律

新版對註冊、授權、Token 與撤銷要求設有跨 worker 的頻率預算,透過 Cosmos 紀錄每分鐘計數,不信任客戶端自行提供的 IP 標頭。

目前是按操作計算的全域預算,不是每 IP 的獨立配額,也不等於完整 DDoS 防護。它還有請求內容大小、metadata 格式、callback、scope 與 resource 檢查。這些是本版實際做的邊界,不能因為有 rate limit 就宣稱所有濫用情境都被消除。


EasyAuth 與根路徑 metadata,還要一起調整

新增自己的 MCP Token 後,如果 EasyAuth 仍在新端點前面只接受 Entra Token,請求會在到達程式前被拒絕。因此這次只把以下精確路徑排除在 EasyAuth 的強制登入外:

/api/mcp
/api/oauth/register
/api/oauth/authorize
/api/oauth/token
/api/oauth/revoke
/.well-known/oauth-authorization-server
/.well-known/oauth-protected-resource/api/mcp

其中 /api/mcp 在應用程式內驗證 MCP Token;其餘是探索或 OAuth 公開入口,各有自己的流程驗證。排除 EasyAuth 不是排除應用程式授權。 同意與連線管理 API 沒有一起開放,它們仍要求既有網站的 Entra 身分。

為了讓標準 /.well-known/... 文件位於主機根路徑,Functions 的 HTTP routePrefix 改成空字串,再把原本 58 條既有 HTTP route 明確寫上 api/。

{
  "extensions": {
    "http": {
      "routePrefix": ""
    }
  }
}

例如原本宣告 Route = "meetings",搭配預設 prefix 對外是 /api/meetings;改完後宣告成 Route = "api/meetings",對外仍是 /api/meetings。這次逐條比對了既有 API,避免新增 MCP metadata 時讓整個網站 API 路徑跟著變掉。

issuer 則從部署設定 McpOAuth__Issuer 取得,固定為 Function App HTTPS origin。不能因為請求帶了不同 Host,就把 metadata 裡的發行者與 Token Endpoint 組成另一個網域。


最後給使用者的安裝指令

以下安裝命令使用替代網域,請換成自己的 Function App 網址:

https://meetings-api.example.com/api/mcp

Codex

第一次安裝,只需要:

codex mcp add duotify-meetings \
  --url https://meetings-api.example.com/api/mcp

當次實測輸出包含:

Added global MCP server 'duotify-meetings'.
Detected OAuth support. Starting OAuth flow…
Successfully logged in.

中間會開啟瀏覽器,讓我使用公司帳號登入並確認讀取授權。沒有手動填 Entra Client ID、scope 或固定 callback。

如果已經有同名的舊端點設定,先移除再新增:

codex mcp remove duotify-meetings
codex mcp add duotify-meetings \
  --url https://meetings-api.example.com/api/mcp
codex mcp list

如果新增後沒有自動啟動登入,可再執行:

codex mcp login duotify-meetings

不要把舊版 /runtime/webhooks/mcp 的手動 OAuth 設定複製到新版。兩者使用不同授權服務,網址與 Client ID 必須成套理解。

Claude Code

Claude Code 的新設定範例是:

claude mcp add --transport http duotify-meetings \
  https://meetings-api.example.com/api/mcp

接著在 Claude Code 執行 /mcp,選擇對應服務進行登入。若已有同名舊服務,要先在原本的設定範圍移除,再新增,避免一份在專案、一份在使用者範圍而看錯設定。

這裡也要把驗證狀態寫清楚:第一版已實測 Claude Code 連接舊端點,新版已完成 Codex 的真實 DCR 登入與獨立協定驗收;本文沒有把新版 Claude Code 的端到端登入當成已完成的測試。上面的命令依 Claude Code 官方 HTTP MCP 設定方式 與目前端點提供,不由 Codex 成功推定所有客戶端都相容。


驗收不能只看 Connected,也不能只看單元測試數字

這次完成時,本機建置通過,616 項前端測試與 213 項後端測試通過,共 829 項。當中不全是 MCP 新增測試,而是整個專案當次執行的總數。

更重要的是,正式環境另外跑了協定與瀏覽器驗收:

驗證項目 當次結果與範圍
未登入呼叫 MCP 回傳 401,包含 PRM 指向
公開 metadata 可匿名讀取,issuer、scope 與註冊端點符合設定
不安全 callback 註冊要求遭拒絕
Codex 一行新增 完成 DCR、瀏覽器授權與 Token 交換
工具探索 正確回傳四項工具與參數 schema
資料查詢 實際列出、搜尋自己的會議,讀取基本資訊與文字片段
模擬使用者 拒絕 X-Impersonate-User-Id
PKCE、client、callback 與 resource 綁定 自動測試驗證錯誤組合遭拒絕
一次性 code 的並行交換 自動測試確認只有一次成功
Refresh Token 輪替與重放 自動測試與正式流程分別驗證輪替、重放撤銷規則
網站撤銷 從 MCP 連線清單撤銷測試 Grant,後續 Access Token 呼叫立即回 401
白名單停用與到期 自動測試確認不能繼續使用既有權杖與 refresh
桌面與手機版授權視窗 實測 390px 畫面沒有水平溢出,初始焦點在拒絕按鈕
CI 與部署 對應最終提交的管線完成,正式網站版本與新端點另行確認

網站授權視窗使用原生 <dialog> 與 showModal(),讓瀏覽器處理模態焦點。手機版也保留 callback 的完整可讀內容,不讓長網址把視窗撐出畫面。

MCP 授權確認的 390px 手機版畫面,完整顯示客戶端與 callback,預設焦點在拒絕按鈕

驗證完成後,測試用連線已撤銷,保留實際使用的 Codex 連線。這樣不只證明能登入,也證明使用者可以收回權限。

用提交紀錄回看,這次其實分成六個步驟

以下短 SHA 取自這個專案的 Git 紀錄,用來對照本次開發順序;儲存庫不是本文提供的公開原始碼下載來源。

提交 這一步實際完成的事
6117ec3 v0.9.0 加入沿用 Entra 認證的四項唯讀 MCP 工具。
39122d9 v0.9.1 修正 Cosmos 排序,補齊舊端點的 resource 與 Inspector callback 設定。
252e450 補上 Claude Code 連線設定與當次正式驗收結果。
980cf3d 把正式端點及 Claude、Codex 設定範例寫進變更記錄,避免使用者不知道如何安裝。
f227acd v0.10.0 新增 DCR、網站授權、MCP 專用權杖與新 HTTP 端點。
b6e4883 v0.10.1 將一次性紀錄更新改成條件式 replace,避免到期資料被重建。

這張表也說明,前面幾次「已完成」指的是當時那一版的需求與驗證範圍。後來我要求簡化重裝流程,才產生新的授權層;不能把最後的結果倒寫成一開始就已經規劃好所有細節。

發佈等待,也曾經造成誤判

這次多個版本都經過 Azure Pipelines。前面一次等待時間較長,是工具與套件下載,不是程式已經失敗。最後 v0.10.1 的 CI 完成時間約十分鐘。

專案還記錄了一個查 CI 的陷阱:某些 az pipelines runs list 用法預設依 finishTime 排序;進行中的 run 沒有 finish time,若只取前幾筆,可能看不到它。看不到不等於沒觸發,更不應立刻手動再排一次。

所以這次用專案的 make ci-watch 或 bun scripts/watch-ci.js,依 queue time 查詢,並對照完整 commit SHA。推送成功、CI 成功、網站已更新、新 MCP 能登入,是不同層的驗證結果,不能互相替代。


把這次遇到的錯誤,按層次整理

現象或錯誤 這次確認的原因 修正方向
Unrecognized token '<' 請求落到網站,回傳 HTML 先確認 host、path、HTTP status 與 Content-Type
AADSTS9010010 舊版 Entra 流程的 resource 與 API 識別 URI 不符 核對既有註冊與 MCP 完整資源網址
AADSTS7000218 當次 Public Client 平台與 callback 組合不符合預期 核對實際 callback 與註冊平台,不直接加 secret
Dynamic client registration not supported 客戶端嘗試 DCR,但直接使用的 Entra 授權服務不支援 舊版預先提供 Client ID;新版使用自有 DCR 授權層
OAuth login is only supported for streamable HTTP servers Codex 少了 --url,把網址存成 STDIO 命令 刪除錯誤設定,以 HTTP 形式重建
登入成功仍顯示需要認證 當次 Claude 留有該服務的失敗快取 先核對設定,再只處理對應服務,不清全部憑證
tools/list 成功,列表工具失敗 Cosmos 正式索引不支援兩欄排序 改用既有排序或另外規劃索引,不把 mock 測試當整合測試
工具結果多包一層文字 當次擴充套件的結果 metadata 處理順序 檢查實際 JSON-RPC 的 structuredContent 與版本行為
程式測試通過,還不能說上線 CI、部署與用戶端狀態是不同階段 對照 SHA、正式頁面與實際登入結果

這張表只描述本次已查證的原因。相同錯誤文字在其他環境可能有不同根因,排錯仍要回到實際 HTTP、設定與註冊狀態。


總結

我原本只是想替既有網站增加四個 MCP 工具。真正花時間的地方,卻是把「沿用公司登入」轉成一個能讓外部客戶端正確註冊、取得明確授權、限定資料範圍,而且可以撤銷的流程。

第一版證明沿用現有 Entra 註冊可以連通;後來的 DCR 版本,才解決重裝要貼一大段設定,以及把網站 API 權杖交給 MCP 客戶端的權限邊界問題。兩個階段都值得保留,但不能把它們的網址、scope、Client ID 與 callback 混著使用。

這次我會記住三件事:連線錯誤先分層,工具成功要驗資料,使用者少設定一步,服務端往往就得把那一步完整實作。 最後的一行安裝指令,不代表後端只多一個端點;它代表原本交給使用者處理的註冊與授權工作,已被搬回系統內負責。


相關連結

留言評論