> 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/en/mcp/self-hosted-mcp-server.md).

# Self-Hosted MCP Server Integration Specifications

Wrap your own system as an MCP Server for MaiAgent Agents to call：supported transports and URL requirements, authentication (static headers, contact credentials, OAuth dynamic registration), allowed t

This page is for engineers who want to wrap their own system as an MCP Server for MaiAgent Agents to call. It explains how MaiAgent connects to your Server, which headers it sends, how OAuth works, and how the platform handles timeouts, retries, and errors when retrieving the tool list and calling tools.

* For MCP concepts and how it works, see [MCP (Model Context Protocol)](/tech/en/mcp/mcp-model-context-protocol.md). For hosted services such as Composio and Zapier, see [Remote MCP Service Overview](/tech/en/mcp/remote-mcp.md).
* For step-by-step console instructions (where to add an MCP tool and what to enter in each field), see [Create an MCP Tool](https://docs.maiagent.ai/build/tools/mcp-setup) in the user manual. This page does not repeat those steps.
* To integrate a regular REST API instead of an MCP Server, see the [API Tool Engineering Guide](/tech/en/api-integration/api-tool-engineering-guide.md).

## Call Flow <a href="#call-flow" id="call-flow"></a>

```mermaid
sequenceDiagram
    participant A as MaiAgent Agent
    participant P as MaiAgent Platform (MCP Client)
    participant S as Your MCP Server

    Note over P: Each time the Agent's tools are assembled
    P->>S: initialize → tools/list (with configured headers)
    S-->>P: Tool list (name, description, inputSchema, annotations)
    P->>P: Filter by allowed tools, prefix names, convert to model tool definitions
    A->>P: tool call (tool name + JSON arguments)
    P->>S: Open a new session: initialize → tools/call
    S-->>P: CallToolResult
    P->>P: Convert to text, truncate to context window, write tool execution log
    P-->>A: Result text (or error message)
```

MaiAgent is the **MCP Client**. The Agent only decides which tool to call and which arguments to use; the platform makes the actual connection.

## Transports and URL Requirements <a href="#transport-and-url" id="transport-and-url"></a>

| Item                 | Specification                                                                                                                                                                                                                                                                                                       |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Supported transports | **Streamable HTTP** and **SSE**. stdio is not supported (the platform does not launch local commands such as `npx` or `node`).                                                                                                                                                                                      |
| Transport detection  | Detected automatically from the URL: SSE is used if the path ends in `/sse`, contains `/sse/`, or the query string includes `transport=sse`. All other URLs use Streamable HTTP.                                                                                                                                    |
| URL scheme           | Must be `https://`. `http://` is accepted only when the target resolves to an internal network allowed by the platform (the internal CIDR allowlist in the deployment configuration).                                                                                                                               |
| Network location     | The domain must resolve. Private, loopback, and other internal addresses are rejected unless they fall within the internal CIDR allowlist. Link-local addresses (such as `169.254.x.x`) are always rejected, even if allowlisted. In SaaS environments, your Server must be accessible from the public internet.    |
| TLS                  | Strict certificate verification is used by default. If a connection fails because the certificate chain uses an older root certificate without a Subject Key Identifier, only MaiAgent can enable the relaxed verification option in system administration, individually for each tool or domain. Contact MaiAgent. |

{% hint style="info" %}
Use Streamable HTTP and place the MCP endpoint on a dedicated path (for example, `https://mcp.example.com/mcp`). If your Server only supports SSE, use a URL ending in `/sse` so the platform connects over SSE.
{% endhint %}

## Connection Validation During Tool Setup <a href="#setup-validation" id="setup-validation"></a>

When you create or update an MCP tool in the console or through the API, the platform first connects to your Server and runs `tools/list`:

* If it cannot connect or retrieve the tool list, creation fails. The error message includes the connection error details.
* If the request omits `mcpAllowedTools` or passes an empty array `[]`, the platform stores all tool names returned by `tools/list` **at that moment** as the allowlist. Tools added to the Server later **are not enabled automatically**. Return to the tool settings, retrieve the list again, and select them.
* Connection validation only sends headers in `rawMcpHeader` that **do not contain** `{{variables}}` (there is no conversation yet to supply variable values). If your Server requires a dynamic header on every request, make sure `tools/list` can also respond with only static headers.
* If this URL matches an MCP Registry configured for OAuth (see [OAuth 2.0](#oauth)), validation is skipped during creation or updates, and the allowlist remains empty until authorization completes and the tool list can be retrieved.

## Authentication <a href="#authentication" id="authentication"></a>

The platform sends identity information to your Server through HTTP headers. The headers depend on the tool's <mark style="color:blue;">authorization subject</mark> (API field `authSubject`):

| `authSubject`      | Console name                                                      | Identity used                                 | Headers applied (later entries override earlier headers with the same name) |
| ------------------ | ----------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------- |
| `shared` (default) | <mark style="color:blue;">Organization-wide</mark>                | One authorization for the entire organization | Tool-level OAuth token → `rawMcpHeader` → the contact's MCP credentials     |
| `contact`          | <mark style="color:blue;">Individual contact authorization</mark> | The contact in the conversation               | `rawMcpHeader` → the contact's MCP credentials → the contact's OAuth token  |

In `contact` mode, this tool **is never provided** to the Agent in conversations with anonymous contacts (those without a `sourceId`).

### Static Headers (API Key) <a href="#static-headers" id="static-headers"></a>

The simplest approach is to put a JSON object in the tool's `rawMcpHeader`. It is sent with every connection.

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

* `rawMcpHeader` is write-only. Reading the tool returns only the masked `rawMcpHeaderMasked`.
* Values support the following `{{variables}}`, populated from the current conversation: `contact_id`, `source_id`, `contact_name`, `conversation_id`, `inbox_id`, `organization_id`, `agent_slug`, `agent_version`.

### Contact-level MCP Credentials <a href="#contact-credentials" id="contact-credentials"></a>

To let the Agent call your Server with **the end user's own permissions**, write the user's token into that contact's MCP credentials:

* Each contact can have at most one credential entry per MCP tool, containing a set of `headers`.
* During a conversation, the platform looks up credentials by “current conversation's contact + this tool” and merges the `headers` into the request headers **as-is**, overriding `rawMcpHeader` when names match. Credential headers **do not** substitute `{{variables}}`.
* Ways to write credentials:
  * When embedding Web Chat, call `setup-contact-credentials` and include `toolId` and `headers` in `mcpCredentials` (`toolId` must identify an MCP tool; otherwise, the response is 400).
  * From your backend, call the contact API's `mcp-credentials` endpoint to create, update, or delete credentials.

For full parameters, token updates, and revocation on logout for both approaches, see [Contact Identity Synchronization and Token Updates](/tech/en/serve/contacts/contact-credentials-sync.md). For manual console configuration, see [Contact MCP Credential Settings](https://docs.maiagent.ai/build/tools/setup-contacts-mcp-credentials) in the user manual.

### Conversation Identification Header <a href="#conversation-header" id="conversation-header"></a>

Whenever a conversation context is available, the platform adds `X-Conversation-Id: <conversation ID>` to every request. Stateful Servers can use it to retain state per conversation without relying on the model to pass a session token. If your authentication headers use the same name, the authentication header takes precedence.

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

If your MCP Server is protected by OAuth, the platform automatically completes the following flow according to the MCP authorization specification:

{% stepper %}
{% step %}

#### Discover the Protected Resource <a href="#oauth-resource-discovery" id="oauth-resource-discovery"></a>

The platform first sends a `GET` to the MCP URL without a token. If the response is `401` with `WWW-Authenticate: Bearer resource_metadata="..."`, it reads that URL (which must use `https` and have the same host as the MCP URL). Otherwise, it tries the default RFC 9728 locations: first `https://{host}/.well-known/oauth-protected-resource{MCP path}`, then `https://{host}/.well-known/oauth-protected-resource`.

It retrieves `resource` (the token audience) and `authorization_servers` (uses the first entry, which must use `https`). Discovery results are cached for 5 minutes.
{% endstep %}

{% step %}

#### Read Authorization Server Metadata <a href="#oauth-server-metadata" id="oauth-server-metadata"></a>

The platform reads `/.well-known/oauth-authorization-server` according to RFC 8414. If the authorization server URL includes a path, it tries three locations in order: well-known inserted between the host and path, well-known appended after the path, and the host root.
{% endstep %}

{% step %}

#### Dynamically Register the Client (RFC 7591) <a href="#oauth-dynamic-registration" id="oauth-dynamic-registration"></a>

If the metadata includes `registration_endpoint`, the platform sends:

```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"
}
```

If there is no `registration_endpoint`, configure `oauthClientId` in the MCP Registry in advance (also set `oauthClientSecret` for confidential clients). Otherwise, authorization fails.
{% endstep %}

{% step %}

#### Authorization Code + PKCE <a href="#oauth-authorization-code" id="oauth-authorization-code"></a>

The authorization request includes `response_type=code`, `state`, `code_challenge` (`S256`), and `resource`. The `scope` is selected in this order: scope configured in the MCP Registry → `scopes_supported` in protected resource metadata → `scopes_supported` in authorization server metadata. `state` is valid for 10 minutes.

When exchanging the code for a token, the platform uses `grant_type=authorization_code` with `code_verifier` and `resource`, including the client secret only if one exists.
{% endstep %}

{% step %}

#### Automatically Refresh the Token <a href="#oauth-refresh" id="oauth-refresh"></a>

Within 60 seconds before token expiry, the platform refreshes it using `grant_type=refresh_token`. If the response does not include a new `refresh_token`, the old one is retained.
{% endstep %}
{% endstepper %}

**Callback URL**: If your authorization server does not support dynamic registration and requires redirect URIs to be registered in advance, register both URLs below. Otherwise, the corresponding flow fails at `/authorize`:

| Flow                                     | Callback URL                                                                                                             |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Member (organization-wide) authorization | MaiAgent console domain + `/oauth/mcp/callback`                                                                          |
| Individual contact authorization         | `https://api.maiagent.ai/api/mcp/oauth/contacts/callback/` (SaaS; private deployments use that environment's API domain) |

**MCP Registry**: If your self-hosted Server requires a fixed `oauthClientId`, a specific authorization server URL, or a `resourceIdentifier`, a member with connector management permissions can create an organization-private MCP Registry (`/api/v1/mcp/private-registry/`). `authType` can be `oauth`, `service_account` (key sent through headers), or `none`. The platform matches tools to a Registry when the MCP URL starts with the Registry's `remoteMcpUrl`. Only fields left blank use automatically discovered values.

## Allowed Tools (`mcpAllowedTools`) <a href="#allowed-tools" id="allowed-tools"></a>

`mcpAllowedTools` is an array of Server tool names that determines which tools are provided to the Agent. At runtime, stored values are interpreted according to the authorization subject:

| Value                    | `shared`                  | `contact`                 |
| ------------------------ | ------------------------- | ------------------------- |
| `null`                   | All Server tools enabled  | All Server tools enabled  |
| `[]` (empty array)       | **No tools enabled**      | All Server tools enabled  |
| `["search_orders", ...]` | Only listed tools enabled | Only listed tools enabled |

In `contact` mode, an empty array enables all tools because administrators do not have the contact's token and cannot retrieve the tool list in advance. In `shared` mode with OAuth, the list is populated only after the administrator completes authorization and retrieves the tool list.

{% hint style="warning" %}
When creating or updating tools through the API:

* **Regular MCP tools**: Omitting `mcpAllowedTools` or passing `[]` replaces it with all tool names retrieved at that moment (see [Connection Validation During Tool Setup](#setup-validation)). You therefore cannot use `[]` to disable all tools.
* **Tools matching an OAuth MCP Registry**: `[]` is stored as-is. During updates, passing `[]` or omitting this field also clears the allowlist, leaving no tools enabled in `shared` mode. Always include the complete `mcpAllowedTools` when updating these tools.
  {% endhint %}

## How the Tool List Reaches the Model <a href="#tool-list-to-model" id="tool-list-to-model"></a>

* **When it is retrieved**: Each time the Agent's tools are assembled, the platform connects to your Server, runs `tools/list`, and filters the results by the allowlist.
* **Names**: If the tool has a configured `name`, the model sees `{name}__{your tool name}`. Characters other than alphanumerics and underscores are replaced with underscores. If the name does not start with an English letter, `t_` is prepended. Names longer than 64 characters are truncated and given a 6-character hash suffix.
* **Descriptions**: The model sees the `description` you return in `tools/list`. Clearly explain the tool's purpose and when to use it.
* **Parameters**: `inputSchema` is converted into the model's parameter definition. The platform automatically handles two common patterns: converting array-form property values to `anyOf`, and inlining local `$ref` references (recursive references are changed to accept any value). If the schema still cannot be converted, **only that tool is skipped**; other tools remain available.
* **Retrieval failure**: If the connection fails, times out, or fails authentication, this MCP tool **provides no tools** to the model for the current reply. The conversation itself continues.

## Tool Calls: Timeouts, Retries, and Errors <a href="#tool-call" id="tool-call"></a>

### Sessions and Timeouts <a href="#session-and-timeout" id="session-and-timeout"></a>

* Every `tools/call` **opens a new MCP session** (`initialize` followed by the call). Sessions are not reused across calls. To retain state across calls, use `X-Conversation-Id` or your own authenticated identity.
* The MCP session read timeout is 30 seconds.

### Automatic Retries for Transient Errors <a href="#transient-retry" id="transient-retry"></a>

For connection timeouts, interrupted or reset connections, disconnections before the Server responds, sessions closed mid-operation, and MCP error codes indicating a closed connection or 408, the platform retries up to 3 times at intervals of 1, 2, and 4 seconds (capped at 8 seconds). Error results returned by your Server and application-level errors **are not** retried.

Only tools that are safe to resend are retried, using this decision order:

1. The tool's `annotations` include `readOnlyHint: true` or `idempotentHint: true` → retry.
2. Either is explicitly `false` → do not retry.
3. If neither is declared, check whether the first word of the tool name is `get`, `list`, `read`, `fetch`, `search`, `find`, `check`, `query`, `describe`, `show`, `view`, `browse`, or `lookup` (camelCase and `-` are split into words first).

{% hint style="info" %}
Explicitly declare `readOnlyHint` / `idempotentHint` for every tool in `tools/list`. Mark tools with side effects (placing orders or submitting forms) as `false` to prevent resends after a disconnection.
{% endhint %}

### Content Returned to the Model <a href="#response-to-model" id="response-to-model"></a>

| Situation                                                              | Content seen by the model                                                                                                                                                                                                             |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Successful call                                                        | Text converted from `CallToolResult` (including the `content`, `structuredContent`, and `isError` fields). The model still sees this content when you return `isError: true`.                                                         |
| Exception during the call (retries exhausted or a non-transient error) | `MCP tool execution error\n MCP Tool Execution Error: <error details>`                                                                                                                                                                |
| Result too long                                                        | The limit is the model's context window × tool output ratio (default 0.8), estimated in tokens. When exceeded, the first and last halves are retained and the middle is omitted. A notice that the result is incomplete is prepended. |

Every call writes a tool execution log (tool, actual MCP tool name, input arguments, output or error, duration, and retry count). Debugging works the same way as [Logging and Debugging in the API Tool Engineering Guide](/tech/en/api-integration/api-tool-engineering-guide.md#records-and-debugging).

### Billing Categories <a href="#billing" id="billing"></a>

Each successful call counts as 1 MCP tool usage, categorized as a read or write operation by tool name. If the first word matches one of the read prefixes above, it is a read operation. Otherwise, a name containing a write keyword is a write operation; all remaining names count as read operations. If the organization has insufficient credits, the call is not sent to your Server.

## MCP Apps (Interactive Cards) <a href="#mcp-apps" id="mcp-apps"></a>

MaiAgent supports the MCP Apps extension (SEP-1865). In addition to returning data, tools can attach an HTML interface, which appears as an interactive card in the conversation, such as a chart, form, or query result list. For the user experience and limitations, see [MCP Interactive Cards (MCP Apps)](https://docs.maiagent.ai/build/tools/mcp-apps) in the user manual. To get started immediately, jump to the [Minimal Runnable Example](#mcp-apps-example).

### Server Side: Returning UI Resources <a href="#mcp-apps-server" id="mcp-apps-server"></a>

1. **Link the UI resource**: Include `ui.resourceUri` in `_meta` in the tool definition in `tools/list` or the `tools/call` result. The value must start with `ui://`, for example `{"_meta": {"ui": {"resourceUri": "ui://orders/list"}}}`. If both locations specify it, the call result takes precedence. The flat form `_meta["ui/resourceUri"]` (the format generated by `registerAppTool` in `@modelcontextprotocol/ext-apps`) is also accepted. The tool definition's `_meta` is read each time the tool list is assembled.
2. **Provide resource content**: After a successful tool call, the platform sends `resources/read` for this URI in the same MCP session and uses the first content entry's `text` as the card HTML. Use `text/html;profile=mcp-app` for `mimeType`.
3. **Return data**: The card receives the current tool result: the first text block in `content`, plus `structuredContent` (an object). Use `structuredContent` as the card's data source. The platform does not pass tool input arguments to the card (`arguments` in `ui/notifications/tool-input` is an empty object).

{% hint style="warning" %}
The platform's MCP client **does not** declare the `io.modelcontextprotocol/ui` extension capability during `initialize`. Do not use checks such as `getUiCapability()` to decide whether to attach `_meta`, or the platform will never receive the UI resource.
{% endhint %}

Cards do not appear, or fall back to text, in the following cases:

| Situation                                    | Result                                                                |
| -------------------------------------------- | --------------------------------------------------------------------- |
| Tool result has `isError: true`              | No card generated                                                     |
| No linked `ui://` resource                   | No card generated; the conversation displays the tool result normally |
| `resources/read` fails or returns no content | The card displays the first text block in `content` instead           |

The card HTML is saved with the message when it is generated. Updating the UI on the server later does not change cards already generated in historical messages. Make sure new UI versions can handle data produced by older versions.

### Frontend: How Cards Are Displayed <a href="#mcp-apps-rendering" id="mcp-apps-rendering"></a>

* **Location**: After the Agent finishes its reply, the card appears below that reply. Within the same reply, only the latest card from the same source and UI resource is displayed.
* **Sandbox**: Cards run in an iframe with `sandbox="allow-scripts"` (without `allow-same-origin`). They cannot read the host page's cookies or storage, or open new windows themselves.
* **Content Security Policy (CSP)**: By default, only inline scripts and styles are allowed. Images, media, and fonts must use `data:` / `blob:`. `connect-src` is `'none'` (no outbound requests), and form submissions are blocked. Bundle JS, CSS, and images into one HTML file.
* **Additional allowed origins**: If a card needs external resources, list their origins in `cspAllowedOrigins` in the organization-private MCP Registry (`/api/v1/mcp/private-registry/`). Only origins starting with `https://`, without paths or wildcards, are accepted, such as `https://viewer.example.com`. When creating or updating an MCP tool, a tool whose URL starts with the Registry's `remoteMcpUrl` is matched to that Registry.
* **Size and display mode**: Cards start at a default height of 220px and adjust according to the `height` reported by `ui/notifications/size-changed` (up to 2000px). The maximum height in the message stream is `min(62vh, 620px)`; taller cards scroll internally. Cards can request `fullscreen` mode. The platform adds an exit button in the upper-right corner, and users can also press Esc to return. After collapsing, the card retains its last reported height. Requests for `pip` use normal (`inline`) mode.
* **Hidden during streaming**: Cards mount only after the Agent's reply stream ends. When the same reply is updated multiple times, the platform pushes new results in place using `ui/notifications/tool-result` without rebuilding the iframe.

### Card–Host Handshake <a href="#mcp-apps-handshake" id="mcp-apps-handshake"></a>

Cards and the platform communicate through MCP Apps JSON-RPC via `window.parent.postMessage` (the host uses `AppBridge` from `@modelcontextprotocol/ext-apps` 1.2, protocol version `2026-01-26`). The sequence is:

1. The card sends `ui/initialize` (`appInfo`, `appCapabilities`, `protocolVersion`). The platform returns `hostInfo`, `hostCapabilities` (`serverTools`, `message.text`, `openLinks`), and `hostContext` (`displayMode`, `availableDisplayModes: ["inline", "fullscreen"]`, `theme: { primaryColor, mode }`).
2. The card sends `ui/notifications/initialized`.
3. The platform pushes `ui/notifications/tool-input` (empty `arguments`) followed by `ui/notifications/tool-result` (the complete `CallToolResult`). Later, changes to display mode or theme trigger `ui/notifications/host-context-changed`. Before the card is unmounted, the platform sends a `ui/resource-teardown` request.

The platform also injects `window.__MCP_INITIAL_RENDER_DATA__` (the same tool result) into the card HTML's `<head>` so the card can render before the handshake completes. The authoritative data still comes from the `tool-result` notification.

### Host Functions Available to Cards <a href="#mcp-apps-host-api" id="mcp-apps-host-api"></a>

| Method                                                          | Platform handling                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools/call`                                                    | The platform makes the call on the card's behalf. The card cannot connect directly to your server or the MaiAgent backend. It can only call tools from the same MCP source that generated the card, enabled in this conversation and included in the allowlist. Authentication headers are the same as those used by the Agent. Each call times out after 30 seconds. The result returned to the card retains only the first text block in `content`, `structuredContent`, and `isError`. On failure, a message outside the card says “This action could not be completed. Please try again later.” |
| `ui/message`                                                    | `content` must be an **array** of content blocks. The platform extracts its text blocks, submits them to the conversation as a user message, and first collapses the card to `inline` so the Agent can respond                                                                                                                                                                                                                                                                                                                                                                                      |
| `ui/open-link`                                                  | The platform opens a new tab. Only `https:`, `http:`, `tel:`, and `mailto:` are accepted. Credentials in the URL are removed. If disallowed or blocked by the browser, it returns `{ "isError": true }`                                                                                                                                                                                                                                                                                                                                                                                             |
| `ui/request-display-mode`                                       | Switches between `inline` and `fullscreen` and returns the mode actually applied                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `ui/notifications/size-changed`                                 | Adjusts the card height using `height` (ignored in fullscreen mode)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `ui/download-file`, `ui/update-model-context`, `resources/read` | Unsupported; the platform returns `-32601 Method not found`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |

### Scope <a href="#mcp-apps-scope" id="mcp-apps-scope"></a>

* Cards appear in Web Chat and MaiGPT conversations. Other messaging platforms such as LINE and Messenger receive only the Agent's text reply.
* Cards work for MCP tools attached to an Agent (Agent mode) and connectors authorized by members in MaiGPT. No additional switch is required on the MaiAgent side.
* Whether Web Chat in an on-premises deployment displays cards depends on deployment settings. Contact your MaiAgent representative.

### Minimal Runnable Example <a href="#mcp-apps-example" id="mcp-apps-example"></a>

A Node.js (18 or later) MCP Server: a tool for querying order status (returning mock data) links to a `ui://orders/status` card. The card can refresh the query and ask the AI questions. Put the three files in the same folder.

{% stepper %}
{% step %}

#### Create the Project <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`: Tool and UI Resource <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')

// Mock data: query your own system in production
const ORDERS = {
  A1001: { status: 'shipped', carrier: 'Black Cat Delivery', eta: '2026-10-06', items: ['Wireless mouse ×1', 'USB-C cable ×2'] },
  A1002: { status: 'processing', carrier: null, eta: '2026-10-08', items: ['Mechanical keyboard ×1'] },
}

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

  server.registerTool(
    'get_order_status',
    {
      description: 'Get the current shipping status and estimated delivery date of an order',
      inputSchema: { orderId: z.string().describe('Order ID, such as A1001') },
      annotations: { readOnlyHint: true },
      // Link the interactive card: the platform reads _meta.ui.resourceUri
      _meta: { ui: { resourceUri: CARD_URI } },
    },
    async ({ orderId }) => {
      const order = ORDERS[orderId]
      if (!order) {
        return { isError: true, content: [{ type: 'text', text: `Order ${orderId} not found` }] }
      }
      return {
        // The first text block in content is what the model sees and the plain-text fallback if the card fails to load
        content: [{ type: 'text', text: `Order ${orderId}: ${order.status}, estimated delivery ${order.eta}` }],
        // The card uses structuredContent as its data source
        structuredContent: { orderId, ...order },
      }
    },
  )

  // Card HTML: after a successful tool call, the platform sends resources/read to this URI in the same session
  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
  }
  // Stateless mode: create a server + transport for each request; the platform already opens a new session for every call
  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 single-file card <a href="#mcp-apps-example-card" id="mcp-apps-example-card"></a>

Use JSON-RPC directly through `postMessage` without importing any packages: perform the handshake, receive `tool-result`, report the height, refresh through `tools/call`, and ask AI through `ui/message`. Keep JS and CSS in the same file (the sandbox CSP does not allow external resources to load).

```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">Loading…</p></div>
  <button id="refresh" hidden>Refresh</button>
  <button id="ask" class="secondary" hidden>Ask AI about this order</button>

<script>
  // ---- JSON-RPC exchange with the host (MaiAgent): all messages use 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) Responses to our requests
    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) Notifications pushed by the host: after the handshake, tool-input arrives first, followed by tool-result
    if (msg.method === 'ui/notifications/tool-result') render(msg.params)
    // 3) The host requests card teardown: simply return an empty result
    if (msg.method === 'ui/resource-teardown' && msg.id !== undefined) {
      window.parent.postMessage({ jsonrpc: '2.0', id: msg.id, result: {} }, '*')
    }
  })

  // ---- View ----
  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 : 'No data'
      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>Order ' + escapeHtml(data.orderId) + ' <span class="status">' + escapeHtml(data.status) + '</span></h4>' +
        '<div>Estimated delivery: ' + escapeHtml(data.eta) + (data.carrier ? '（' + escapeHtml(data.carrier) + '）' : '') + '</div>' +
        '<table>' + rows + '</table>'
      document.getElementById('refresh').hidden = false
      document.getElementById('ask').hidden = false
    }
    // Report the height so the host can adjust the card height
    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] })
  }

  // Actions inside the card: the host calls tools on the same MCP server on its behalf
  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) })
  })
  // Questions inside the card: the text is sent to the conversation as a user message, and the Agent answers next
  document.getElementById('ask').addEventListener('click', function () {
    request('ui/message', { role: 'user', content: [{ type: 'text', text: 'When will order ' + currentOrder + ' arrive?' }] })
  })

  // ---- Handshake: ui/initialize → ui/notifications/initialized; only then does the host push 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 %}

#### Start and verify <a href="#mcp-apps-example-verify" id="mcp-apps-example-verify"></a>

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

Open another terminal and send three requests in the order used by the platform (stateless mode does not require a session header):

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

# 1) tools/list：Confirm that the tool includes _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：Confirm that content text and structuredContent are present
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":"Order A1001: shipped, estimated delivery 2026-10-06"}],"structuredContent":{"orderId":"A1001","status":"shipped", ...}}, ...}

# 3) resources/read：Confirm that the complete HTML is returned
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>..."}]}, ...}
```

Once all three return the correct responses, the platform can render the card.
{% endstep %}
{% endstepper %}

### Steps to connect to MaiAgent <a href="#mcp-apps-example-connect" id="mcp-apps-example-connect"></a>

1. Make the Server publicly accessible over `https://` (for local testing, use a tool such as `cloudflared tunnel --url http://localhost:8787` or `ngrok http 8787` to obtain a temporary URL). The MCP endpoint is `https://<你的網域>/mcp`.
2. Add an MCP tool in the admin console by following [Create an MCP tool](https://docs.maiagent.ai/build/tools/mcp-setup) in the user manual, and enter the endpoint from the previous step as the URL. This example requires no headers. After refreshing the list, "Allowed tools" should include `get_order_status`.
3. Assign the tool to an Agent whose reply mode is Agent mode, then ask "What is the status of order A1001?" in Web Chat or MaiGPT. After the Agent finishes replying, the card appears below the reply. Click <mark style="color:blue;">Refresh</mark> to use `tools/call`, or <mark style="color:blue;">Ask AI about this order</mark> to use `ui/message`. For user-facing behavior, see [MCP interactive cards (MCP Apps)](https://docs.maiagent.ai/build/tools/mcp-apps).

### Common errors <a href="#mcp-apps-example-errors" id="mcp-apps-example-errors"></a>

| Symptom                                                                         | Cause                                                                                                                                                                                                                                                                                              |
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The Agent calls the tool, but no card appears                                   | Both the tool definition in `tools/list` and the `tools/call` result lack `_meta.ui.resourceUri` (or its value does not start with `ui://`), the tool returns `isError: true`, or the channel, such as LINE, does not display cards                                                                |
| Only plain text appears where the card should be                                | `resources/read` fails or returns empty content, so the platform falls back to the first text block in `content`; check with the third curl command above                                                                                                                                          |
| The card stays on "Loading…"                                                    | The card has not completed the handshake (`ui/initialize` lacks `appInfo` / `appCapabilities` / `protocolVersion` in `params`, or `ui/notifications/initialized` was not sent), so the platform does not push `tool-result`; external JS loaded by the card HTML may also have been blocked by CSP |
| The card is blank                                                               | The HTML depends on external CSS / JS / images blocked by the sandbox CSP. Bundle them into the same HTML file, or configure `cspAllowedOrigins` in the Registry                                                                                                                                   |
| The card displays "This action could not be completed. Please try again later." | The platform rejects the card's `tools/call`, or the call fails: the tool name is not on the MCP tool's allowed list, the tool has been removed from the Agent, authorization has expired, the Server returns an error, or it does not respond within 30 seconds                                   |
| "Ask AI" on the card does not respond                                           | The `content` in `ui/message` is not an array, or it contains no block with `type: "text"`                                                                                                                                                                                                         |

## Pre-launch checklist <a href="#checklist" id="checklist"></a>

* [ ] The endpoint uses `https://`, is publicly accessible, and uses Streamable HTTP; if it only supports SSE, the URL ends with `/sse`.
* [ ] `tools/list` responds correctly even when only static headers are provided.
* [ ] Each tool's `description` clearly states its purpose, and its `inputSchema` is valid JSON Schema.
* [ ] Declare `readOnlyHint` / `idempotentHint` for each tool.
* [ ] Do not rely on MCP sessions across calls; use `X-Conversation-Id` or user identity when state is needed.
* [ ] User-level permissions: plan when to write contact MCP credentials (sync on login, delete on logout).
* [ ] When using OAuth: support RFC 9728 resource metadata, RFC 8414 authorization server metadata, PKCE (S256), and the `resource` parameter; if dynamic registration is unsupported, register both Callback URLs.
* [ ] After adding tools to the Server, return to the tool settings and refresh the list.
* [ ] When providing interactive cards: link a `ui://` resource through `_meta.ui.resourceUri`, return complete HTML from `resources/read`, use `structuredContent` as the card's data source, and make no external requests (configure `cspAllowedOrigins` in the Registry if needed).


---

# 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/en/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.
