diff --git a/CHANGELOG.md b/CHANGELOG.md
index e14b979..6887502 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ follow [SemVer](https://semver.org/).
## [Unreleased]
+### Added
+- **Portable MCP Apps transport** — capability-aware tool metadata links
+ form and workspace renderers to one self-contained `ui://` resource.
+ User actions post back through the existing validation tools, and only
+ validated results are offered to host model context. Non-supporting and
+ partially supporting hosts retain the meaningful structured/text result
+ and name the manual fallback visibly.
+
## [0.9.1] — 2026-08-29
### Added
diff --git a/README.md b/README.md
index 792fd1b..fa26cdd 100644
--- a/README.md
+++ b/README.md
@@ -31,9 +31,11 @@ and serves six MCP tools — `elicitation_render_form`,
`elicitation_collect_workspace_action` — from this package via `uvx`. Decision cards,
pushback cards, progress forms, deliberation cards, triage boards,
confirm gates, ranking lists, and assumption reviews work out of the
-box; rich HTML renders where the host
-supports widgets, degrades to plain questions where it doesn't, and
-renders as portable markdown on text-only hosts — with typed replies
+box. MCP Apps hosts discover one shared `ui://` resource, render the
+rich surface inline, send user actions through the same server-side
+validator, and return the validated result to the conversation. Other
+hosts degrade to plain questions where possible and render as portable
+markdown on text-only hosts — with typed replies
parsed back into the same validator.
**As a Python library:**
@@ -102,6 +104,13 @@ if select_form_surface(form) == "widget":
## One schema, every surface
+- **MCP Apps transport** — capable hosts advertise
+ `io.modelcontextprotocol/ui`, receive UI metadata only after that
+ negotiation, and render the shared `ui://attune-forms/dynamic-surface/v1`
+ resource. App submissions call the existing collector tools; only a
+ successful validated result is offered back to model context. Hosts
+ missing app-to-server or app-to-chat capabilities show an explicit
+ manual-continuation state rather than a dead control.
- **Renderers** — `form_to_widget_html` (self-contained interactive
widget with postback), `form_to_askuserquestion` (batched payloads),
`form_to_elicitation_schema` (native MCP elicitation), and
diff --git a/plugin/skills/forms/SKILL.md b/plugin/skills/forms/SKILL.md
index a6d00f7..64a7315 100644
--- a/plugin/skills/forms/SKILL.md
+++ b/plugin/skills/forms/SKILL.md
@@ -207,14 +207,20 @@ wearing a new construct.
## Choosing a surface
-1. **Widget host** (the client renders HTML): call
- `elicitation_render_widget` and show the returned `html`. The form
- posts answers back as a JSON block marked
+1. **MCP Apps host**: call `elicitation_render_widget` (or
+ `elicitation_render_workspace`). After capability negotiation the host
+ discovers the linked `ui://attune-forms/dynamic-surface/v1` resource and
+ renders it inline. Its actions call the named server-side collector; only
+ a validated result is offered back to model context. If the embedded view
+ names a missing submission or continuation capability, continue with the
+ native or text path below — do not treat the rendered click as authority.
+2. **Legacy widget host** (the client renders returned HTML): show the
+ returned `html`. The form posts answers back as a JSON block marked
`__elicitation_response__` — parse it and validate with
`elicitation_collect_response`.
-2. **Native elicitation host**: call `elicitation_ask`; on
- `action: "unsupported"`, fall back to (3).
-3. **Plain conversation**: call `elicitation_render_form` and map each
+3. **Native elicitation host**: call `elicitation_ask`; on
+ `action: "unsupported"`, fall back to (4).
+4. **Plain conversation**: call `elicitation_render_form` and map each
batched payload to your host's question tool (or plain prose):
recommendation-first ordering, `multi_select` → multi-select,
constructs → single-select with the recommended option first and
@@ -222,7 +228,7 @@ wearing a new construct.
pre-expanded as one single-select per item; a ranking as one
single-select per rank slot; an assumption review as one
single-select per assumption plus its paired text question).
-4. **No widget, no question tool** (text-only hosts): render the form
+5. **No widget, no question tool** (text-only hosts): render the form
with `form_to_markdown` (library) and relay the markdown verbatim.
It ends with a JSON answer skeleton — the widget's exact postback
shape — and documents the line shorthand (`field_id: value`,
diff --git a/src/attune_forms/__init__.py b/src/attune_forms/__init__.py
index 6652935..e5a99ea 100644
--- a/src/attune_forms/__init__.py
+++ b/src/attune_forms/__init__.py
@@ -72,6 +72,16 @@
)
from attune_forms.markdown_ingestion import markdown_to_answers, problems_to_markdown
from attune_forms.markdown_surface import form_to_markdown
+from attune_forms.mcp_app import (
+ MCP_APP_MIME_TYPE,
+ MCP_APP_PROTOCOL_VERSION,
+ MCP_APP_RESOURCE_URI,
+ MCP_APPS_EXTENSION,
+ client_supports_mcp_apps,
+ mcp_app_resource,
+ mcp_app_result,
+ mcp_app_tool_meta,
+)
from attune_forms.models import (
ASSUMPTION_RULINGS,
FormQuestion,
@@ -138,6 +148,14 @@
"keyboard_mode_enabled",
"list_templates",
"markdown_to_answers",
+ "MCP_APPS_EXTENSION",
+ "MCP_APP_MIME_TYPE",
+ "MCP_APP_PROTOCOL_VERSION",
+ "MCP_APP_RESOURCE_URI",
+ "client_supports_mcp_apps",
+ "mcp_app_resource",
+ "mcp_app_result",
+ "mcp_app_tool_meta",
"needs_widget",
"problems_to_markdown",
"select_form_surface",
diff --git a/src/attune_forms/mcp_app.py b/src/attune_forms/mcp_app.py
new file mode 100644
index 0000000..67d5636
--- /dev/null
+++ b/src/attune_forms/mcp_app.py
@@ -0,0 +1,455 @@
+"""Portable MCP Apps transport for Attune forms and workspaces.
+
+The form and workspace renderers remain the presentation source of truth.
+This module supplies the host adapter around their trusted HTML output:
+
+* capability negotiation for ``io.modelcontextprotocol/ui``;
+* one predeclared ``ui://`` resource shared by every Attune surface;
+* a JSON-RPC ``postMessage`` bridge that sends widget submissions back
+ through the existing server-side collector tools.
+
+Hosts without MCP Apps ignore the tool metadata and keep receiving the same
+meaningful text/structured tool result. The app also names partial host
+support visibly instead of silently degrading a submission path.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from typing import Any
+
+MCP_APPS_EXTENSION = "io.modelcontextprotocol/ui"
+MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"
+MCP_APP_RESOURCE_URI = "ui://attune-forms/dynamic-surface/v1"
+MCP_APP_PROTOCOL_VERSION = "2026-01-26"
+
+_TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
+_COLLECT_MODES = frozenset({"form", "workspace", "response"})
+
+
+def _as_mapping(value: Any) -> Mapping[str, Any]:
+ """Return a mapping view for plain dictionaries or Pydantic models."""
+ if isinstance(value, Mapping):
+ return value
+ dump = getattr(value, "model_dump", None)
+ if callable(dump):
+ serialized = dump(by_alias=True)
+ if isinstance(serialized, Mapping):
+ return serialized
+ return {}
+
+
+def client_supports_mcp_apps(capabilities: Any) -> bool:
+ """Whether client capabilities advertise the MCP Apps HTML profile."""
+ cap_map = _as_mapping(capabilities)
+ extensions = _as_mapping(cap_map.get("extensions"))
+ ui = _as_mapping(extensions.get(MCP_APPS_EXTENSION))
+ mime_types = ui.get("mimeTypes")
+ return isinstance(mime_types, list) and MCP_APP_MIME_TYPE in mime_types
+
+
+def mcp_app_tool_meta(*, visibility: tuple[str, ...] = ("model", "app")) -> dict[str, Any]:
+ """Return standard tool metadata linking to Attune's shared UI resource."""
+ if not visibility or any(item not in {"model", "app"} for item in visibility):
+ raise ValueError("MCP App visibility must contain only 'model' and/or 'app'")
+ return {
+ "ui": {
+ "resourceUri": MCP_APP_RESOURCE_URI,
+ "visibility": list(visibility),
+ }
+ }
+
+
+def mcp_app_result(
+ *,
+ collect_tool: str,
+ collect_mode: str,
+) -> dict[str, Any]:
+ """Describe how the shared app must validate one rendered submission.
+
+ ``form`` collectors receive the original form plus the submitted answers.
+ ``workspace`` collectors also receive the original workspace and optional
+ binding. ``response`` collectors receive only the full authority-bound
+ response, for hosts such as attune-ai that retain canonical state
+ server-side. The renderer never authorizes any path; the named server tool
+ remains the validation and dispatch boundary.
+ """
+ if not _TOOL_NAME_RE.fullmatch(collect_tool):
+ raise ValueError("MCP App collector must be a stable MCP tool name")
+ if collect_mode not in _COLLECT_MODES:
+ raise ValueError("MCP App collect_mode must be 'form', 'workspace', or 'response'")
+ return {
+ "resource_uri": MCP_APP_RESOURCE_URI,
+ "collect_tool": collect_tool,
+ "collect_mode": collect_mode,
+ }
+
+
+def mcp_app_resource() -> dict[str, Any]:
+ """Return the predeclared MCP Apps resource definition and HTML content."""
+ return {
+ "uri": MCP_APP_RESOURCE_URI,
+ "name": "attune_dynamic_surface",
+ "description": "Interactive Attune form or command workspace",
+ "mime_type": MCP_APP_MIME_TYPE,
+ "text": _MCP_APP_HTML,
+ "meta": {"ui": {"prefersBorder": True}},
+ }
+
+
+_MCP_APP_HTML = r"""
+
+
+
+
+ Attune dynamic surface
+
+
+
+
+ Connecting the interactive surface…
+
+
+
+
+"""
+
+
+__all__ = [
+ "MCP_APPS_EXTENSION",
+ "MCP_APP_MIME_TYPE",
+ "MCP_APP_PROTOCOL_VERSION",
+ "MCP_APP_RESOURCE_URI",
+ "client_supports_mcp_apps",
+ "mcp_app_resource",
+ "mcp_app_result",
+ "mcp_app_tool_meta",
+]
diff --git a/src/attune_forms/mcp_server.py b/src/attune_forms/mcp_server.py
index 62e0979..b3e72ec 100644
--- a/src/attune_forms/mcp_server.py
+++ b/src/attune_forms/mcp_server.py
@@ -27,6 +27,7 @@
import mcp.types as types
from mcp.server import Server
+from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.stdio import stdio_server
from attune_forms.bridge import (
@@ -40,6 +41,14 @@
)
from attune_forms.elicitation_schema import form_to_elicitation_schema
from attune_forms.form_events import log_submission, maybe_keyboard_hint
+from attune_forms.mcp_app import (
+ MCP_APP_MIME_TYPE,
+ MCP_APPS_EXTENSION,
+ client_supports_mcp_apps,
+ mcp_app_resource,
+ mcp_app_result,
+ mcp_app_tool_meta,
+)
from attune_forms.widget import form_to_widget_html
from attune_forms.workspace import (
WorkspaceActionBinding,
@@ -339,11 +348,12 @@ def _workspace_response_schema() -> dict[str, Any]:
}
-def tool_definitions() -> list[types.Tool]:
+def tool_definitions(*, mcp_apps: bool = False) -> list[types.Tool]:
"""The mirrored tools (names/schemas match attune-ai\'s server)."""
form = _form_schema()
workspace = _workspace_schema()
binding = _workspace_binding_schema()
+ app_meta = mcp_app_tool_meta() if mcp_apps else None
return [
types.Tool(
name="elicitation_render_form",
@@ -376,6 +386,7 @@ def tool_definitions() -> list[types.Tool]:
},
"required": ["form"],
},
+ **({"_meta": app_meta} if app_meta else {}),
),
types.Tool(
name="elicitation_collect_response",
@@ -429,6 +440,7 @@ def tool_definitions() -> list[types.Tool]:
"required": ["workspace"],
"additionalProperties": False,
},
+ **({"_meta": app_meta} if app_meta else {}),
),
types.Tool(
name="elicitation_collect_workspace_action",
@@ -498,6 +510,10 @@ async def handle_render_widget(args: dict[str, Any]) -> dict[str, Any]:
"html": form_to_widget_html(form, args.get("message") or ""),
"title": form.title,
"field_ids": [q.id for q in form.questions],
+ "mcp_app": mcp_app_result(
+ collect_tool="elicitation_collect_response",
+ collect_mode="form",
+ ),
}
@@ -578,6 +594,10 @@ async def handle_render_workspace(args: dict[str, Any]) -> dict[str, Any]:
"view": view.id.value,
"action_ids": [action.id for action in view.actions],
"bound": binding is not None,
+ "mcp_app": mcp_app_result(
+ collect_tool="elicitation_collect_workspace_action",
+ collect_mode="workspace",
+ ),
}
@@ -663,7 +683,11 @@ async def handle_ask(args: dict[str, Any]) -> dict[str, Any]:
@_server.list_tools()
async def _list_tools() -> list[types.Tool]:
- return tool_definitions()
+ try:
+ capabilities = _server.request_context.session.client_params.capabilities
+ except (AttributeError, LookupError, RuntimeError):
+ capabilities = None
+ return tool_definitions(mcp_apps=client_supports_mcp_apps(capabilities))
@_server.call_tool()
@@ -674,9 +698,48 @@ async def _call_tool(name: str, arguments: dict[str, Any] | None) -> dict[str, A
return await handler(arguments or {})
+@_server.list_resources()
+async def _list_resources() -> list[types.Resource]:
+ resource = mcp_app_resource()
+ return [
+ types.Resource(
+ uri=resource["uri"],
+ name=resource["name"],
+ description=resource["description"],
+ mimeType=resource["mime_type"],
+ **{"_meta": resource["meta"]},
+ )
+ ]
+
+
+@_server.read_resource()
+async def _read_resource(uri: Any) -> list[ReadResourceContents]:
+ resource = mcp_app_resource()
+ if str(uri) != resource["uri"]:
+ raise ValueError(f"Unknown resource: {uri}")
+ return [
+ ReadResourceContents(
+ content=resource["text"],
+ mime_type=resource["mime_type"],
+ )
+ ]
+
+
+def _initialization_options() -> Any:
+ """Advertise the stable MCP Apps extension alongside core MCP."""
+ options = _server.create_initialization_options()
+ extensions = {
+ MCP_APPS_EXTENSION: {
+ "mimeTypes": [MCP_APP_MIME_TYPE],
+ }
+ }
+ capabilities = options.capabilities.model_copy(update={"extensions": extensions})
+ return options.model_copy(update={"capabilities": capabilities})
+
+
async def _run() -> None:
async with stdio_server() as (read, write):
- await _server.run(read, write, _server.create_initialization_options())
+ await _server.run(read, write, _initialization_options())
def main() -> None:
diff --git a/tests/test_mcp_app.py b/tests/test_mcp_app.py
new file mode 100644
index 0000000..7947841
--- /dev/null
+++ b/tests/test_mcp_app.py
@@ -0,0 +1,240 @@
+"""Contract tests for the shared MCP Apps transport."""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import subprocess
+
+import pytest
+
+from attune_forms.mcp_app import (
+ MCP_APP_MIME_TYPE,
+ MCP_APP_PROTOCOL_VERSION,
+ MCP_APP_RESOURCE_URI,
+ MCP_APPS_EXTENSION,
+ client_supports_mcp_apps,
+ mcp_app_resource,
+ mcp_app_result,
+ mcp_app_tool_meta,
+)
+
+
+def test_capability_negotiation_is_exact_and_fail_closed() -> None:
+ supported = {
+ "extensions": {
+ MCP_APPS_EXTENSION: {
+ "mimeTypes": ["text/plain", MCP_APP_MIME_TYPE],
+ }
+ }
+ }
+ assert client_supports_mcp_apps(supported) is True
+ assert client_supports_mcp_apps({}) is False
+ assert client_supports_mcp_apps({"extensions": {MCP_APPS_EXTENSION: {}}}) is False
+ assert (
+ client_supports_mcp_apps(
+ {"extensions": {MCP_APPS_EXTENSION: {"mimeTypes": MCP_APP_MIME_TYPE}}}
+ )
+ is False
+ )
+
+
+def test_tool_metadata_uses_the_standard_nested_ui_shape() -> None:
+ assert mcp_app_tool_meta() == {
+ "ui": {
+ "resourceUri": MCP_APP_RESOURCE_URI,
+ "visibility": ["model", "app"],
+ }
+ }
+ assert mcp_app_tool_meta(visibility=("app",))["ui"]["visibility"] == ["app"]
+ with pytest.raises(ValueError, match="visibility"):
+ mcp_app_tool_meta(visibility=("browser",))
+
+
+def test_collector_descriptor_supports_form_workspace_and_host_state() -> None:
+ assert mcp_app_result(
+ collect_tool="elicitation_collect_response",
+ collect_mode="form",
+ ) == {
+ "resource_uri": MCP_APP_RESOURCE_URI,
+ "collect_tool": "elicitation_collect_response",
+ "collect_mode": "form",
+ }
+ assert mcp_app_result(collect_tool="collect_workspace", collect_mode="workspace")
+ assert mcp_app_result(collect_tool="fix_workspace_collect_action", collect_mode="response")
+ with pytest.raises(ValueError, match="stable MCP tool"):
+ mcp_app_result(collect_tool="bad tool", collect_mode="form")
+ with pytest.raises(ValueError, match="collect_mode"):
+ mcp_app_result(collect_tool="collect", collect_mode="unknown")
+
+
+def test_resource_is_self_contained_and_names_every_degraded_state() -> None:
+ resource = mcp_app_resource()
+ html = resource["text"]
+ assert resource["uri"] == MCP_APP_RESOURCE_URI
+ assert resource["mime_type"] == MCP_APP_MIME_TYPE
+ assert html.startswith("")
+ assert "ui/initialize" in html
+ assert f"protocolVersion: '{MCP_APP_PROTOCOL_VERSION}'" in html
+ assert "ui/notifications/initialized" in html
+ assert "ui/notifications/tool-input" in html
+ assert "ui/notifications/tool-result" in html
+ assert "tools/call" in html
+ assert "ui/update-model-context" in html
+ assert "ui/message" in html
+ assert "hostCapabilities.updateModelContext" in html
+ assert "hostCapabilities.message" in html
+ assert html.index("validated.success !== true") < html.index(
+ "var continued = await continueValidatedInteraction"
+ )
+ assert "event.source !== window.parent" in html
+ assert "cannot submit MCP App tool calls" in html
+ assert "cannot continue automatically; continue in chat" in html
+ assert "native or text fallback" in html
+ assert "https://" not in html and "http://" not in html
+ assert "document.write" not in html
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")
+def test_resource_script_parses_as_javascript() -> None:
+ html = mcp_app_resource()["text"]
+ script = re.search(r"", html, re.DOTALL)
+ assert script is not None
+ result = subprocess.run(
+ ["node", "--check"],
+ input=script.group(1),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")
+def test_validated_submission_reaches_host_context_and_conversation() -> None:
+ html = mcp_app_resource()["text"]
+ match = re.search(r"", html, re.DOTALL)
+ assert match is not None
+ harness = f"""
+const assert = require('node:assert/strict');
+const source = {json.dumps(match.group(1))};
+const posted = [];
+const listeners = {{}};
+const status = {{
+ textContent: '',
+ tone: '',
+ setAttribute: function (name, value) {{ if (name === 'data-tone') this.tone = value; }}
+}};
+const surface = {{
+ innerHTML: '',
+ querySelectorAll: function () {{ return []; }}
+}};
+const parent = {{
+ postMessage: function (message) {{
+ posted.push(message);
+ if (!Object.prototype.hasOwnProperty.call(message, 'id')) return;
+ let result = {{}};
+ if (message.method === 'ui/initialize') {{
+ result = {{
+ hostCapabilities: {{
+ serverTools: {{}},
+ updateModelContext: {{ structuredContent: {{}} }},
+ message: {{ text: {{}} }}
+ }},
+ hostContext: {{ theme: 'light' }}
+ }};
+ }} else if (message.method === 'tools/call') {{
+ result = {{ content: [{{ type: 'text', text: JSON.stringify({{
+ success: true,
+ responses: {{ choice: 'repair' }},
+ response_id: 'receipt-123'
+ }}) }}] }};
+ }}
+ queueMicrotask(function () {{
+ listeners.message({{
+ source: parent,
+ data: {{ jsonrpc: '2.0', id: message.id, result: result }}
+ }});
+ }});
+ }}
+}};
+global.document = {{
+ getElementById: function (id) {{ return id === 'attune-app-status' ? status : surface; }},
+ documentElement: {{
+ scrollWidth: 640,
+ scrollHeight: 480,
+ style: {{ colorScheme: '', setProperty: function () {{}} }}
+ }},
+ createElement: function () {{ return {{ textContent: '', replaceWith: function () {{}} }}; }}
+}};
+global.window = {{
+ parent: parent,
+ setTimeout: setTimeout,
+ clearTimeout: clearTimeout,
+ requestAnimationFrame: function (callback) {{ callback(); }},
+ addEventListener: function (name, callback) {{ listeners[name] = callback; }},
+ ResizeObserver: null
+}};
+eval(source);
+
+(async function () {{
+ await new Promise(function (resolve) {{ setTimeout(resolve, 0); }});
+ listeners.message({{
+ source: parent,
+ data: {{
+ jsonrpc: '2.0',
+ method: 'ui/notifications/tool-input',
+ params: {{ arguments: {{ form: {{ title: 'Repair' }} }} }}
+ }}
+ }});
+ listeners.message({{
+ source: parent,
+ data: {{
+ jsonrpc: '2.0',
+ method: 'ui/notifications/tool-result',
+ params: {{
+ content: [{{ type: 'text', text: JSON.stringify({{
+ success: true,
+ html: '',
+ mcp_app: {{
+ collect_tool: 'elicitation_collect_response',
+ collect_mode: 'form'
+ }}
+ }}) }}]
+ }}
+ }}
+ }});
+ await window.sendPrompt(
+ 'Submitted\\n```json\\n' + JSON.stringify({{
+ __elicitation_response__: true,
+ answers: {{ choice: 'repair' }}
+ }}) + '\\n```'
+ );
+ const calls = posted.filter(function (item) {{ return item.id; }});
+ assert.deepEqual(calls.map(function (item) {{ return item.method; }}), [
+ 'ui/initialize',
+ 'tools/call',
+ 'ui/update-model-context',
+ 'ui/message'
+ ]);
+ assert.equal(calls[1].params.arguments.form.title, 'Repair');
+ assert.equal(
+ calls[2].params.structuredContent.attune_submission.result.response_id,
+ 'receipt-123'
+ );
+ assert.equal(calls[2].params.content, undefined);
+ assert.equal(calls[3].params.role, 'user');
+ assert.match(status.textContent, /conversation was notified/);
+}})().catch(function (error) {{
+ console.error(error.stack || error.message);
+ process.exitCode = 1;
+}});
+"""
+ result = subprocess.run(
+ ["node", "-e", harness],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 606ec96..707b531 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -17,7 +17,15 @@
mcp = pytest.importorskip("mcp")
from mcp import ClientSession, StdioServerParameters # noqa: E402
+from mcp import types as mcp_types # noqa: E402
from mcp.client.stdio import get_default_environment, stdio_client # noqa: E402
+from mcp.shared.version import LATEST_PROTOCOL_VERSION # noqa: E402
+
+from attune_forms.mcp_app import ( # noqa: E402
+ MCP_APP_MIME_TYPE,
+ MCP_APP_RESOURCE_URI,
+ MCP_APPS_EXTENSION,
+)
_SRC = str(Path(__file__).resolve().parents[1] / "src")
@@ -89,6 +97,38 @@ def _payload(result) -> dict:
return json.loads(text)
+class _McpAppsClientSession(ClientSession):
+ """ClientSession that advertises the official MCP Apps extension."""
+
+ async def initialize(self) -> mcp_types.InitializeResult:
+ capabilities = mcp_types.ClientCapabilities.model_validate(
+ {
+ "extensions": {
+ MCP_APPS_EXTENSION: {
+ "mimeTypes": [MCP_APP_MIME_TYPE],
+ }
+ }
+ }
+ )
+ result = await self.send_request(
+ mcp_types.ClientRequest(
+ mcp_types.InitializeRequest(
+ params=mcp_types.InitializeRequestParams(
+ protocolVersion=LATEST_PROTOCOL_VERSION,
+ capabilities=capabilities,
+ clientInfo=self._client_info,
+ )
+ )
+ ),
+ mcp_types.InitializeResult,
+ )
+ self._server_capabilities = result.capabilities
+ await self.send_notification(
+ mcp_types.ClientNotification(mcp_types.InitializedNotification())
+ )
+ return result
+
+
async def _round_trip(home: Path) -> dict[str, dict]:
out: dict[str, dict] = {}
async with stdio_client(_server_params(home)) as (read, write):
@@ -156,11 +196,43 @@ async def _round_trip(home: Path) -> dict[str, dict]:
return out
+async def _mcp_apps_round_trip(home: Path) -> dict[str, object]:
+ out: dict[str, object] = {}
+ async with stdio_client(_server_params(home)) as (read, write):
+ async with _McpAppsClientSession(read, write) as session:
+ initialized = await session.initialize()
+ out["server_capabilities"] = initialized.capabilities.model_dump(
+ by_alias=True, exclude_none=True
+ )
+
+ tools = await session.list_tools()
+ out["tool_meta"] = {tool.name: tool.meta for tool in tools.tools}
+
+ resources = await session.list_resources()
+ out["resource_uris"] = [str(resource.uri) for resource in resources.resources]
+ resource = await session.read_resource(resources.resources[0].uri)
+ out["resource"] = resource.contents[0]
+
+ rendered = await session.call_tool("elicitation_render_widget", {"form": _FORM})
+ out["render_widget"] = _payload(rendered)
+ workspace = await session.call_tool(
+ "elicitation_render_workspace",
+ {"workspace": _WORKSPACE, "binding": _BINDING},
+ )
+ out["render_workspace"] = _payload(workspace)
+ return out
+
+
@pytest.fixture(scope="module")
def round_trip(tmp_path_factory):
return asyncio.run(_round_trip(tmp_path_factory.mktemp("attune-home")))
+@pytest.fixture(scope="module")
+def mcp_apps_round_trip(tmp_path_factory):
+ return asyncio.run(_mcp_apps_round_trip(tmp_path_factory.mktemp("attune-app-home")))
+
+
def test_all_six_tools_listed(round_trip):
assert round_trip["tools"]["names"] == [
"elicitation_ask",
@@ -223,6 +295,41 @@ def test_workspace_tools_round_trip_over_real_stdio(round_trip):
assert any("revision does not match" in problem for problem in stale["problems"])
+def test_mcp_apps_negotiates_resource_metadata_and_transport_over_real_stdio(
+ mcp_apps_round_trip,
+) -> None:
+ extensions = mcp_apps_round_trip["server_capabilities"]["extensions"]
+ assert extensions[MCP_APPS_EXTENSION]["mimeTypes"] == [MCP_APP_MIME_TYPE]
+
+ tool_meta = mcp_apps_round_trip["tool_meta"]
+ assert tool_meta["elicitation_render_widget"]["ui"]["resourceUri"] == MCP_APP_RESOURCE_URI
+ assert tool_meta["elicitation_render_workspace"]["ui"]["resourceUri"] == MCP_APP_RESOURCE_URI
+ assert tool_meta["elicitation_render_form"] is None
+
+ assert mcp_apps_round_trip["resource_uris"] == [MCP_APP_RESOURCE_URI]
+ resource = mcp_apps_round_trip["resource"]
+ assert resource.mimeType == MCP_APP_MIME_TYPE
+ assert "ui/notifications/tool-result" in resource.text
+
+ form = mcp_apps_round_trip["render_widget"]
+ assert form["mcp_app"]["collect_tool"] == "elicitation_collect_response"
+ assert form["mcp_app"]["collect_mode"] == "form"
+ workspace = mcp_apps_round_trip["render_workspace"]
+ assert workspace["mcp_app"]["collect_tool"] == "elicitation_collect_workspace_action"
+ assert workspace["mcp_app"]["collect_mode"] == "workspace"
+
+
+def test_non_ui_client_gets_no_ui_metadata_but_keeps_meaningful_result(round_trip) -> None:
+ from attune_forms.mcp_server import tool_definitions
+
+ tools = {tool.name: tool for tool in tool_definitions()}
+ assert tools["elicitation_render_widget"].meta is None
+ rendered = round_trip["render_widget"]
+ assert rendered["success"] is True
+ assert rendered["html"]
+ assert rendered["mcp_app"]["collect_mode"] == "form"
+
+
def test_workspace_handlers_preserve_the_problems_contract_on_import() -> None:
from attune_forms.mcp_server import handle_render_workspace