﻿# 替既有網站加入 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](https://oauth.net/2/dynamic-client-registration/)。最讓我在意的是，第一版明明已經連得上，重新安裝時卻還要貼一大段 [TOML](https://toml.io/en/)。我問了一句「只能這麼麻煩嗎？能否支援 dynamic registration？」，最後改變的就不只是設定範例，而是 MCP 的授權架構。

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

![從終端機的連線障礙，經過公司身分驗證與獨立授權入口，連到私人會議資料的概念插畫](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/mcp-entra-dynamic-registration/banner.webp)

> 我的前端是 Vanilla JavaScript 與 [Bun](https://bun.sh/)，後端是 [.NET 10 Azure Functions isolated worker](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide)，搭配 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](https://learn.microsoft.com/en-us/entra/identity-platform/msal-overview) 登入 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](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) | Model Context Protocol，讓 AI 客戶端探索及呼叫外部工具的協定；不是另一套公司帳號系統。 |
| MCP Host／Client／Server | 使用者操作的 AI 應用程式是 Host，裡面的 Client 負責 MCP 連線；我新增的會議服務是 Server。日常說的「Codex 連 MCP」，會跨過這幾個角色。 |
| [STDIO](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#stdio) | Client 啟動本機處理序，透過標準輸入與標準輸出交換 MCP 訊息。設定的是可執行命令，不是遠端 HTTP 網址。 |
| [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) | 透過 HTTP 傳送 MCP 訊息的傳輸方式。名稱有 Streamable，不表示每個回應都必須是 SSE。 |
| Authentication | 認證，確認使用者是誰，例如公司帳號登入。 |
| Authorization | 授權，判斷某個使用者、某個客戶端，能讀取哪些會議。 |
| [App registration](https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals) | Entra 中的應用程式登記，描述應用程式、回呼與權限；不等於每個 MCP 客戶端都一定要新增一筆。 |
| Client ID | OAuth 用來識別客戶端的值。它不是密碼，也不代表客戶端已通過品牌或身分審核。 |
| [Public Client](https://oauth.net/2/client-types/) | 無法妥善保管共用 Client Secret 的客戶端，例如安裝在各人電腦上的 CLI。Public 不表示資料公開。 |
| [Confidential Client](https://oauth.net/2/client-types/) | 能在可信任的服務端保護自身憑證的客戶端，與 Public Client 的安全前提不同。 |
| [Access Token](https://oauth.net/2/access-tokens/) | 呼叫受保護資源時出示的存取權杖。持有 Bearer Token 的人通常就能使用它，所以不能公開。 |
| [Refresh Token](https://oauth.net/2/refresh-tokens/) | 用來換取新 Access Token 的更新權杖，不拿來直接呼叫會議工具。 |
| [Scope](https://oauth.net/2/scope/) | 權限範圍。第一版使用 Entra 的 `access_as_user`，新版 MCP 授權層只提供 `meetings.read`。 |
| [Resource／Audience](https://www.rfc-editor.org/rfc/rfc8707.html) | 分別從請求與權杖驗證角度界定權杖要用在哪個資源，不能只看使用者登入成功。 |
| [Redirect URI／Callback](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url) | 登入或授權結束後，瀏覽器返回客戶端的位置。CLI 常在本機暫時開一個 HTTP listener 接收結果。 |
| [PKCE](https://www.rfc-editor.org/rfc/rfc7636.html) | Proof Key for Code Exchange，讓換取權杖的客戶端證明自己持有發起流程時產生的隨機秘密。 |
| [DCR](https://oauth.net/2/dynamic-client-registration/) | Dynamic Client Registration，客戶端透過註冊 API 取得 Client ID；不是自動替使用者核准資料存取。 |
| [PRM](https://www.rfc-editor.org/rfc/rfc9728.html) | Protected Resource Metadata，告訴客戶端這個資源要去哪個授權服務登入。 |
| [Issuer](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) | 發行權杖或提供授權服務的識別網址，新版服務使用固定設定值，不能任由請求的 Host 決定。 |

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

* * *

### 第一版：沿用 Entra 權杖，先把唯讀工具做出來

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

```xml
<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://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-mcp)。

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

```
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` 有這段設定：

```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 網址送出初始化：

```bash
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 限制](https://learn.microsoft.com/en-us/azure/static-web-apps/apis-overview) 說明 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 整合說明](https://learn.microsoft.com/en-us/entra/agent-id/secure-mcp-server-with-entra-id)。

Resource Indicator 的基礎定義在 [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html)。我會把它理解成：客戶端不只說「我要登入」，還會說「這次登入拿到的授權，是要用在這個資源」。

還有一個文件背景要交代：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 兩欄排序需要相容索引

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

```sql
ORDER BY c.createdAt DESC, c.id ASC
```

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

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

```sql
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：

```bash
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 授權文件](https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-mcp) 後，確認 Microsoft Entra ID 不提供這裡需要的 Dynamic Client Registration。Entra 可以當 OAuth 授權服務，和它是否提供 DCR，是兩個不同問題。

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

#### DCR 到底省掉什麼工作

依 [RFC 7591](https://www.rfc-editor.org/rfc/rfc7591.html)，客戶端可以送出註冊 metadata，例如名稱、redirect URIs、grant types 與 token endpoint 的認證方式，再取得 Client ID。

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

OpenAI 的 [MCP 設定文件](https://learn.chatgpt.com/docs/extend/mcp) 也區分預先設定 Client ID、DCR，以及 Client ID Metadata Documents，也就是 CIMD。CIMD 用文件描述客戶端 metadata，是另一種方案；本次實作選擇 DCR，沒有宣稱一併支援 CIMD。DCR 也不是 MCP 所有版本中唯一的註冊途徑。

* * *

### Codex 的第二個錯誤：少了 –url，就變成本機命令

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

```bash
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 設定的輪廓如下。這是**歷史排錯範例，不是新版安裝方式**：

```toml
[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
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 文件的內容輪廓如下：

```json
{
  "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](https://www.rfc-editor.org/rfc/rfc9728.html)。

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

新版另外提供：

```
/.well-known/oauth-authorization-server
```

其中包含固定 `issuer`、`authorization_endpoint`、`token_endpoint`、`registration_endpoint`，以及以下能力宣告：

```json
{
  "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，輪廓如下：

```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 預先登記一份回呼。

#### 4\. authorize 驗證要求，再帶使用者回網站

客戶端產生 `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 授權視窗的桌面版畫面，顯示客戶端、回呼網址、讀取範圍與允許或拒絕按鈕；此圖為本機視覺驗證畫面](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/mcp-entra-dynamic-registration/consent-desktop.webp)

網站送出同意時，還要帶既有網站 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](https://www.rfc-editor.org/rfc/rfc7636.html)。

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

* * *

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

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

#### 每次授權都要明確同意

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

這和 MCP 安全文件提到的 [Confused Deputy 與 Token Passthrough](https://modelcontextprotocol.io/docs/2025-11-25/tutorials/security/security_best_practices) 有關：一個原本擁有權限的服務，不能在沒有適當授權的情況下，替不可信任的客戶端借出那份權限。本次不是替下游 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**。

概念上的程式碼如下：

```csharp
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](https://learn.microsoft.com/en-us/azure/cosmos-db/database-transactions-optimistic-concurrency)。

這不表示整個多筆權杖發行流程因此就成為單一資料庫交易。若消耗 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/`。

```json
{
  "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

第一次安裝，只需要：

```bash
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。

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

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

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

```bash
codex mcp login duotify-meetings
```

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

#### Claude Code

Claude Code 的新設定範例是：

```bash
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 設定方式](https://code.claude.com/docs/en/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，預設焦點在拒絕按鈕](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/mcp-entra-dynamic-registration/consent-mobile.webp)

驗證完成後，測試用連線已撤銷，保留實際使用的 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 混著使用。

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

* * *

### 相關連結

-   [OAuth.net：Dynamic Client Registration](https://oauth.net/2/dynamic-client-registration/)
-   [TOML 官方網站](https://toml.io/en/)
-   [MCP Authorization 規範，2025-11-25 版](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
-   [MCP Security Best Practices](https://modelcontextprotocol.io/docs/2025-11-25/tutorials/security/security_best_practices)
-   [Azure Functions MCP bindings](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-mcp)
-   [App Service MCP 授權與 Entra 不支援 DCR 的說明](https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-mcp)
-   [使用 Microsoft Entra ID 保護 MCP Server](https://learn.microsoft.com/en-us/entra/agent-id/secure-mcp-server-with-entra-id)
-   [Azure Static Web Apps API 限制](https://learn.microsoft.com/en-us/azure/static-web-apps/apis-overview)
-   [將既有 Azure Functions 連結到 Static Web Apps](https://learn.microsoft.com/en-us/azure/static-web-apps/functions-bring-your-own)
-   [OpenAI：Codex MCP 設定與 OAuth Client Registration](https://learn.chatgpt.com/docs/extend/mcp)
-   [Claude Code：透過 MCP 連接工具](https://code.claude.com/docs/en/mcp)
-   [RFC 7591：OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html)
-   [RFC 7636：PKCE](https://www.rfc-editor.org/rfc/rfc7636.html)
-   [RFC 8707：OAuth Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html)
-   [RFC 9728：OAuth Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
-   [Azure Cosmos DB：交易與樂觀並行控制](https://learn.microsoft.com/en-us/azure/cosmos-db/database-transactions-optimistic-concurrency)
