diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml
index b7b39bbaf5..a8b367c6ad 100644
--- a/.github/workflows/shared.yml
+++ b/.github/workflows/shared.yml
@@ -36,6 +36,9 @@ jobs:
- name: Workflow script tests
run: node --test '.github/scripts/*.test.js'
+ - name: MCP Apps UI script tests
+ run: node --test tests/docs_src/test_apps.test.js
+
- name: Surface types match vendored schema
run: |
uv sync --group codegen --frozen
diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md
index 3b6232cad8..b15323c920 100644
--- a/docs/advanced/apps.md
+++ b/docs/advanced/apps.md
@@ -20,7 +20,7 @@ then come back.
## A clock with a face
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -51,7 +51,7 @@ The model reads `content`; the iframe is for humans. A UI-capable host still fee
the text result to the model, and a text-only client gets *only* that. So the
canonical pattern is one tool, two answers. Look at `get_time` again:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py
index 6cbefdff7a..f62e3016a3 100644
--- a/docs_src/apps/tutorial001.py
+++ b/docs_src/apps/tutorial001.py
@@ -7,10 +7,29 @@
Clock
...
"""
diff --git a/examples/stories/apps/server.py b/examples/stories/apps/server.py
index 74d412e02c..1b4ad79ebb 100644
--- a/examples/stories/apps/server.py
+++ b/examples/stories/apps/server.py
@@ -17,10 +17,29 @@
Current time
…
"""
diff --git a/i18n/de/pages/advanced/apps.md b/i18n/de/pages/advanced/apps.md
index 266de0496d..1ad8fc751d 100644
--- a/i18n/de/pages/advanced/apps.md
+++ b/i18n/de/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ Das SDK liefert das als eingebaute Extension `Apps` (`io.modelcontextprotocol/ui
## Eine Uhr mit Gesicht {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ Nicht jeder Client rendert Apps. Die Spezifikation sagt unverblümt, was das fü
Das Modell liest `content`; der iframe ist für Menschen. Ein UI-fähiger Host füttert das Modell trotzdem mit dem Textergebnis, und ein reiner Text-Client bekommt *nur* das. Das kanonische Muster ist also: ein Tool, zwei Antworten. Sieh dir `get_time` noch einmal an:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/es/pages/advanced/apps.md b/i18n/es/pages/advanced/apps.md
index 163ceb906e..46ae17aa48 100644
--- a/i18n/es/pages/advanced/apps.md
+++ b/i18n/es/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -25,7 +25,7 @@ y luego vuelve.
## Un reloj con cara visible {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -56,7 +56,7 @@ El modelo lee `content`; el iframe es para humanos. Un host capaz de mostrar UI
el resultado en texto al modelo, y un cliente solo de texto recibe *solo* eso. Así que el
patrón canónico es una herramienta, dos respuestas. Mira `get_time` de nuevo:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/fr/pages/advanced/apps.md b/i18n/fr/pages/advanced/apps.md
index 414eee7e62..a315dc9967 100644
--- a/i18n/fr/pages/advanced/apps.md
+++ b/i18n/fr/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ Le SDK fournit cela sous la forme de l’extension intégrée `Apps` (`io.modelc
## Une horloge avec un cadran {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ Tous les clients n’affichent pas les apps. La spécification dit sans détour
Le modèle lit `content` ; l’iframe est pour les humains. Un hôte capable d’afficher une interface transmet quand même le résultat textuel au modèle, et un client purement textuel ne reçoit *que* cela. Le schéma canonique est donc : un outil, deux réponses. Regardez à nouveau `get_time` :
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/hi/pages/advanced/apps.md b/i18n/hi/pages/advanced/apps.md
index efaea6c3a6..cf57545323 100644
--- a/i18n/hi/pages/advanced/apps.md
+++ b/i18n/hi/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ SDK इसे built-in `Apps` extension (`io.modelcontextprotocol/ui`) के
## चेहरे वाली घड़ी {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -49,7 +49,7 @@ model `content` पढ़ता है; iframe इंसानों के ल
model को देता है, और text-only client को **सिर्फ़** वही मिलता है। इसलिए मानक
pattern है: एक tool, दो जवाब। `get_time` को फिर से देखें:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/ja/pages/advanced/apps.md b/i18n/ja/pages/advanced/apps.md
index d1b4bf587e..d211cb10fc 100644
--- a/i18n/ja/pages/advanced/apps.md
+++ b/i18n/ja/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ SDK はこれを組み込みの `Apps` 拡張(`io.modelcontextprotocol/ui`)
## 見た目のある時計 {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ HTML 自体はホストの `postMessage` を待ち受けて結果を表示しま
モデルが読むのは `content` で、iframe は人間のためのものです。UI に対応したホストでもテキストの結果はモデルに渡されますし、テキスト専用のクライアントはそれ「だけ」を受け取ります。ですから定番のパターンは「1 つのツール、2 つの答え」です。もう一度 `get_time` を見てください。
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/ko/pages/advanced/apps.md b/i18n/ko/pages/advanced/apps.md
index 2b10642ac7..1e68a1a7d2 100644
--- a/i18n/ko/pages/advanced/apps.md
+++ b/i18n/ko/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ SDK는 이를 내장 `Apps` 확장(`io.modelcontextprotocol/ui`)으로 제공합
## 얼굴을 가진 시계 {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ HTML 자체는 호스트의 `postMessage`를 수신하고 결과를 표시합니
모델은 `content`를 읽고, iframe은 사람을 위한 것입니다. UI를 지원하는 호스트도 여전히 텍스트 결과를 모델에 전달하며, 텍스트 전용 클라이언트는 **오직** 그 텍스트만 받습니다. 따라서 표준 패턴은 도구 하나에 답 둘입니다. `get_time`을 다시 살펴보세요.
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/pt/pages/advanced/apps.md b/i18n/pt/pages/advanced/apps.md
index 49a412660f..273da30ea5 100644
--- a/i18n/pt/pages/advanced/apps.md
+++ b/i18n/pt/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -25,7 +25,7 @@ e depois volte aqui.
## Um relógio com uma cara {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -56,7 +56,7 @@ O modelo lê `content`; o iframe é para humanos. Um host com suporte a UI ainda
o resultado em texto para o modelo, e um cliente só de texto recebe *apenas* isso. Então o
padrão canônico é uma ferramenta, duas respostas. Olhe `get_time` de novo:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/ru/pages/advanced/apps.md b/i18n/ru/pages/advanced/apps.md
index 772437d023..1d91c20838 100644
--- a/i18n/ru/pages/advanced/apps.md
+++ b/i18n/ru/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ translation:
## Часы с циферблатом {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ translation:
Модель читает `content`; iframe — для людей. Хост с поддержкой UI всё равно передаёт текстовый результат модели, а чисто текстовый клиент получает *только* его. Поэтому канонический паттерн — один инструмент, два ответа. Взгляните на `get_time` ещё раз:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/tr/pages/advanced/apps.md b/i18n/tr/pages/advanced/apps.md
index dbe3380688..18207f063b 100644
--- a/i18n/tr/pages/advanced/apps.md
+++ b/i18n/tr/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ SDK bunu yerleşik `Apps` uzantısı (`io.modelcontextprotocol/ui`) olarak sunar
## Yüzü olan bir saat {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ Her istemci uygulamaları çizmez. Şartname bunun sizin için ne anlama geldiğ
Model `content`'i okur; iframe insanlar içindir. UI destekli bir host yine de metin sonucunu modele iletir, yalnızca metin destekleyen bir istemci ise *sadece* onu alır. Yani kanonik desen tek araç, iki yanıttır. `get_time`'a bir daha bakın:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/uk/pages/advanced/apps.md b/i18n/uk/pages/advanced/apps.md
index 76f5f941fe..12c6b34fae 100644
--- a/i18n/uk/pages/advanced/apps.md
+++ b/i18n/uk/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ SDK постачає це як вбудоване розширення `Apps` (`
## Годинник із циферблатом {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ SDK постачає це як вбудоване розширення `Apps` (`
Модель читає `content`; iframe — для людей. Хост із підтримкою UI все одно передає текстовий результат моделі, а суто текстовий клієнт отримує *лише* його. Тож канонічний шаблон — один інструмент, дві відповіді. Погляньте на `get_time` ще раз:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/zh-hant/pages/advanced/apps.md b/i18n/zh-hant/pages/advanced/apps.md
index 73884b17ec..22cf4f6bf6 100644
--- a/i18n/zh-hant/pages/advanced/apps.md
+++ b/i18n/zh-hant/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ SDK 以內建的 `Apps` 擴充功能(`io.modelcontextprotocol/ui`)提供這
## 有錶面的時鐘 {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ HTML 本身會監聽主機的 `postMessage` 並顯示結果。真正的 app 請
模型讀的是 `content`;iframe 是給人看的。支援 UI 的主機照樣會把文字結果餵給模型,而純文字用戶端**只**會拿到那個。所以標準做法是一個工具,兩種答案。再看一次 `get_time`:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/i18n/zh/pages/advanced/apps.md b/i18n/zh/pages/advanced/apps.md
index 1cc37465a5..3d0d76d26e 100644
--- a/i18n/zh/pages/advanced/apps.md
+++ b/i18n/zh/pages/advanced/apps.md
@@ -1,6 +1,6 @@
---
translation:
- sections: [0355618e5f4d5fe4, 0fefa31fb7c7b585, 5c53e7487c9c70cc, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
+ sections: [0355618e5f4d5fe4, b4ac54f338092ec6, 1419418a05511012, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
@@ -18,7 +18,7 @@ SDK 把它作为内置的 `Apps` 扩展(`io.modelcontextprotocol/ui`)提供
## 一个带界面的时钟 {#a-clock-with-a-face}
-```python title="server.py" hl_lines="17 20 28 30"
+```python title="server.py" hl_lines="36 39 47 49"
--8<-- "docs_src/apps/tutorial001.py"
```
@@ -39,7 +39,7 @@ HTML 本身监听宿主的 `postMessage` 并显示结果。真正的应用请在
模型读的是 `content`;iframe 是给人看的。支持 UI 的宿主照样会把文本结果交给模型,而纯文本客户端**只**拿到那部分。所以标准模式是一个工具、两种回答。再看一遍 `get_time`:
-```python title="server.py" hl_lines="21-25"
+```python title="server.py" hl_lines="40-44"
--8<-- "docs_src/apps/tutorial001.py"
```
diff --git a/tests/docs_src/test_apps.test.js b/tests/docs_src/test_apps.test.js
new file mode 100644
index 0000000000..62ce079690
--- /dev/null
+++ b/tests/docs_src/test_apps.test.js
@@ -0,0 +1,95 @@
+const assert = require("node:assert/strict");
+const { execFileSync } = require("node:child_process");
+const path = require("node:path");
+const test = require("node:test");
+const vm = require("node:vm");
+
+const root = path.resolve(__dirname, "../..");
+const python = `
+import json
+
+import anyio
+
+from docs_src.apps import tutorial001
+from mcp import Client
+from mcp.client import advertise
+from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
+from mcp.types import TextResourceContents
+from stories.apps.server import RESOURCE_URI, build_server
+
+
+async def main() -> None:
+ cases = []
+ for name, server, uri in (
+ ("tutorial", tutorial001.mcp, "ui://clock/app.html"),
+ ("story", build_server(), RESOURCE_URI),
+ ):
+ async with Client(server, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
+ resource = await client.read_resource(uri)
+ result = await client.call_tool("get_time", {})
+ content = resource.contents[0]
+ assert isinstance(content, TextResourceContents)
+ assert content.mime_type == APP_MIME_TYPE
+ cases.append({"name": name, "html": content.text, "result": result.model_dump(mode="json", by_alias=True)})
+ print(json.dumps(cases))
+
+
+anyio.run(main)
+`;
+
+const cases = JSON.parse(
+ execFileSync("uv", ["run", "--frozen", "--no-sync", "python", "-c", python], {
+ cwd: root,
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PYTHONPATH: [root, path.join(root, "examples"), process.env.PYTHONPATH]
+ .filter(Boolean)
+ .join(path.delimiter),
+ },
+ }),
+);
+
+for (const { name, html, result } of cases) {
+ test(`${name} clock shows the tool-result notification`, () => {
+ const script = html.match(/