Skip to content

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

  1. Discovery -- as_langchain_tools() calls MCPMultiClient.list_tools() to fetch tool definitions from all connected servers.
  2. Schema conversion -- Each tool's inputSchema (a JSON Schema dict) is recursively converted to a Pydantic BaseModel using _jsonschema_to_pydantic.
  3. Tool wrapping -- Each tool is wrapped in a _PromptiseMCPTool (a BaseTool subclass) that calls MCPMultiClient.call_tool() on invocation. With forward_caller_token, the call carries the invoking CallerContext.bearer_token (see below).
  4. Result extraction -- The MCP CallToolResult is 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 / $defs references are resolved and inlined
  • anyOf / oneOf become Union[...] or Optional[...]
  • allOf properties are merged
  • Enums become Literal["a", "b", "c"]
  • Descriptions and defaults are preserved as Pydantic Field metadata

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:

for tool in tools:
    print(tool.name, tool.args_schema.model_json_schema())

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 MCPClient and MCPMultiClient that power the adapter
  • Tools -- @tool functions, and the schema conversion logic shared with the legacy loader
  • Building Agents -- use adapted tools with build_agent

Learn the concept