MCP Tool Adapter¶
The MCPToolAdapter discovers tools from connected MCP servers and converts
them into fully-typed LangChain BaseTool instances. It builds recursive
Pydantic models from MCP JSON Schemas -- preserving nested objects, arrays,
unions, $ref/$defs, enums, defaults, and descriptions -- so LLMs can
generate correct tool calls on the first attempt.
Source: src/promptise/mcp/client/_tool_adapter.py
Quick example¶
import asyncio
from promptise import MCPClient, MCPMultiClient, MCPToolAdapter, build_agent
async def main():
multi = MCPMultiClient({
"math": MCPClient(url="http://localhost:8080/mcp"),
})
async with multi:
adapter = MCPToolAdapter(multi)
tools = await adapter.as_langchain_tools()
# Use tools with build_agent
agent = await build_agent(
model="openai:gpt-5-mini",
servers={},
extra_tools=tools,
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "What is 7 + 3?"}]}
)
print(result["messages"][-1].content)
asyncio.run(main())
Concepts¶
How MCPToolAdapter fits in¶
MCPToolAdapter is the recommended way to convert MCP tools into LangChain
BaseTool instances. It is backed by MCPMultiClient, uses persistent
connections for the entire agent lifetime, supports Bearer token and API key
authentication, and provides recursive schema conversion.
How it works¶
- Discovery --
as_langchain_tools()callsMCPMultiClient.list_tools()to fetch tool definitions from all connected servers. - Schema conversion -- Each tool's
inputSchema(a JSON Schema dict) is recursively converted to a PydanticBaseModelusing_jsonschema_to_pydantic. - Tool wrapping -- Each tool is wrapped in a
_PromptiseMCPTool(aBaseToolsubclass) that callsMCPMultiClient.call_tool()on invocation. Withforward_caller_token, the call carries the invokingCallerContext.bearer_token(see below). - Result extraction -- The MCP
CallToolResultis converted to a plain string by concatenating all text content parts.
Creating an adapter¶
from promptise import MCPClient, MCPMultiClient, MCPToolAdapter
multi = MCPMultiClient({
"hr": MCPClient(url="http://hr-server:8080/mcp", bearer_token="..."),
"docs": MCPClient(url="http://docs-server:9090/mcp", api_key="secret"),
})
async with multi:
adapter = MCPToolAdapter(multi)
tools = await adapter.as_langchain_tools()
Calling tools as the invoking user¶
MCPToolAdapter(multi, forward_caller_token=...) makes each tool call with
the bearer token of the CallerContext active when the tool runs (inside
agent.ainvoke(..., caller=...)), over a session opened for that token:
True for every server, a collection of server names for only those, or
False (the default) for none. build_agent() turns it on for every
server whose spec forwards caller tokens (HTTPServerSpec.forwards_caller_token:
not forward_caller_token=False, and no API key unless
forward_caller_token=True). See
Calling a tool as a specific user.
Getting LangChain tools¶
as_langchain_tools() returns a list of BaseTool instances ready for any
LangChain or LangGraph agent:
async with multi:
adapter = MCPToolAdapter(multi)
tools = await adapter.as_langchain_tools()
for tool in tools:
print(f"{tool.name}: {tool.description}")
print(f" Schema: {tool.args_schema.model_json_schema()}")
Each tool's args_schema is a dynamically-created Pydantic model that
preserves the full structure of the MCP server's JSON Schema, including:
- Nested objects become nested Pydantic models
- Arrays of objects become
list[NestedModel] $ref/$defsreferences are resolved and inlinedanyOf/oneOfbecomeUnion[...]orOptional[...]allOfproperties are merged- Enums become
Literal["a", "b", "c"] - Descriptions and defaults are preserved as Pydantic
Fieldmetadata
Tool introspection¶
Use list_tool_info() to get lightweight metadata about discovered tools
without creating BaseTool wrappers:
async with multi:
adapter = MCPToolAdapter(multi)
infos = await adapter.list_tool_info()
for info in infos:
print(f"{info.server_guess}/{info.name}")
print(f" {info.description}")
print(f" Schema keys: {list(info.input_schema.get('properties', {}).keys())}")
This returns a list of ToolInfo dataclass instances -- useful for debugging,
logging, or building tool registries.
Callback hooks¶
Attach optional callbacks for tracing every tool invocation:
def on_before(tool_name: str, arguments: dict) -> None:
print(f"[TRACE] Calling {tool_name}")
def on_after(tool_name: str, result) -> None:
print(f"[TRACE] {tool_name} completed")
def on_error(tool_name: str, exc: Exception) -> None:
print(f"[ERROR] {tool_name} failed: {exc}")
adapter = MCPToolAdapter(
multi,
on_before=on_before,
on_after=on_after,
on_error=on_error,
)
tools = await adapter.as_langchain_tools()
Callbacks are wrapped in contextlib.suppress(Exception) so a failing callback
never breaks the actual tool call.
Arguments sent to the server¶
A tool sends only the arguments the model gave, as plain JSON (nested
objects as dicts), so the server applies its own defaults. LangChain would
otherwise fill in None for every optional parameter left out, which a server
that validates its input rejects. An explicit null is sent only where the
parameter's schema allows null; a null for an optional parameter that
cannot be null counts as not given. The on_before callback receives the same
plain arguments.
Arguments that don't match the tool's schema are not sent: the tool raises
ToolArgumentError (from promptise.tools; a LangChain ToolException with
code INVALID_ARGUMENTS), whose message lists each problem by argument path,
and on_before and on_error run. Inside an agent the model sees that
message and can correct the call.
Result extraction¶
MCP servers return CallToolResult objects containing a list of content items
(text, images, embedded resources). The adapter extracts all text parts and
joins them with newlines:
# What the MCP server returns:
# CallToolResult(content=[
# TextContent(type="text", text="Found 3 results"),
# TextContent(type="text", text="Result 1: ..."),
# ])
# What the LangChain tool returns:
# "Found 3 results\nResult 1: ..."
If the result is an error (isError=True, or the {"error": {...}} envelope a
Promptise server returns for a ToolError), the tool raises MCPToolError — see
below.
Error handling¶
When a tool call fails at the transport or protocol level, the adapter raises
MCPClientError. When the server reports a failed call, it raises
MCPToolError (a LangChain ToolException, with tool_name, code,
message and retryable from the server's error), so LangChain callbacks get
on_tool_error (observability records tool.error) and the on_error
adapter callback runs instead of on_after:
from promptise.mcp.client import MCPClientError, MCPToolError
try:
result = await tool.ainvoke({"query": "test"})
except MCPToolError as exc:
print(f"Tool reported an error: {exc.code}: {exc.message}")
except MCPClientError as exc:
print(f"Tool call failed: {exc}")
Inside a Promptise agent you do not need to catch either: the agent loop marks
the call as failed and passes the server's error text to the model so it can
correct itself. When you use the tools in
another framework, set tool.handle_tool_error = True to have LangChain
return the error text to the model instead of raising.
API summary¶
MCPToolAdapter¶
| Parameter | Type | Default | Description |
|---|---|---|---|
multi |
MCPMultiClient |
required | Connected multi-server client |
on_before |
OnBefore \| None |
None |
Callback fired before each tool invocation |
on_after |
OnAfter \| None |
None |
Callback fired after each tool invocation |
on_error |
OnError \| None |
None |
Callback fired on tool errors |
Methods¶
| Method | Returns | Description |
|---|---|---|
as_langchain_tools() |
list[BaseTool] |
Discover tools and return as LangChain BaseTool instances |
list_tool_info() |
list[ToolInfo] |
Return tool metadata for introspection |
Callback types¶
| Type | Signature |
|---|---|
OnBefore |
Callable[[str, dict[str, Any]], None] |
OnAfter |
Callable[[str, Any], None] |
OnError |
Callable[[str, Exception], None] |
ToolInfo¶
| Attribute | Type | Description |
|---|---|---|
server_guess |
str |
Server name that owns the tool |
name |
str |
Tool name |
description |
str |
Human-readable description |
input_schema |
dict[str, Any] |
Raw JSON Schema for tool input |
Tips and gotchas¶
Tip
MCPToolAdapter uses persistent connections via MCPMultiClient. Create
the adapter once and reuse the tools for the entire agent session -- do not
recreate the adapter per invocation.
Warning
You must call as_langchain_tools() (or list_tool_info()) inside
the async with multi: block. The underlying MCPMultiClient must be
connected for tool discovery to work.
Tip
The recursive schema converter handles complex MCP schemas that would
otherwise appear as opaque dict parameters to the LLM. If your tool
call accuracy is low, inspect the generated args_schema to verify the
schema conversion is correct:
Warning
Callback hooks (on_before, on_after, on_error) are silenced on
failure -- they never propagate exceptions. If your callback raises,
the error is swallowed and the tool call proceeds normally. Use proper
error handling inside your callbacks.
Tip
For simpler setups, build_agent handles tool adaptation internally.
Use MCPToolAdapter directly when you need fine-grained control over
tool discovery, callbacks, or when integrating with custom LangChain
pipelines.
What's next¶
- MCP Client -- the
MCPClientandMCPMultiClientthat power the adapter - Tools --
@toolfunctions, and the schema conversion logic shared with the legacy loader - Building Agents -- use adapted tools with
build_agent
Learn the concept
- How LLM Tool Calling Works: The Model Never Runs Tools -- How tool calling works: the model never runs the tool.