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