> For the complete documentation index, see [llms.txt](https://docs.maiagent.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.maiagent.ai/tech/mcp/self-hosted-mcp-server.md).

# 自架 MCP Server 接入規格

把自家系統做成 MCP Server 讓 MaiAgent 的 Agent 呼叫：支援的傳輸方式與 URL 要求、驗證（固定標頭、聯絡人憑證、OAuth 動態註冊）、允許的工具、逾時重試與錯誤如何回給模型，以及 MCP Apps 互動卡片。

本頁寫給要「把自家系統包成 MCP Server，讓 MaiAgent 的 Agent 呼叫」的工程師：MaiAgent 這一端怎麼連你的 Server、帶哪些標頭、怎麼做 OAuth、抓工具清單與呼叫工具時的逾時、重試與錯誤處理。

* MCP 的概念與運作原理請看 [MCP（Model Context Protocol）](/tech/mcp/mcp-model-context-protocol.md)；託管型服務（Composio、Zapier 等）請看 [Remote MCP 服務概述](/tech/mcp/remote-mcp.md)。
* 後台的逐步操作（在哪個畫面新增 MCP 工具、每個欄位填什麼）請看使用者手冊的 [建立 MCP 工具](https://docs.maiagent.ai/build/tools/mcp-setup)，本頁不重複操作步驟。
* 若你要接的是一般 REST API 而不是 MCP Server，請看 [API 工具工程指南](/tech/api-integration/api-tool-engineering-guide.md)。

## 一次呼叫的流程 <a href="#call-flow" id="call-flow"></a>

```mermaid
sequenceDiagram
    participant A as MaiAgent 的 Agent
    participant P as MaiAgent 平台（MCP Client）
    participant S as 你的 MCP Server

    Note over P: 每次組裝 Agent 的工具時
    P->>S: initialize → tools/list（帶設定好的標頭）
    S-->>P: 工具清單（name、description、inputSchema、annotations）
    P->>P: 依「允許的工具」過濾、加上名稱前綴、轉成模型的工具定義
    A->>P: tool call（工具名稱＋JSON 參數）
    P->>S: 新開一個 session：initialize → tools/call
    S-->>P: CallToolResult
    P->>P: 轉成文字、依 context window 截斷、寫入工具執行記錄
    P-->>A: 結果文字（或錯誤訊息）
```

MaiAgent 是 **MCP Client**；Agent 只負責決定呼叫哪個工具與參數，實際連線由平台送出。

## 傳輸方式與 URL 要求 <a href="#transport-and-url" id="transport-and-url"></a>

| 項目         | 規格                                                                                                                          |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- |
| 支援的傳輸      | **Streamable HTTP** 與 **SSE**。不支援 stdio（不會在平台上啟動 `npx`、`node` 等本機指令）。                                                       |
| 傳輸判斷       | 依 URL 自動判斷：路徑結尾為 `/sse`、路徑中含 `/sse/`，或 query string 帶 `transport=sse` 時走 SSE；其餘一律走 Streamable HTTP。                         |
| URL scheme | 必須是 `https://`。只有當目標解析到平台允許的內部網段（部署設定的內部 CIDR 白名單）時才接受 `http://`。                                                           |
| 網路位置       | 網域必須可解析；解析到私有、loopback 等內部位址會被拒絕（位址落在內部 CIDR 白名單內者除外）；link-local（如 `169.254.x.x`）一律拒絕，白名單也無法放行。SaaS 環境下，你的 Server 需要能從公網存取。 |
| TLS        | 預設使用嚴格的憑證驗證。若憑證鏈使用缺少 Subject Key Identifier 的舊版根憑證而連不上，這個放寬選項只能由 MaiAgent 在系統管理端逐一工具或逐一網域開啟，請聯繫 MaiAgent。                   |

{% hint style="info" %}
建議使用 Streamable HTTP，並把 MCP 端點放在獨立路徑（例如 `https://mcp.example.com/mcp`）。若你的 Server 只提供 SSE，URL 請以 `/sse` 結尾，平台才會用 SSE 連線。
{% endhint %}

## 建立工具時的連線驗證 <a href="#setup-validation" id="setup-validation"></a>

在後台或透過 API 建立／更新 MCP 工具時，平台會先實際連到你的 Server 執行一次 `tools/list`：

* 連不上或拿不到工具清單時，建立會失敗，錯誤訊息會附上連線錯誤內容。
* 若請求沒有指定 `mcpAllowedTools` 或傳空陣列 `[]`，平台會把**當下** `tools/list` 回傳的所有工具名稱存成允許清單。之後 Server 新增的工具**不會自動開放**，要回到工具設定重新取得清單並勾選。
* 驗證連線只帶 `rawMcpHeader` 中**不含** `{{變數}}` 的標頭（這時還沒有對話可以代入變數）。若你的 Server 要求每個請求都必須帶某個動態標頭，請讓 `tools/list` 在只有固定標頭時也能回應。
* 若這個 URL 對應到設定為 OAuth 的 MCP Registry（見 [OAuth 2.0](#oauth)），建立或更新時會略過驗證、允許清單先留空，等授權完成後再取得工具清單。

## 驗證方式 <a href="#authentication" id="authentication"></a>

平台透過 HTTP 標頭把身分帶給你的 Server。帶哪些標頭取決於工具的<mark style="color:blue;">授權對象</mark>（API 欄位 `authSubject`）：

| `authSubject` | 後台名稱                                     | 誰的身分      | 帶入的標頭（後者覆蓋前者同名標頭）                              |
| ------------- | ---------------------------------------- | --------- | ---------------------------------------------- |
| `shared`（預設）  | <mark style="color:blue;">組織共用</mark>    | 全組織一組授權   | 工具層 OAuth 權杖 → `rawMcpHeader` → 該聯絡人的 MCP 憑證   |
| `contact`     | <mark style="color:blue;">聯絡人個別授權</mark> | 對話中的聯絡人本人 | `rawMcpHeader` → 該聯絡人的 MCP 憑證 → 該聯絡人的 OAuth 權杖 |

`contact` 模式下，匿名聯絡人（沒有 `sourceId`）的對話中**完全不會提供這個工具**給 Agent。

### 固定標頭（API Key） <a href="#static-headers" id="static-headers"></a>

最簡單的做法：在工具的 `rawMcpHeader` 放一個 JSON 物件，每次連線都會帶上。

```json
{
  "Authorization": "Bearer <your-api-key>",
  "X-Tenant": "{{organization_id}}"
}
```

* `rawMcpHeader` 為唯寫欄位，讀取工具時只會回傳遮罩後的 `rawMcpHeaderMasked`。
* 值支援以下 `{{變數}}`，依當下對話代入：`contact_id`、`source_id`、`contact_name`、`conversation_id`、`inbox_id`、`organization_id`、`agent_slug`、`agent_version`。

### 聯絡人層級 MCP 憑證 <a href="#contact-credentials" id="contact-credentials"></a>

要讓 Agent 以**終端使用者本人的權限**呼叫你的 Server，把使用者的權杖寫進該聯絡人的 MCP 憑證：

* 每個聯絡人對每個 MCP 工具最多一筆憑證，內容是一組 `headers`。
* 對話進行時，平台依「目前對話的聯絡人＋這個工具」找出憑證，把 `headers` **原樣**合併進請求標頭，同名時覆蓋 `rawMcpHeader`。憑證標頭**不做** `{{變數}}` 代入。
* 寫入方式：
  * Web Chat 嵌入時呼叫 `setup-contact-credentials`，在 `mcpCredentials` 帶 `toolId` 與 `headers`（`toolId` 必須是 MCP 類型工具，否則回 400）。
  * 由你的後端呼叫聯絡人 API 的 `mcp-credentials` 端點新增、更新或刪除。

兩種寫法的完整參數、Token 更新與登出撤銷流程請看 [聯絡人身分同步與 Token 更新](/tech/serve/contacts/contact-credentials-sync.md)；後台手動設定見使用者手冊的 [聯絡人 MCP 憑證設定](https://docs.maiagent.ai/build/tools/setup-contacts-mcp-credentials)。

### 對話識別標頭 <a href="#conversation-header" id="conversation-header"></a>

只要有對話情境，平台會在每個請求加上 `X-Conversation-Id: <對話 ID>`，讓有狀態的 Server 能以對話為單位保存工作狀態，不必依賴模型自己傳一個 session token。若你的驗證標頭剛好同名，以驗證標頭為準。

### OAuth 2.0 <a href="#oauth" id="oauth"></a>

若你的 MCP Server 以 OAuth 保護，平台會自動完成下列流程（依 MCP 授權規格）：

{% stepper %}
{% step %}

#### 探索受保護資源 <a href="#oauth-resource-discovery" id="oauth-resource-discovery"></a>

平台先不帶權杖對 MCP URL 送一個 `GET`。若回 `401` 且 `WWW-Authenticate: Bearer resource_metadata="..."`，就讀取這個網址（必須是 `https`，且主機必須與 MCP URL 相同）。否則依 RFC 9728 預設位置嘗試：先 `https://{host}/.well-known/oauth-protected-resource{MCP 路徑}`，再 `https://{host}/.well-known/oauth-protected-resource`。

從中取得 `resource`（權杖的 audience）與 `authorization_servers`（取第一個，必須是 `https`）。探索結果快取 5 分鐘。
{% endstep %}

{% step %}

#### 讀取授權伺服器中繼資料 <a href="#oauth-server-metadata" id="oauth-server-metadata"></a>

依 RFC 8414 讀取 `/.well-known/oauth-authorization-server`。授權伺服器 URL 帶路徑時，會依序嘗試「well-known 插在主機與路徑之間」、「well-known 接在路徑後面」、「主機根目錄」三個位置。
{% endstep %}

{% step %}

#### 動態註冊 Client（RFC 7591） <a href="#oauth-dynamic-registration" id="oauth-dynamic-registration"></a>

中繼資料有 `registration_endpoint` 時，平台送出：

```json
{
  "client_name": "MaiAgent MCP Client",
  "redirect_uris": ["<MaiAgent callback URL>"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

沒有 `registration_endpoint` 時，需在 MCP Registry 預先設定 `oauthClientId`（機密 client 另設 `oauthClientSecret`），否則授權會失敗。
{% endstep %}

{% step %}

#### 授權碼＋PKCE <a href="#oauth-authorization-code" id="oauth-authorization-code"></a>

授權請求帶 `response_type=code`、`state`、`code_challenge`（`S256`）與 `resource`。`scope` 的取用順序：MCP Registry 設定的 scope → 受保護資源中繼資料的 `scopes_supported` → 授權伺服器中繼資料的 `scopes_supported`。`state` 有效 10 分鐘。

換權杖時以 `grant_type=authorization_code` 帶 `code_verifier` 與 `resource`，有 client secret 才一併帶上。
{% endstep %}

{% step %}

#### 自動更新權杖 <a href="#oauth-refresh" id="oauth-refresh"></a>

權杖到期前 60 秒內，平台以 `grant_type=refresh_token` 更新。回應沒有新的 `refresh_token` 時沿用舊的。
{% endstep %}
{% endstepper %}

**Callback URL**：若你的授權伺服器不支援動態註冊、需要預先登記 redirect URI，下列兩個都要登記，否則對應的流程會在 `/authorize` 失敗：

| 流程         | Callback URL                                                                      |
| ---------- | --------------------------------------------------------------------------------- |
| 成員（組織共用）授權 | MaiAgent 後台網域 + `/oauth/mcp/callback`                                             |
| 聯絡人個別授權    | `https://api.maiagent.ai/api/mcp/oauth/contacts/callback/`（SaaS；私有部署依該環境的 API 網域） |

**MCP Registry**：自架 Server 若需要固定 `oauthClientId`、指定授權伺服器 URL 或 `resourceIdentifier`，可由具連接器管理權限的成員建立組織私有的 MCP Registry（`/api/v1/mcp/private-registry/`）。`authType` 可選 `oauth`、`service_account`（以標頭帶金鑰）或 `none`。平台以「MCP URL 開頭符合 Registry 的 `remoteMcpUrl`」判斷工具對應哪個 Registry，留空的欄位才會用自動探索的結果。

## 允許的工具（`mcpAllowedTools`） <a href="#allowed-tools" id="allowed-tools"></a>

`mcpAllowedTools` 是 Server 工具名稱的陣列，決定哪些工具會交給 Agent。執行時，儲存的值依授權對象解讀如下：

| 值                        | `shared`       | `contact`      |
| ------------------------ | -------------- | -------------- |
| `null`                   | 開放 Server 所有工具 | 開放 Server 所有工具 |
| `[]`（空陣列）                | **一個都不開放**     | 開放 Server 所有工具 |
| `["search_orders", ...]` | 只開放列出的工具       | 只開放列出的工具       |

`contact` 模式把空陣列視為全部開放，是因為管理員手上沒有聯絡人的權杖、無法事先取得工具清單。`shared` 模式搭配 OAuth 時，要在管理員完成授權並取得工具清單後，清單才會有內容。

{% hint style="warning" %}
透過 API 建立或更新工具時：

* **一般 MCP 工具**：不帶 `mcpAllowedTools` 或傳 `[]`，都會被換成當下取得的全部工具名稱（見[建立工具時的連線驗證](#setup-validation)），因此不能用 `[]` 關閉所有工具。
* **對應到 OAuth MCP Registry 的工具**：`[]` 會原樣保存；更新時若傳 `[]` 或沒帶這個欄位，允許清單也會被清空，`shared` 模式下就一個工具都不開放。更新這類工具時請一律帶上完整的 `mcpAllowedTools`。
  {% endhint %}

## 工具清單怎麼交給模型 <a href="#tool-list-to-model" id="tool-list-to-model"></a>

* **何時抓**：每次組裝 Agent 的工具時都會連到你的 Server 執行 `tools/list`，再依允許清單過濾。
* **名稱**：工具有設定 `name` 時，模型看到的名稱是 `{name}__{你的工具名}`。非英數與底線的字元會換成底線、開頭不是英文字母時補 `t_`，超過 64 字元則截斷並加上 6 碼雜湊尾碼。
* **說明**：模型看到的描述就是你在 `tools/list` 回的 `description`，請寫清楚用途與使用時機。
* **參數**：`inputSchema` 會轉成模型的參數定義。平台會自動處理兩種常見寫法：把陣列形式的屬性值轉成 `anyOf`，並把本地 `$ref` 內嵌展開（遞迴參照會改成接受任意值）。Schema 仍無法轉換時，**只會略過該工具**，其他工具照常提供。
* **抓取失敗**：連不上、逾時或驗證失敗時，這個 MCP 工具在本次回覆中**不會提供任何工具**給模型，對話本身不會中斷。

## 呼叫工具：逾時、重試與錯誤 <a href="#tool-call" id="tool-call"></a>

### Session 與逾時 <a href="#session-and-timeout" id="session-and-timeout"></a>

* 每次 `tools/call` 都會**新開一個 MCP session**（`initialize` 後再呼叫），不會跨呼叫重用 session。需要跨呼叫保存狀態時，請以 `X-Conversation-Id` 或你自己的驗證身分為依據。
* MCP session 的讀取逾時為 30 秒。

### 暫時性錯誤的自動重試 <a href="#transient-retry" id="transient-retry"></a>

連線逾時、連線中斷或被重設、Server 未回應就斷線、session 中途關閉，以及 MCP 錯誤碼為連線關閉或 408 時，平台最多重試 3 次，間隔 1、2、4 秒（上限 8 秒）。你的 Server 回傳的錯誤結果或應用層錯誤**不會**重試。

只有「重送也安全」的工具才會重試，判斷順序：

1. 工具的 `annotations` 有 `readOnlyHint: true` 或 `idempotentHint: true` → 會重試。
2. 其中任一明確為 `false` → 不重試。
3. 兩者都沒宣告時，看工具名稱的第一個字是否為 `get`、`list`、`read`、`fetch`、`search`、`find`、`check`、`query`、`describe`、`show`、`view`、`browse`、`lookup`（camelCase、`-` 會先拆字）。

{% hint style="info" %}
建議在 `tools/list` 為每個工具明確宣告 `readOnlyHint`／`idempotentHint`。會產生副作用的工具（下單、送出表單）請標為 `false`，避免斷線後被重送。
{% endhint %}

### 回給模型的內容 <a href="#response-to-model" id="response-to-model"></a>

| 情況                     | 模型看到的內容                                                                                                   |
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
| 呼叫成功                   | `CallToolResult` 轉成的文字（包含 `content`、`structuredContent` 與 `isError` 欄位）。你回 `isError: true` 時，模型一樣會看到這段內容。 |
| 呼叫時拋出例外（重試後仍失敗、非暫時性錯誤） | `MCP 工具執行錯誤\n MCP Tool Execution Error: <錯誤內容>`                                                           |
| 結果過長                   | 上限為模型 context window × 工具輸出比例（預設 0.8），以 token 估算。超過時保留開頭與結尾各一半，中間省略，並在最前面加上說明「結果不完整」的提示。                  |

每次呼叫都會寫入工具執行記錄（工具、實際 MCP 工具名稱、輸入參數、輸出或錯誤、耗時、重試次數），除錯方式同 [API 工具工程指南的記錄與除錯](/tech/api-integration/api-tool-engineering-guide.md#records-and-debugging)。

### 計費分類 <a href="#billing" id="billing"></a>

每次成功呼叫計 1 次 MCP 工具用量，依工具名稱分為讀取類或寫入類：名稱第一個字符合上述讀取字首就算讀取類，否則名稱中含寫入關鍵字時算寫入類，其餘都算讀取類。組織點數不足時，這次呼叫不會送到你的 Server。

## MCP Apps（互動卡片） <a href="#mcp-apps" id="mcp-apps"></a>

MaiAgent 支援 MCP Apps 擴充（SEP-1865）：工具除了回傳資料，還可以附上一份 HTML 介面，對話中就會出現一張可操作的卡片，例如圖表、表單或查詢結果清單。使用者看到的效果與限制見使用者手冊的 [MCP 互動卡片（MCP Apps）](https://docs.maiagent.ai/build/tools/mcp-apps)；想直接動手，跳到[最小可跑範例](#mcp-apps-example)。

### 伺服器端：怎麼回傳 UI 資源 <a href="#mcp-apps-server" id="mcp-apps-server"></a>

1. **連結 UI 資源**：在 `tools/list` 的工具定義，或 `tools/call` 結果的 `_meta` 帶上 `ui.resourceUri`，值必須以 `ui://` 開頭，例如 `{"_meta": {"ui": {"resourceUri": "ui://orders/list"}}}`。兩處都有時以呼叫結果的為準；扁平寫法 `_meta["ui/resourceUri"]`（`@modelcontextprotocol/ext-apps` 的 `registerAppTool` 產生的格式）也接受。工具定義的 `_meta` 在每次組裝工具清單時讀取。
2. **提供資源內容**：工具呼叫成功後，平台在同一個 MCP session 對這個 URI 送出 `resources/read`，取第一筆內容的 `text` 當作卡片 HTML。建議 `mimeType` 使用 `text/html;profile=mcp-app`。
3. **回傳資料**：卡片收到的是這次的工具結果：`content` 的第一段文字，以及 `structuredContent`（物件）。請讓卡片以 `structuredContent` 為資料來源；平台不會把工具輸入參數傳給卡片（`ui/notifications/tool-input` 的 `arguments` 為空物件）。

{% hint style="warning" %}
平台的 MCP client 在 `initialize` 時**不會**宣告 `io.modelcontextprotocol/ui` 擴充能力。請不要用 `getUiCapability()` 之類的判斷來決定要不要附上 `_meta`，否則平台永遠拿不到 UI 資源。
{% endhint %}

以下情況不會出現卡片，或改以文字顯示：

| 情況                       | 結果                     |
| ------------------------ | ---------------------- |
| 工具結果 `isError: true`     | 不產生卡片                  |
| 沒有連結 `ui://` 資源          | 不產生卡片，對話照常顯示工具結果       |
| `resources/read` 失敗或沒有內容 | 卡片改顯示 `content` 的第一段文字 |

卡片 HTML 會在產生當下隨訊息保存。之後更新伺服器上的 UI，不會改變歷史訊息中已產生的卡片，請讓新版 UI 能處理舊版產生的資料。

### 前端：卡片怎麼呈現 <a href="#mcp-apps-rendering" id="mcp-apps-rendering"></a>

* **出現位置**：Agent 回覆完成後，卡片出現在該則回覆下方；同一則回覆中，來自同一來源、同一個 UI 資源的卡片只顯示最新一張。
* **沙盒**：卡片在 `sandbox="allow-scripts"` 的 iframe 中執行（沒有 `allow-same-origin`），無法讀取宿主頁面的 Cookie 與儲存空間，也無法自行開新視窗。
* **內容安全政策（CSP）**：預設只允許內嵌的 script 與 style，圖片、媒體與字型只接受 `data:`／`blob:`，`connect-src` 為 `'none'`（不能對外發出請求），也不能送出表單。請把 JS、CSS 與圖片都打包進同一份 HTML。
* **額外允許的來源**：卡片確實需要載入外部資源時，在組織私有 MCP Registry（`/api/v1/mcp/private-registry/`）的 `cspAllowedOrigins` 列出來源。只接受 `https://` 開頭、不含路徑與萬用字元的 origin，例如 `https://viewer.example.com`。建立或更新 MCP 工具時，網址開頭符合 Registry `remoteMcpUrl` 的工具會對應到該 Registry。
* **尺寸與顯示模式**：卡片預設高度 220px，依卡片以 `ui/notifications/size-changed` 回報的 `height` 調整（上限 2000px），訊息流中的最大高度為 `min(62vh, 620px)`，超過時在卡片內捲動。卡片可以請求切換為全螢幕（`fullscreen`），平台會在右上角加上退出按鈕，也可按 Esc 返回，收合後維持卡片最後回報的高度；請求 `pip` 時以一般（`inline`）模式顯示。
* **串流中不顯示**：Agent 回覆串流結束後才掛載卡片；同一則回覆多次更新時，平台以 `ui/notifications/tool-result` 原地推新結果，不重建 iframe。

### 卡片與宿主的握手 <a href="#mcp-apps-handshake" id="mcp-apps-handshake"></a>

卡片與平台之間以 MCP Apps 的 JSON-RPC 透過 `window.parent.postMessage` 溝通（宿主端使用 `@modelcontextprotocol/ext-apps` 1.2 的 `AppBridge`，協定版本 `2026-01-26`）。順序：

1. 卡片送 `ui/initialize`（`appInfo`、`appCapabilities`、`protocolVersion`）。平台回 `hostInfo`、`hostCapabilities`（`serverTools`、`message.text`、`openLinks`）與 `hostContext`（`displayMode`、`availableDisplayModes: ["inline", "fullscreen"]`、`theme: { primaryColor, mode }`）。
2. 卡片送 `ui/notifications/initialized`。
3. 平台依序推 `ui/notifications/tool-input`（`arguments` 為空）與 `ui/notifications/tool-result`（完整 `CallToolResult`）。之後顯示模式或主題改變時推 `ui/notifications/host-context-changed`；卡片卸載前送 `ui/resource-teardown` 請求。

平台另外會在卡片 HTML 的 `<head>` 注入 `window.__MCP_INITIAL_RENDER_DATA__`（同一份工具結果），讓卡片在握手完成前就能先畫；正式資料仍以 `tool-result` 通知為準。

### 卡片可以呼叫的宿主功能 <a href="#mcp-apps-host-api" id="mcp-apps-host-api"></a>

| 方法                                                            | 平台的處理                                                                                                                                                                                                |
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools/call`                                                  | 由平台代為呼叫，卡片無法直接連到你的伺服器或 MaiAgent 後端。只能呼叫產生這張卡片的同一個 MCP 來源、且在這段對話中已啟用並在允許清單內的工具，驗證標頭與 Agent 呼叫時相同。單次呼叫逾時 30 秒；回給卡片的結果只保留 `content` 的第一段文字、`structuredContent` 與 `isError`。失敗時卡片外會顯示「這個動作沒有完成，請稍後再試一次。」 |
| `ui/message`                                                  | `content` 需為內容區塊**陣列**，平台取出其中的文字區塊，當作使用者訊息送進對話並先把卡片收合回 `inline`，由 Agent 接著回答                                                                                                                         |
| `ui/open-link`                                                | 由平台開啟新分頁，只接受 `https:`、`http:`、`tel:`、`mailto:`，網址中的帳號密碼會被移除；不允許或被瀏覽器擋下時回 `{ "isError": true }`                                                                                                       |
| `ui/request-display-mode`                                     | 在 `inline` 與 `fullscreen` 之間切換，回覆實際生效的模式                                                                                                                                                             |
| `ui/notifications/size-changed`                               | 依 `height` 調整卡片高度（全螢幕時忽略）                                                                                                                                                                            |
| `ui/download-file`、`ui/update-model-context`、`resources/read` | 未支援，平台回 `-32601 Method not found`                                                                                                                                                                    |

### 適用範圍 <a href="#mcp-apps-scope" id="mcp-apps-scope"></a>

* 卡片顯示在 Web Chat 與 MaiGPT 的對話中；LINE、Messenger 等其他通訊平台只會收到 Agent 的文字回覆。
* 對 Agent 掛載的 MCP 工具（Agent 模式），以及成員在 MaiGPT 授權使用的連接器都有效，MaiAgent 這一端不需要額外開關。
* 地端部署的 Web Chat 是否顯示卡片依部署設定而定，請洽 MaiAgent 窗口。

### 最小可跑範例 <a href="#mcp-apps-example" id="mcp-apps-example"></a>

一支 Node.js（18 以上）的 MCP Server：一個查訂單狀態的工具（回傳假資料）連結 `ui://orders/status` 卡片，卡片能重新查詢、也能向 AI 提問。三個檔案放在同一個資料夾。

{% stepper %}
{% step %}

#### 建立專案 <a href="#mcp-apps-example-setup" id="mcp-apps-example-setup"></a>

```bash
mkdir order-status-mcp && cd order-status-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
```

{% endstep %}

{% step %}

#### `server.mjs`：工具與 UI 資源 <a href="#mcp-apps-example-server" id="mcp-apps-example-server"></a>

```javascript
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'

const PORT = Number(process.env.PORT ?? 8787)
const CARD_URI = 'ui://orders/status'
const CARD_HTML = readFileSync(new URL('./card.html', import.meta.url), 'utf8')

// 假資料：正式環境換成查你的系統
const ORDERS = {
  A1001: { status: 'shipped', carrier: '黑貓宅急便', eta: '2026-10-06', items: ['無線滑鼠 ×1', 'USB-C 線 ×2'] },
  A1002: { status: 'processing', carrier: null, eta: '2026-10-08', items: ['機械鍵盤 ×1'] },
}

function buildServer() {
  const server = new McpServer({ name: 'order-status-demo', version: '1.0.0' })

  server.registerTool(
    'get_order_status',
    {
      description: '查詢訂單目前的出貨狀態與預計送達日',
      inputSchema: { orderId: z.string().describe('訂單編號，例如 A1001') },
      annotations: { readOnlyHint: true },
      // 連結互動卡片：平台讀 _meta.ui.resourceUri
      _meta: { ui: { resourceUri: CARD_URI } },
    },
    async ({ orderId }) => {
      const order = ORDERS[orderId]
      if (!order) {
        return { isError: true, content: [{ type: 'text', text: `找不到訂單 ${orderId}` }] }
      }
      return {
        // content 的第一段文字＝模型看到的內容，也是卡片載入失敗時的純文字備援
        content: [{ type: 'text', text: `訂單 ${orderId}：${order.status}，預計 ${order.eta} 送達` }],
        // 卡片以 structuredContent 為資料來源
        structuredContent: { orderId, ...order },
      }
    },
  )

  // 卡片 HTML：工具呼叫成功後，平台在同一個 session 對這個 URI 送 resources/read
  server.registerResource(
    'order-status-card',
    CARD_URI,
    { mimeType: 'text/html;profile=mcp-app' },
    async (uri) => ({
      contents: [{ uri: uri.href, mimeType: 'text/html;profile=mcp-app', text: CARD_HTML }],
    }),
  )

  return server
}

createServer(async (req, res) => {
  if (new URL(req.url, 'http://localhost').pathname !== '/mcp') {
    res.writeHead(404).end()
    return
  }
  // 無狀態模式：每個請求各自建立 server + transport；平台每次呼叫本來就會新開 session
  const server = buildServer()
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
  res.on('close', () => { transport.close(); server.close() })
  await server.connect(transport)
  await transport.handleRequest(req, res)
}).listen(PORT, () => console.log(`MCP server listening on http://localhost:${PORT}/mcp`))
```

{% endstep %}

{% step %}

#### `card.html`：單檔卡片 <a href="#mcp-apps-example-card" id="mcp-apps-example-card"></a>

不引入任何套件，直接用 `postMessage` 走 JSON-RPC：握手、接收 `tool-result`、回報高度、`tools/call` 重新查詢、`ui/message` 向 AI 提問。JS、CSS 都寫在同一個檔案裡（沙盒 CSP 不允許載入外部資源）。

```html
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<style>
  body { margin: 0; padding: 16px; font: 14px/1.6 system-ui, sans-serif; color: #1f2937; }
  h4 { margin: 0 0 8px; font-size: 16px; }
  .status { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e0f2fe; color: #075985; }
  table { border-collapse: collapse; margin: 8px 0; width: 100%; }
  td { padding: 4px 0; border-bottom: 1px solid #e5e7eb; }
  button { margin: 8px 8px 0 0; padding: 8px 14px; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
  button.secondary { background: #6b7280; }
  #hint { color: #6b7280; }
</style>
</head>
<body>
  <div id="view"><p id="hint">載入中…</p></div>
  <button id="refresh" hidden>重新查詢</button>
  <button id="ask" class="secondary" hidden>問 AI 這筆訂單</button>

<script>
  // ---- 與宿主（MaiAgent）的 JSON-RPC 往返：全部走 window.parent.postMessage ----
  var pending = {}
  var nextId = 1
  var currentOrder = null

  function request(method, params) {
    var id = nextId++
    return new Promise(function (resolve, reject) {
      pending[id] = { resolve: resolve, reject: reject }
      window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params }, '*')
    })
  }
  function notify(method, params) {
    window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*')
  }

  window.addEventListener('message', function (event) {
    var msg = event.data
    if (!msg || msg.jsonrpc !== '2.0') return
    // 1) 我方請求的回覆
    if (msg.id !== undefined && !msg.method) {
      var p = pending[msg.id]
      if (!p) return
      delete pending[msg.id]
      msg.error ? p.reject(msg.error) : p.resolve(msg.result)
      return
    }
    // 2) 宿主推來的通知：握手完成後會先收到 tool-input，再收到 tool-result
    if (msg.method === 'ui/notifications/tool-result') render(msg.params)
    // 3) 宿主請求卡片收尾：回一個空結果即可
    if (msg.method === 'ui/resource-teardown' && msg.id !== undefined) {
      window.parent.postMessage({ jsonrpc: '2.0', id: msg.id, result: {} }, '*')
    }
  })

  // ---- 畫面 ----
  function render(result) {
    var data = result && result.structuredContent
    var view = document.getElementById('view')
    if (!data || result.isError) {
      var text = result && result.content && result.content[0] ? result.content[0].text : '沒有資料'
      view.innerHTML = '<p id="hint"></p>'
      view.firstChild.textContent = text
    } else {
      currentOrder = data.orderId
      var rows = (data.items || []).map(function (i) { return '<tr><td>' + escapeHtml(i) + '</td></tr>' }).join('')
      view.innerHTML =
        '<h4>訂單 ' + escapeHtml(data.orderId) + ' <span class="status">' + escapeHtml(data.status) + '</span></h4>' +
        '<div>預計送達：' + escapeHtml(data.eta) + (data.carrier ? '（' + escapeHtml(data.carrier) + '）' : '') + '</div>' +
        '<table>' + rows + '</table>'
      document.getElementById('refresh').hidden = false
      document.getElementById('ask').hidden = false
    }
    // 回報高度，宿主會依此調整卡片高度
    notify('ui/notifications/size-changed', { height: document.body.scrollHeight })
  }
  function escapeHtml(s) {
    return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] })
  }

  // 卡片內的動作：由宿主代為呼叫同一個 MCP server 的工具
  document.getElementById('refresh').addEventListener('click', function () {
    request('tools/call', { name: 'get_order_status', arguments: { orderId: currentOrder } })
      .then(render)
      .catch(function (err) { console.error('tools/call failed', err) })
  })
  // 卡片內提問：文字會成為一則使用者訊息送進對話，由 Agent 接著回答
  document.getElementById('ask').addEventListener('click', function () {
    request('ui/message', { role: 'user', content: [{ type: 'text', text: '訂單 ' + currentOrder + ' 什麼時候會到？' }] })
  })

  // ---- 握手：ui/initialize → ui/notifications/initialized，之後宿主才會推 tool-result ----
  request('ui/initialize', {
    appInfo: { name: 'order-status-card', version: '1.0.0' },
    appCapabilities: {},
    protocolVersion: '2026-01-26',
  }).then(function () {
    notify('ui/notifications/initialized')
  })
</script>
</body>
</html>
```

{% endstep %}

{% step %}

#### 啟動並驗證 <a href="#mcp-apps-example-verify" id="mcp-apps-example-verify"></a>

```bash
node server.mjs
# MCP server listening on http://localhost:8787/mcp
```

另開一個終端機，依平台的順序打三個請求（無狀態模式不需要 session 標頭）：

```bash
MCP=http://localhost:8787/mcp
H='-H Content-Type:application/json -H Accept:application/json,text/event-stream'

# 1) tools/list：確認工具帶 _meta.ui.resourceUri
curl -s $MCP $H -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# {"result":{"tools":[{"name":"get_order_status", ... ,"_meta":{"ui":{"resourceUri":"ui://orders/status"}}}]}, ...}

# 2) tools/call：確認有 content 文字與 structuredContent
curl -s $MCP $H -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_order_status","arguments":{"orderId":"A1001"}}}'
# {"result":{"content":[{"type":"text","text":"訂單 A1001：shipped，預計 2026-10-06 送達"}],"structuredContent":{"orderId":"A1001","status":"shipped", ...}}, ...}

# 3) resources/read：確認回傳整份 HTML
curl -s $MCP $H -d '{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"ui://orders/status"}}'
# {"result":{"contents":[{"uri":"ui://orders/status","mimeType":"text/html;profile=mcp-app","text":"<!doctype html>..."}]}, ...}
```

三個都回對了，平台這一端就能產生卡片。
{% endstep %}
{% endstepper %}

### 接進 MaiAgent 的步驟 <a href="#mcp-apps-example-connect" id="mcp-apps-example-connect"></a>

1. 讓 Server 從公網以 `https://` 存取（本機測試可用 `cloudflared tunnel --url http://localhost:8787` 或 `ngrok http 8787` 之類的工具取得暫時網址），MCP 端點為 `https://<你的網域>/mcp`。
2. 依使用者手冊的[建立 MCP 工具](https://docs.maiagent.ai/build/tools/mcp-setup)在後台新增 MCP 工具，網址填上一步的端點；本範例不需要標頭。「允許的工具」重新取得後應列出 `get_order_status`。
3. 把工具配置給回答模式為 Agent 模式的 Agent，在 Web Chat 或 MaiGPT 問「A1001 這筆訂單到哪了？」。Agent 回覆完成後，卡片會出現在回覆下方；點 <mark style="color:blue;">重新查詢</mark> 走 `tools/call`、點 <mark style="color:blue;">問 AI 這筆訂單</mark> 走 `ui/message`。使用者端的行為見 [MCP 互動卡片（MCP Apps）](https://docs.maiagent.ai/build/tools/mcp-apps)。

### 常見錯誤 <a href="#mcp-apps-example-errors" id="mcp-apps-example-errors"></a>

| 現象                      | 原因                                                                                                                                                                        |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Agent 有呼叫工具，但沒出卡片       | `tools/list` 的工具定義與 `tools/call` 結果都沒有 `_meta.ui.resourceUri`（或值不是 `ui://` 開頭）、工具回了 `isError: true`、或是在 LINE 等不顯示卡片的通路                                                    |
| 卡片位置只顯示純文字              | `resources/read` 失敗或回空內容，平台改用 `content` 第一段文字；用上面第 3 個 curl 確認                                                                                                            |
| 卡片一直停在「載入中…」            | 卡片沒有完成握手（`ui/initialize` 的 `params` 缺 `appInfo`／`appCapabilities`／`protocolVersion`，或沒送 `ui/notifications/initialized`），平台不會推 `tool-result`；也可能是卡片 HTML 載入了外部 JS 被 CSP 擋下 |
| 卡片空白                    | HTML 依賴外部 CSS／JS／圖片而被沙盒 CSP 擋下，請打包進同一份 HTML，或在 Registry 設定 `cspAllowedOrigins`                                                                                            |
| 卡片顯示「這個動作沒有完成，請稍後再試一次。」 | 卡片的 `tools/call` 被平台拒絕或失敗：工具名稱不在該 MCP 工具的允許清單、工具已從 Agent 移除、授權失效、Server 回錯或超過 30 秒未回應                                                                                     |
| 卡片的「問 AI」沒反應            | `ui/message` 的 `content` 不是陣列、或沒有 `type: "text"` 的區塊                                                                                                                      |

## 上線前檢查清單 <a href="#checklist" id="checklist"></a>

* [ ] 端點是 `https://`、可從公網存取，使用 Streamable HTTP；只支援 SSE 時 URL 以 `/sse` 結尾。
* [ ] 只帶固定標頭時 `tools/list` 也能正常回應。
* [ ] 每個工具的 `description` 寫清楚用途，`inputSchema` 是合法的 JSON Schema。
* [ ] 為每個工具宣告 `readOnlyHint`／`idempotentHint`。
* [ ] 不依賴跨呼叫的 MCP session；需要狀態時改用 `X-Conversation-Id` 或使用者身分。
* [ ] 使用者層級權限：規劃好聯絡人 MCP 憑證的寫入時機（登入時同步、登出時刪除）。
* [ ] 使用 OAuth：支援 RFC 9728 資源中繼資料、RFC 8414 授權伺服器中繼資料、PKCE（S256）與 `resource` 參數；不支援動態註冊時，兩個 Callback URL 都已登記。
* [ ] Server 新增工具後，回到工具設定重新取得清單。
* [ ] 提供互動卡片時：以 `_meta.ui.resourceUri` 連結 `ui://` 資源、`resources/read` 回傳完整 HTML，卡片以 `structuredContent` 為資料來源、不對外發出請求（需要時在 Registry 設定 `cspAllowedOrigins`）。


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.maiagent.ai/tech/mcp/self-hosted-mcp-server.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
