diff --git a/libs/langchain_v1/langchain/mcp/__init__.py b/libs/langchain_v1/langchain/mcp/__init__.py index c8efb78a94..7262dec35c 100644 --- a/libs/langchain_v1/langchain/mcp/__init__.py +++ b/libs/langchain_v1/langchain/mcp/__init__.py @@ -1,5 +1,9 @@ """LangChain MCP adapters for connecting MCP servers with LangChain applications. +MCP Apps (SEP-1865) has its own module: the capability a host advertises, and +the filters that decide which audience a tool is for. Import those from +`langchain.mcp.apps`. + Interrupt-driven elicitation has its own types — the interrupt payload, the answers a run resumes with, and the discriminator to recognize them by. Import those from `langchain.mcp.elicitation`. diff --git a/libs/langchain_v1/langchain/mcp/apps.py b/libs/langchain_v1/langchain/mcp/apps.py new file mode 100644 index 0000000000..29c0cefe68 --- /dev/null +++ b/libs/langchain_v1/langchain/mcp/apps.py @@ -0,0 +1,139 @@ +"""MCP Apps (SEP-1865): which audience a tool is for, and how to say so. + +An MCP App is a tool that returns a user interface instead of text. The tool +carries a `ui://` resource in its `_meta.ui`, a host renders that resource in a +sandboxed frame beside the conversation, and the tool's result goes into it. + +That makes a tool's audience part of its definition. `_meta.ui.visibility` +names it: `["app"]` means the app may call the tool and the model may not, +because the app exists so a person decides and a model that can call the submit +tool can skip the person. **Absent means both audiences**, which is every tool +on every server that has never heard of this extension. + +A host built on `MCPAdapter` therefore owes the extension two lines. It +advertises `MCP_APPS_EXTENSION` when it opens a client, and it filters: + +```python +async with Client(url, extensions=[MCP_APPS_EXTENSION]) as client: + async with MCPAdapter(client) as adapter: + tools = filter_model_visible_tools(await adapter.list_tools()) + +agent = create_agent(model, tools=tools) +``` + +Rendering the view, reading the `ui://` resource and proxying a view's own +`tools/call` are the browser-facing half and are not in this module. +""" + +from __future__ import annotations + +from typing import Any, Final + +from mcp.client.extension import ClientExtension, advertise + +#: The MIME type every MCP App resource is served as. +UI_MIME_TYPE: Final = "text/html;profile=mcp-app" + +#: The capability identifier a client advertises to say it can render apps. +UI_EXTENSION_ID: Final = "io.modelcontextprotocol/ui" + +#: What a host declares during MCP's `initialize`, under +#: `ClientCapabilities.extensions`. +#: +#: Inert against a server that always sends `_meta.ui`, and load-bearing +#: against one that gates on the capability: the `ext-apps` SDK ships +#: `getUiCapability` for exactly that, and its documented example registers a +#: text-only tool for a client that did not advertise. Such a server leaves a +#: host with tools that work and no apps, and raises nothing on the way past. +#: +#: One value, shared: `advertise` returns an identifier and a settings dict and +#: holds nothing per client. +MCP_APPS_EXTENSION: Final[ClientExtension] = advertise( + UI_EXTENSION_ID, {"mimeTypes": [UI_MIME_TYPE]} +) + + +def _raw_meta(tool: Any) -> dict[str, Any]: + """A tool's `_meta`, from whichever kind of tool object this is. + + Both shapes turn up in one process: an MCP `Tool` off `list_tools()` + carries `_meta` as `.meta`, and the same tool adapted by `MCPAdapter` + keeps it under `metadata["mcp"]["tool"]["_meta"]`. Reading one and not the + other leaves every tool with no declared visibility, which is + indistinguishable from a server that has never heard of MCP Apps. + """ + meta = getattr(tool, "meta", None) + if isinstance(meta, dict): + return meta + + metadata = getattr(tool, "metadata", None) or {} + mcp = metadata.get("mcp") or {} + return ((mcp.get("tool") or {}).get("_meta")) or {} + + +def ui_meta(tool: Any) -> dict[str, Any]: + """The `_meta.ui` block a server declared on a tool, or an empty dict. + + Args: + tool: An MCP `Tool`, or a LangChain tool adapted from one. + + Returns: + The `ui` block, or `{}` for a tool that declares none. + """ + ui = _raw_meta(tool).get("ui") + return ui if isinstance(ui, dict) else {} + + +def app_uri(tool: Any) -> str | None: + """The `ui://` resource this tool opens, or `None` if it opens none. + + Args: + tool: An MCP `Tool`, or a LangChain tool adapted from one. + + Returns: + The resource URI, or `None` for a tool that ships no view. + """ + uri = ui_meta(tool).get("resourceUri") + return uri if isinstance(uri, str) else None + + +def _visible_to(tool: Any, who: str) -> bool: + """Whether `who` may call this tool, per the declared `visibility`. + + A `visibility` that is not a list is treated as absent, so a malformed + value falls back to both audiences rather than to neither. + """ + visibility = ui_meta(tool).get("visibility") + return not isinstance(visibility, list) or who in visibility + + +def filter_model_visible_tools(tools: list[Any]) -> list[Any]: + """The tools a model may be given. + + A tool whose `visibility` omits `"model"` MUST be kept out of the agent's + tool list. This is the filter for that, and the only thing MCP Apps asks + of an agent. + + Args: + tools: Tools from one server, in either shape `_raw_meta` reads. + + Returns: + The subset the model may be offered, in the order given. + """ + return [tool for tool in tools if _visible_to(tool, "model")] + + +def filter_app_visible_tools(tools: list[Any]) -> list[Any]: + """The tools a view may call. + + A host checks a view's `tools/call` against this and refuses anything the + server did not open to apps. The view is server-authored HTML in a frame, + so without the check the app side is a way to reach every tool. + + Args: + tools: Tools from one server, in either shape `_raw_meta` reads. + + Returns: + The subset a view may call, in the order given. + """ + return [tool for tool in tools if _visible_to(tool, "app")] diff --git a/libs/langchain_v1/tests/unit_tests/mcp/test_apps.py b/libs/langchain_v1/tests/unit_tests/mcp/test_apps.py new file mode 100644 index 0000000000..10ceb2cde8 --- /dev/null +++ b/libs/langchain_v1/tests/unit_tests/mcp/test_apps.py @@ -0,0 +1,150 @@ +"""Tests for MCP Apps (SEP-1865) tool visibility.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastmcp import Client, FastMCP +from fastmcp.server.dependencies import get_context +from mcp.types import Tool + +from langchain.mcp import MCPAdapter +from langchain.mcp.apps import ( + MCP_APPS_EXTENSION, + UI_EXTENSION_ID, + UI_MIME_TYPE, + app_uri, + filter_app_visible_tools, + filter_model_visible_tools, + ui_meta, +) + +VIEW = "ui://demo/view" + + +def tool(name: str, ui: dict[str, Any] | None = None) -> Tool: + """An MCP tool definition, with the `_meta.ui` a server would declare.""" + return Tool(name=name, inputSchema={"type": "object"}, _meta={"ui": ui} if ui else None) + + +def test_absent_visibility_means_both_audiences() -> None: + """The default that costs an ordinary server half its tools if inverted.""" + plain = tool("plain") + + assert filter_model_visible_tools([plain]) == [plain] + assert filter_app_visible_tools([plain]) == [plain] + + +def test_a_view_without_visibility_is_still_for_both() -> None: + """Declaring a view says nothing about who may call the tool.""" + opens_app = tool("opens_app", {"resourceUri": VIEW}) + + assert filter_model_visible_tools([opens_app]) == [opens_app] + assert filter_app_visible_tools([opens_app]) == [opens_app] + + +def test_an_app_only_tool_is_kept_from_the_model() -> None: + app_only = tool("submit", {"visibility": ["app"]}) + + assert filter_model_visible_tools([app_only]) == [] + assert filter_app_visible_tools([app_only]) == [app_only] + + +def test_a_model_only_tool_is_kept_from_the_app() -> None: + model_only = tool("search", {"visibility": ["model"]}) + + assert filter_model_visible_tools([model_only]) == [model_only] + assert filter_app_visible_tools([model_only]) == [] + + +def test_both_audiences_listed_is_visible_to_both() -> None: + both = tool("either", {"visibility": ["model", "app"]}) + + assert filter_model_visible_tools([both]) == [both] + assert filter_app_visible_tools([both]) == [both] + + +def test_a_malformed_visibility_falls_back_to_both_not_neither() -> None: + """A string where a list belongs must not silently hide a tool.""" + odd = tool("odd", {"visibility": "app"}) + + assert filter_model_visible_tools([odd]) == [odd] + assert filter_app_visible_tools([odd]) == [odd] + + +def test_filters_preserve_order_and_drop_only_what_they_must() -> None: + tools = [tool("a"), tool("b", {"visibility": ["app"]}), tool("c")] + + assert [t.name for t in filter_model_visible_tools(tools)] == ["a", "c"] + + +def test_ui_meta_and_app_uri_read_a_tool_definition() -> None: + assert ui_meta(tool("opens_app", {"resourceUri": VIEW})) == {"resourceUri": VIEW} + assert app_uri(tool("opens_app", {"resourceUri": VIEW})) == VIEW + + +def test_a_tool_with_no_view_has_no_app_uri() -> None: + assert ui_meta(tool("plain")) == {} + assert app_uri(tool("plain")) is None + assert app_uri(tool("submit", {"visibility": ["app"]})) is None + + +def test_a_non_string_resource_uri_is_not_a_uri() -> None: + assert app_uri(tool("odd", {"resourceUri": 3})) is None + + +def test_the_extension_advertises_the_apps_mime_type() -> None: + """What a server's `getUiCapability` reads to learn this host renders.""" + assert MCP_APPS_EXTENSION.identifier == UI_EXTENSION_ID == "io.modelcontextprotocol/ui" + assert MCP_APPS_EXTENSION.settings() == {"mimeTypes": [UI_MIME_TYPE]} + + +async def test_a_client_sends_the_capability_to_the_server() -> None: + """Advertising is only useful if it reaches the peer's `initialize`.""" + server = FastMCP("caps") + + @server.tool + def client_extensions() -> str: + """Report what the connecting client advertised.""" + params = get_context().session.client_params + caps = params.capabilities if params else None + return repr(getattr(caps, "extensions", None)) + + async with Client(server, extensions=[MCP_APPS_EXTENSION]) as client: + result = await client.call_tool("client_extensions") + + assert UI_EXTENSION_ID in str(result.content[0].text) + + +async def test_visibility_survives_the_adapter() -> None: + """The filters have to work on adapted tools, which move `_meta`. + + An MCP `Tool` carries it on `.meta`; the same tool after `MCPAdapter` + keeps it under `metadata["mcp"]["tool"]["_meta"]`. A host filters after + adapting, so reading only the first shape would offer the model every + app-only tool on the server. + """ + server = FastMCP("apps") + + @server.tool(meta={"ui": {"resourceUri": VIEW}}) + def opens_app() -> str: + """Ships a view, and both audiences may call it.""" + return "ok" + + @server.tool(meta={"ui": {"visibility": ["app"]}}) + def submit() -> str: + """The app's own tool.""" + return "ok" + + async with Client(server) as client, MCPAdapter(client) as adapter: + tools = await adapter.list_tools() + + assert {t.name for t in tools} == {"opens_app", "submit"} + assert [t.name for t in filter_model_visible_tools(tools)] == ["opens_app"] + assert {t.name for t in filter_app_visible_tools(tools)} == {"opens_app", "submit"} + assert app_uri(next(t for t in tools if t.name == "opens_app")) == VIEW + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/libs/langchain_v1/uv.lock b/libs/langchain_v1/uv.lock index 9986832e77..bad84295d4 100644 --- a/libs/langchain_v1/uv.lock +++ b/libs/langchain_v1/uv.lock @@ -31,7 +31,7 @@ resolution-markers = [ "python_full_version < '3.11' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "caio", version = "0.9.25", source = { registry = "https://pypi.org/simple" } }, + { name = "caio", version = "0.9.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } wheels = [ @@ -55,7 +55,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "caio", version = "0.12.2", source = { registry = "https://pypi.org/simple" } }, + { name = "caio", version = "0.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/14/31/edb06aabd8f8f0b56d659f30800795f40b93cba96be946ce179f6931e3a5/aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41", size = 21600, upload-time = "2026-08-04T22:59:27.171Z" } wheels = [ @@ -1532,12 +1532,12 @@ resolution-markers = [ "python_full_version < '3.11' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, - { name = "requests" }, + { name = "google-api-core", marker = "python_full_version < '3.13'" }, + { name = "google-auth", marker = "python_full_version < '3.13'" }, + { name = "google-cloud-core", marker = "python_full_version < '3.13'" }, + { name = "google-crc32c", marker = "python_full_version < '3.13'" }, + { name = "google-resumable-media", marker = "python_full_version < '3.13'" }, + { name = "requests", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/98/c0c6d10f893509585c755a6567689e914df3501ae269f46b0d67d7e7c70a/google_cloud_storage-3.5.0.tar.gz", hash = "sha256:10b89e1d1693114b3e0ca921bdd28c5418701fd092e39081bb77e5cee0851ab7", size = 17242207, upload-time = "2025-11-05T12:41:02.715Z" } wheels = [ @@ -1557,12 +1557,12 @@ resolution-markers = [ "python_full_version == '3.13.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, - { name = "requests" }, + { name = "google-api-core", marker = "python_full_version >= '3.13'" }, + { name = "google-auth", marker = "python_full_version >= '3.13'" }, + { name = "google-cloud-core", marker = "python_full_version >= '3.13'" }, + { name = "google-crc32c", marker = "python_full_version >= '3.13'" }, + { name = "google-resumable-media", marker = "python_full_version >= '3.13'" }, + { name = "requests", marker = "python_full_version >= '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/7e/73bb7512df1d1aad6ce3f9aed847cd40e0cd400ba4a85d86ab8eb412e9cc/google_cloud_storage-3.13.1.tar.gz", hash = "sha256:a80bf8cac2794808aa61c50c5f769ecbbe2d10331bacd0d69d30e59b14b346b2", size = 17341051, upload-time = "2026-08-06T06:24:42.229Z" } wheels = [ @@ -2001,7 +2001,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2581,7 +2581,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.6.3" +version = "1.6.4" source = { editable = "../core" } dependencies = [ { name = "httpx" }, @@ -2785,7 +2785,7 @@ wheels = [ [[package]] name = "langchain-openai" -version = "1.6.2" +version = "1.6.3" source = { editable = "../partners/openai" } dependencies = [ { name = "certifi" },