mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 01:15:09 +03:00
feat(langchain): MCP Apps (SEP-1865) tool visibility in langchain.mcp
An MCP App is a tool that returns a user interface instead of text: it carries
a `ui://` resource in its `_meta.ui`, a host renders that in a sandboxed frame
beside the conversation, and the tool's result goes into it. The person moves
a slider and approves rather than reading a table and typing "yes, do that."
That makes a tool's audience part of its definition, and it puts one
obligation on anyone building an agent against such a server: a tool marked
`visibility: ["app"]` belongs to the app and MUST be kept out of the model's
tool list. The app exists so a person decides, and a model that can call the
submit tool can skip the person.
`langchain.mcp.apps` is that, and only that:
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())
Two details are easy to get backwards, and both fail silently rather than
loudly, which is why they belong in a library and not in every host:
ABSENT `visibility` MEANS BOTH AUDIENCES. It is absent on every tool on every
server that has never heard of this extension. Treating absent as "neither"
costs such a server half its tools, with nothing raised.
`_meta` MOVES WHEN A TOOL IS ADAPTED. 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 hands the model
every app-only tool on the server. There is a test for exactly that path.
`MCP_APPS_EXTENSION` is what a host advertises during `initialize`. It is 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 that,
and its documented example registers a text-only tool for a client that did not
advertise, leaving a host with working tools and no apps.
Rendering the view, reading the `ui://` resource and proxying a view's own
`tools/call` are the browser-facing half and are not here.
13 tests; 91 pass in `tests/unit_tests/mcp`. ruff, format and mypy clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7e89d6c79c
commit
4dd6f7101a
4 files changed
+310
-17
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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")]
|
||||
@@ -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__])
|
||||
Generated
+17
-17
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user