Skip to content

Commit 2c2fb81

Browse files
authored
annotations as tool metadata (#164)
* Annotations as exposed as tool metadata. Internal changes: * Moving logic to run test server into utils. * Creating a way to run a server as a background task for the async test path. * Adding this as an end to end test.
1 parent 570d5c3 commit 2c2fb81

5 files changed

Lines changed: 86 additions & 9 deletions

File tree

langchain_mcp_adapters/tools.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ def convert_mcp_tool_to_langchain_tool(
5353
Args:
5454
session: MCP client session
5555
tool: MCP tool to convert
56-
connection: Optional connection config to use to create a new session if a `session` is not provided
56+
connection: Optional connection config to use to create a new session
57+
if a `session` is not provided
5758
5859
Returns:
5960
a LangChain tool
@@ -81,6 +82,7 @@ async def call_tool(
8182
args_schema=tool.inputSchema,
8283
coroutine=call_tool,
8384
response_format="content_and_artifact",
85+
metadata=tool.annotations.model_dump() if tool.annotations else None,
8486
)
8587

8688

@@ -91,12 +93,9 @@ async def load_mcp_tools(
9193
) -> list[BaseTool]:
9294
"""Load all available MCP tools and convert them to LangChain tools.
9395
94-
Args:
95-
session: MCP client session
96-
connection: Optional connection config to use to create a new session if a `session` is not provided
97-
9896
Returns:
99-
a list of LangChain tools
97+
list of LangChain tools. Tool annotations are returned as part
98+
of the tool metadata object.
10099
"""
101100
if session is None and connection is None:
102101
raise ValueError("Either a session or a connection config must be provided")
@@ -109,7 +108,8 @@ async def load_mcp_tools(
109108
else:
110109
tools = await session.list_tools()
111110

112-
return [
111+
converted_tools = [
113112
convert_mcp_tool_to_langchain_tool(session, tool, connection=connection)
114113
for tool in tools.tools
115114
]
115+
return converted_tools

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,15 @@ test = [
3131

3232
[tool.pytest.ini_options]
3333
minversion = "8.0"
34-
addopts = "-ra -q -v"
34+
# -ra: Report all extra test outcomes (passed, skipped, failed, etc.)
35+
# -q: Enable quiet mode for less cluttered output
36+
# -v: Enable verbose output to display detailed test names and statuses
37+
# --durations=5: Show the 10 slowest tests after the run (useful for performance tuning)
38+
addopts = "-ra -q -v --durations=5"
3539
testpaths = ["tests"]
3640
python_files = ["test_*.py"]
3741
python_functions = ["test_*"]
42+
asyncio_mode = "auto"
3843
asyncio_default_fixture_loop_scope = "function"
3944

4045
[tool.ruff]

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77

8-
from tests.utils.websocket import run_server
8+
from tests.utils import run_server
99

1010

1111
@pytest.fixture

tests/test_tools.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
)
1313
from mcp.types import Tool as MCPTool
1414

15+
from langchain_mcp_adapters.client import MultiServerMCPClient
1516
from langchain_mcp_adapters.tools import (
1617
_convert_call_tool_result,
1718
convert_mcp_tool_to_langchain_tool,
1819
load_mcp_tools,
1920
)
21+
from tests.utils import run_streamable_http
2022

2123

2224
def test_convert_empty_text_content():
@@ -207,3 +209,44 @@ async def mock_call_tool(tool_name, arguments):
207209
assert result2 == ToolMessage(
208210
content="tool2 result with {'param1': 'test2', 'param2': 2}", name="tool2", tool_call_id="2"
209211
)
212+
213+
214+
@pytest.mark.asyncio
215+
async def test_load_mcp_tools_with_annotations(
216+
socket_enabled,
217+
) -> None:
218+
"""Test load mcp tools with annotations."""
219+
from mcp.server import FastMCP
220+
from mcp.types import ToolAnnotations
221+
222+
server = FastMCP(port=8181)
223+
224+
@server.tool(
225+
annotations=ToolAnnotations(title="Get Time", readOnlyHint=True, idempotentHint=False)
226+
)
227+
def get_time() -> str:
228+
"""Get current time"""
229+
return "5:20:00 PM EST"
230+
231+
async with run_streamable_http(server):
232+
# Initialize client without initial connections
233+
client = MultiServerMCPClient(
234+
{
235+
"time": {
236+
"url": "http://localhost:8181/mcp/",
237+
"transport": "streamable_http",
238+
},
239+
}
240+
)
241+
# pass
242+
tools = await client.get_tools(server_name="time")
243+
assert len(tools) == 1
244+
tool = tools[0]
245+
assert tool.name == "get_time"
246+
assert tool.metadata == {
247+
"title": "Get Time",
248+
"readOnlyHint": True,
249+
"idempotentHint": False,
250+
"destructiveHint": None,
251+
"openWorldHint": None,
252+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import asyncio
2+
import contextlib
13
import time
4+
from typing import AsyncGenerator
25

36
import uvicorn
7+
from mcp.server.fastmcp import FastMCP
48
from mcp.server.websocket import websocket_server
59
from starlette.applications import Starlette
610
from starlette.routing import WebSocketRoute
@@ -34,3 +38,28 @@ def run_server(server_port: int) -> None:
3438
# Give server time to start
3539
while not server.started:
3640
time.sleep(0.5)
41+
42+
43+
@contextlib.asynccontextmanager
44+
async def run_streamable_http(server: FastMCP) -> AsyncGenerator[None, None]:
45+
"""Run the server in a separate task exposing a streamable HTTP endpoint.
46+
47+
The endpoint will be available at `http://localhost:{server.settings.port}/mcp/`.
48+
"""
49+
app = server.streamable_http_app()
50+
config = uvicorn.Config(
51+
app,
52+
host="localhost",
53+
port=server.settings.port,
54+
)
55+
server = uvicorn.Server(config)
56+
serve_task = asyncio.create_task(server.serve())
57+
58+
while not server.started:
59+
await asyncio.sleep(0.1)
60+
61+
try:
62+
yield
63+
finally:
64+
server.should_exit = True
65+
await serve_task

0 commit comments

Comments
 (0)