Building MCP Clients
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the 2026-07-28 revision of
the specification, the Python mcp SDK 2.2.0, langchain-mcp-adapters 0.3.2, langchain 1.4.3 and ollama 0.6.3,
the TypeScript @modelcontextprotocol/client 2.2.0 (TypeScript 7.0.2, Node.js 24), Spring AI 2.0.1 (Spring Boot
version 4.1.1) with the MCP Java SDK 2.0.0 that Spring AI pulls in, and langchain4j-mcp 1.20.2-beta30, as checked on
2026-09-29 (see the Model Context Protocol baseline). The Java SDK, and therefore Spring AI,
speaks protocol 2025-11-25 only: its clients have no server/discover and no multi-round-trip requests.
A client is the half of MCP that most application developers write. The host owns the model conversation; the client
is the small component that connects to one server, lists what it offers, forwards tools/call requests and answers
the questions a server may ask on the way (see MCP Architecture for the roles). This
page builds that component in Python, Java and TypeScript against the three implementations of the docs-assistant
server (see Python MCP Servers, Spring Boot MCP
Servers and Node.js MCP Servers), then handles the one behaviour that needs care — input_required results — and finally wires a local Ollama model to MCP tools in a small agent loop. Every snippet
below was run or compiled against the real packages and the running servers; the one exception, the live model, is
called out where it applies.
What every client does
Whatever the language, a client goes through the same lifecycle. With the 2026-07-28 revision the connection itself is
cheap: there is no initialize handshake to hold open, only an optional server/discover probe that tells the client
which protocol revision to use.
Three decisions belong to the client and are easy to get wrong: which protocol era to speak (the SDKs default
differently, see below), which server-initiated questions to answer (elicitation only, in practice, because roots and
sampling are deprecated), and which tool calls the user must approve. The last one is a host policy, not a protocol
feature: tool annotations such as readOnlyHint are untrusted hints supplied by the server (see
Tools, Resources, Prompts & Elicitation).
Python
Python has two client options with very different footprints: the official SDK’s Client, which speaks the protocol
directly, and langchain-mcp-adapters, which turns MCP tools into LangChain tools for agents.
The official SDK client
The mcp 2.2.0 package’s Client accepts a URL string (Streamable HTTP), a StdioServerParameters (it spawns the
server) or an in-process server. Its default mode="auto" probes server/discover and falls back to the initialize
handshake on servers that predate 2026-07-28. This example spawns the Python docs-assistant over stdio and touches
every server feature:
mcp 2.2.0)import asyncio
import sys
from mcp import Client, StdioServerParameters
async def main() -> None:
# stdio: the SDK spawns the server as a subprocess; pass a URL string instead for Streamable HTTP
server = StdioServerParameters(command=sys.executable, args=["server.py"])
async with Client(server) as client:
print(client.protocol_version) # 2026-07-28, learned via server/discover
tools = await client.list_tools() # tools/list
print([t.name for t in tools.tools])
result = await client.call_tool( # tools/call
"search_docs", {"query": "matrix", "component": "algebra", "top_k": 1})
print(result.is_error, result.structured_content["hits"][0]["page"])
page = await client.read_resource("docs://algebra/matrices") # resources/read
print(page.contents[0].text[:40])
prompt = await client.get_prompt("answer_with_citations", # prompts/get
{"question": "How do I solve Ax=b?"})
print(prompt.messages[0].content.text[:50])
asyncio.run(main())
It printed 2026-07-28, ['search_docs'], False matrices, the first line of the page and the start of the prompt.
Two things to remember. Tool failures come back as result.is_error, not as exceptions: check it before reading
structured_content. Protocol failures (an unknown method, a missing capability) raise MCPError. And the client
caches list and read results according to the server’s ttlMs and cacheScope hints by default (the cache
argument, None disables it), which is why examples that expect fresh data after a server change may need a
cache_mode override. See the Python SDK documentation.
langchain-mcp-adapters
langchain-mcp-adapters 0.3.2 wraps MCP servers as LangChain tools. MultiServerMCPClient takes a dictionary of
named connections (streamable_http with a url, or stdio with command and args), and get_tools() returns
ordinary LangChain tools:
langchain-mcp-adapters 0.3.2)import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
async def main() -> None:
client = MultiServerMCPClient(
{
"docs": {"transport": "streamable_http", "url": "http://127.0.0.1:8000/mcp"},
"docs-local": {"transport": "stdio", "command": "python", "args": ["server.py"]},
},
tool_name_prefix=True, # avoid collisions: docs_search_docs, docs-local_search_docs
)
tools = await client.get_tools(server_name="docs")
print([t.name for t in tools])
result = await tools[0].ainvoke({"query": "matrix", "component": "algebra", "top_k": 1})
print(result)
asyncio.run(main())
It printed ['docs_search_docs'] and the tool result as a list of text blocks. Because the library is built on the
first-generation SDK, keep it away from the server environments:
-
langchain-mcp-adapters0.3.2 requiresmcpbelow 2.0.0. Installing it next tomcp2.x downgrades the SDK to 1.30.0 and breaks anything that imports v2 modules — including FastMCP 4, which needsmcp2. Give the adapters their own virtual environment (the same advice as forfastapi-mcpon MCP inside FastAPI). -
Being a v1 client, it speaks the
initializehandshake. A v2 server still serves it, and a server that elicits sendselicitation/createdown the session instead of returninginput_required. To let that work, passcallbacks=Callbacks(on_elicitation=…); without a callback the client declares no elicitation capability and the server’s tool call fails with "Client did not declare the form elicitation capability". -
Tool errors reach the model as a
ToolMessagewith an error status by default (handle_tool_errors=True) so the agent can correct itself instead of crashing.
The agent side is a few lines with LangChain’s create_agent. This one is connected to Ollama and, in the test,
to a scripted stand-in for it (see the last section):
import asyncio
from langchain.agents import create_agent
from langchain_mcp_adapters.callbacks import Callbacks
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_ollama import ChatOllama
from mcp.types import ElicitResult
async def on_elicitation(mcp_context, params, context) -> ElicitResult:
# v1 SDK connection: the server sends elicitation/create itself; answer it (here: always "algebra")
print("server asks:", params.message)
return ElicitResult(action="accept", content={"component": "algebra"})
async def main() -> None:
client = MultiServerMCPClient(
{"docs": {"transport": "streamable_http", "url": "http://127.0.0.1:8000/mcp"}},
callbacks=Callbacks(on_elicitation=on_elicitation),
)
tools = await client.get_tools()
agent = create_agent(ChatOllama(model="llama3.1:8b"), tools)
out = await agent.ainvoke({"messages": [{"role": "user", "content": "How do I solve a linear system?"}]})
print(out["messages"][-1].content)
asyncio.run(main())
LangChain’s own MCP documentation now teaches a different entry point, MCPAdapter in the beta langchain.mcp namespace
(pip install "langchain[mcp]", built on FastMCP 4) and points existing users of langchain-mcp-adapters to a migration
guide. langchain 1.4.3 ships that namespace and emits a beta warning on import; this page did not run it, so check that
documentation before starting a new project on the adapters library. See the
LangChain MCP documentation and the
langchain-mcp-adapters repository. Agent frameworks in general
are compared on The 2026 Agent Frameworks Landscape.
Java
Java has two mainstream options: Spring AI’s client starter, which is configuration-driven, and the LangChain4j MCP
client, which is a plain library. Both sit on protocol 2025-11-25 in the versions above, so both open with initialize
and neither can receive an input_required result.
Spring AI’s MCP client starter
Add spring-ai-starter-mcp-client to a Spring Boot 4 application, plus a chat-model starter for ChatClient (here
Ollama):
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
</dependencies>
Connections are named entries under spring.ai.mcp.client.*. Each transport has its own subtree
(streamable-http, stdio, sse), and the name after connections. identifies the server in logs, tool filters and
handler annotations:
spring.ai.mcp.client.type=SYNC
spring.ai.mcp.client.request-timeout=30s
spring.ai.mcp.client.streamable-http.connections.docs.url=http://127.0.0.1:8080
spring.ai.mcp.client.streamable-http.connections.docs.endpoint=/mcp
spring.ai.ollama.chat.options.model=llama3.1:8b
The starter creates one MCP client per connection, initialises it at start-up (spring.ai.mcp.client.initialized,
default true) and registers a ToolCallbackProvider bean exposing the tools of every connection. Any Spring AI
chat call can take those callbacks:
@SpringBootApplication
public class DocsClientApplication {
public static void main(String[] args) {
SpringApplication.run(DocsClientApplication.class, args);
}
@Bean
CommandLineRunner listAndCall(ToolCallbackProvider tools) {
return args -> {
for (ToolCallback callback : tools.getToolCallbacks()) {
System.out.println(callback.getToolDefinition().name());
}
ToolCallback search = tools.getToolCallbacks()[0];
System.out.println(search.call("{\"query\":\"matrix\",\"component\":\"algebra\",\"topK\":1}"));
};
}
// With any ChatModel (here the Ollama starter), the MCP tools become model tools.
static String ask(ChatModel chatModel, ToolCallbackProvider tools, String question) {
return ChatClient.create(chatModel)
.prompt(question)
.toolCallbacks(tools)
.call()
.content();
}
}
Built with Java 21 on Boot 4.1.1 and run against the Spring docs-assistant on port 8080, the application logged
Protocol: 2025-11-25, printed search_docs and printed the tool’s JSON result; the ask method compiles but was not
run, because it needs a live model. The properties enabled, name, version, initialized, request-timeout,
type (SYNC or ASYNC) and toolcallback.enabled are listed in the reference, as are the customisation hooks
McpClientCustomizer (per-connection timeouts, roots, sampling, elicitation and change handlers), McpToolFilter and
McpToolNamePrefixGenerator.
Because the connection speaks 2025-11-25, elicitation works the old way: the server sends elicitation/create as a
request of its own, and the client answers with a handler bound to a connection name:
@Component
class DocsElicitation {
@McpElicitation(clients = "docs")
ElicitResult onElicit(ElicitRequest request) {
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("component", "algebra"));
}
}
The Spring docs-assistant avoids the question by taking component as a required argument (see
Spring Boot MCP Servers). See the
Spring AI MCP client starter
reference. The Spring side — connections, SyncMcpToolCallbackProvider, McpClientCustomizer, client annotations
and consuming GitHub’s MCP server — is covered on Spring AI: MCP Client; this page
only covers the MCP side.
The LangChain4j MCP client
LangChain4j’s langchain4j-mcp module (1.20.2-beta30) offers DefaultMcpClient over an McpTransport:
StreamableHttpMcpTransport for HTTP and StdioMcpTransport for subprocesses. McpToolProvider adapts one or more
clients for AiServices:
McpTransport transport = StreamableHttpMcpTransport.builder()
.url("http://127.0.0.1:8080/mcp")
.timeout(Duration.ofSeconds(20))
.build();
try (McpClient client = DefaultMcpClient.builder().key("docs").transport(transport).build()) {
client.listTools().forEach(t -> System.out.println(t.name()));
ToolExecutionResult result = client.executeTool(ToolExecutionRequest.builder()
.name("search_docs")
.arguments("{\"query\":\"matrix\",\"component\":\"algebra\",\"topK\":1}")
.build());
System.out.println(result.resultText());
// AiServices.builder(Assistant.class).chatModel(model).toolProvider(provider).build()
McpToolProvider provider = McpToolProvider.builder().mcpClients(client).build();
}
It compiled against langchain4j-mcp 1.20.2-beta30 (the dev.langchain4j:langchain4j-mcp artifact, with
langchain4j-ollama 1.20.2 for the model) and, run against the Spring server, printed search_docs and the JSON
result. The class and method names come from the published jar and the LangChain4j guide. McpToolProvider.Builder has
filterToolNames, toolNameMapper and failIfOneServerFails for multi-server setups; DefaultMcpClient.Builder has
timeouts, list caching, roots and a protocolVersion setting, and a multiRoundTripMaxRetries method whose behaviour
was not exercised for this page. Against the Python server, listing tools worked but a call that needed elicitation
failed with an HTTP 400, so treat elicitation as unsupported in this client until you have tested it. See the
LangChain4j MCP guide; LangChain4j is compared with other frameworks
in The 2026 Agent Frameworks Landscape.
TypeScript
@modelcontextprotocol/client 2.2.0 provides Client and StreamableHTTPClientTransport; the stdio transport lives
in the @modelcontextprotocol/client/stdio entry point. One default matters: the client’s versionNegotiation is
legacy unless you opt in, which means a plain connect() performs the 2025 initialize handshake even against a
2026-07-28 server. Pass { mode: 'auto' } to probe server/discover first and fall back automatically, or pin a
revision with { mode: { pin: '2026-07-28' } }.
@modelcontextprotocol/client 2.2.0)import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client(
{ name: 'docs-client', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' } } // probe server/discover, fall back to initialize
);
await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp')));
const { tools } = await client.listTools(); // tools/list
console.log(tools.map(t => t.name));
const result = await client.callTool({ // tools/call
name: 'search_docs',
arguments: { query: 'matrix decomposition', component: 'algebra', top_k: 2 }
});
if (result.isError) throw new Error(JSON.stringify(result.content));
console.log(JSON.stringify(result.structuredContent));
await client.close();
And over stdio, spawning the server:
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const client = new Client({ name: 'docs-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(new StdioClientTransport({ command: 'npx', args: ['tsx', 'src/stdio.ts'] }));
console.log((await client.listTools()).tools.map(t => t.name));
await client.close();
All the TypeScript on this page type-checks with tsc --noEmit (TypeScript 7.0.2, strict, NodeNext, types:
["node"]) against the installed 2.2.0 packages, and the scripts were run against the Express and stdio versions of the
server. See the TypeScript SDK documentation.
Handling input_required
Elicitation is the only server-to-client question that matters in most applications, and in 2026-07-28 it no longer
arrives as a request the server sends. The server answers tools/call, prompts/get or resources/read with an
interim result, and the client retries the original request with the answers. The specification’s rules for the
client are short: construct the requested inputs before retrying; echo requestState exactly, and never inspect or
change it; if the result has no requestState, send none; use a new JSON-RPC id for the retry; and use the two
fields only for that retry. The full protocol description is on
Tools, Resources, Prompts & Elicitation; the transcript with headers is on
Transports.
The two exchanges below are captured from the Python server (a partial requestState is shown as a placeholder). The
call omits component:
resultType is not complete){
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "input_required",
"inputRequests": {
"__main__:choose_component": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Which component should I search?",
"requestedSchema": {
"type": "object",
"properties": { "component": { "type": "string", "enum": ["algebra", "units", "statistics"] } },
"required": ["component"]
}
}
}
},
"requestState": "<opaque requestState, echo it unchanged>"
}
}
{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "search_docs",
"arguments": { "query": "matrix" },
"inputResponses": {
"__main__:choose_component": { "action": "accept", "content": { "component": "algebra" } }
},
"requestState": "<the same opaque requestState>"
}
}
The key is server-chosen: the Python server derives it from the resolver (<module>:<resolver>), the TypeScript server
uses the plain key component and sends no requestState. A client must therefore never hard-code keys; it answers
whatever inputRequests contains. The TypeScript server’s first response, captured with curl, is the same shape
with "inputRequests": { "component": { "method": "elicitation/create", … } }.
What the SDKs do for you
Both SDK clients run the retry loop automatically, and both offer a manual mode. I checked the behaviour against the installed packages and the running servers:
Python mcp 2.2.0 |
TypeScript @modelcontextprotocol/client 2.2.0 |
|
|---|---|---|
Default |
|
|
Capability |
Passing |
Declare |
Round cap |
|
|
|
Passed through byte-exact; a result with only state and no requests is retried after a short backoff (50 ms doubling to 250 ms). |
Byte-exact echo on a fresh request id. |
Manual mode |
|
|
2025 servers |
The same callback answers a server-initiated |
The same handler answers it; |
Automatic handling is the right default for interactive applications: the callback is where the UI asks the human. Use manual mode when you must persist the questions (a chat UI that renders a form and resumes later, a workflow engine) or when the retry must run somewhere else than the call.
import asyncio
import mcp_types as types
from mcp import Client
from mcp.client.session import ClientRequestContext
async def choose(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
print("server asks:", params.message)
return types.ElicitResult(action="accept", content={"component": "algebra"})
async def main() -> None:
async with Client("http://127.0.0.1:8000/mcp", elicitation_callback=choose) as client:
result = await client.call_tool("search_docs", {"query": "matrix"})
print(result.structured_content["component"], len(result.structured_content["hits"]))
asyncio.run(main())
It printed server asks: Which component should I search? and algebra 1.
import asyncio
import mcp_types as types
from mcp import Client
from mcp.client.session import ClientRequestContext
async def ask_user(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
# Passing a callback is what declares the elicitation capability; the manual loop below answers itself.
return types.ElicitResult(action="decline")
async def main() -> None:
async with Client("http://127.0.0.1:8000/mcp", elicitation_callback=ask_user) as client:
args = {"query": "matrix"}
outcome = await client.session.call_tool("search_docs", args, allow_input_required=True)
if isinstance(outcome, types.InputRequiredResult):
print("input required:", list(outcome.input_requests or {}))
answers = {key: types.ElicitResult(action="accept", content={"component": "algebra"})
for key in (outcome.input_requests or {})}
outcome = await client.session.call_tool(
"search_docs", args, input_responses=answers, request_state=outcome.request_state,
allow_input_required=True)
print(outcome.structured_content["component"])
asyncio.run(main())
It printed input required: ['main:choose_component'] and algebra.
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client(
{ name: 'docs-client', version: '1.0.0' },
{
capabilities: { elicitation: { form: {} } },
versionNegotiation: { mode: 'auto' } // probe server/discover, fall back to initialize
}
);
// The same handler serves 2026-07-28 input_required rounds and 2025-11-25 elicitation/create requests.
client.setRequestHandler('elicitation/create', async request => {
console.log('server asks:', request.params.message);
return { action: 'accept', content: { component: 'algebra' } };
});
await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp')));
const result = await client.callTool({ name: 'search_docs', arguments: { query: 'matrix' } });
console.log(JSON.stringify(result.structuredContent));
await client.close();
It printed the question and the structuredContent for algebra.
import {
Client,
StreamableHTTPClientTransport,
isInputRequiredResult,
withInputRequired,
type InputResponses
} from '@modelcontextprotocol/client';
import * as z from 'zod/v4';
const client = new Client(
{ name: 'docs-client', version: '1.0.0' },
{
capabilities: { elicitation: { form: {} } },
versionNegotiation: { mode: 'auto' },
inputRequired: { autoFulfill: false } // manual mode
}
);
await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp')));
const callSchema = withInputRequired(z.object({ structuredContent: z.unknown().optional() }).loose());
const params = { name: 'search_docs', arguments: { query: 'matrix' } };
let outcome = await client.request({ method: 'tools/call', params }, callSchema, { allowInputRequired: true });
if (isInputRequiredResult(outcome)) {
const inputResponses: InputResponses = {};
for (const [key, req] of Object.entries(outcome.inputRequests ?? {})) {
console.log('asked', key, req.method);
inputResponses[key] = { action: 'accept', content: { component: 'algebra' } };
}
outcome = await client.request(
{ method: 'tools/call', params: { ...params, inputResponses, requestState: outcome.requestState } },
callSchema,
{ allowInputRequired: true }
);
}
console.log(JSON.stringify(outcome));
await client.close();
The first response was resultType: "input_required" for the key component; the retry returned the complete result.
Both TypeScript variants type-check and ran against the Express server.
Java clients built on the Java SDK, Spring AI included, have nothing to handle: protocol 2025-11-25 has no
input_required, so elicitation arrives as a server request, or the server avoids it by requiring the argument. When a
server is shared across generations of clients, design tools so the elicited value can also be passed as an argument,
as docs-assistant does with component.
Connecting a local LLM agent
An MCP client becomes an agent when a model decides which tool to call. The model never speaks MCP: the host converts
MCP tool definitions into the model API’s function format and turns the model’s tool calls back into tools/call
requests (see Structured Output & Tool Calling). With
a local model the same holds; the model just runs on your machine through Ollama
(see Ollama and Running LLMs Locally).
The loop is the one described on Agent Loop & Architectures:
ask the model, run the tools it requests, append the results, repeat until it answers without tools. The MCP-specific
parts are the schema translation (MCP’s inputSchema is already JSON Schema, which is what Ollama’s parameters
field takes), the handling of isError results, and the elicitation callback. The design rules for the tools
themselves are on Tool Design.
llama3.1:8b driving MCP tools (mcp 2.2.0, ollama 0.6.3)import asyncio
import json
import sys
import mcp_types as types
from mcp import Client
from mcp.client.session import ClientRequestContext
from ollama import AsyncClient
MODEL = "llama3.1:8b"
MCP_URL = "http://127.0.0.1:8000/mcp"
MAX_STEPS = 6 # hard cap on model turns: the loop must always terminate
MAX_TOOL_CHARS = 4000 # keep tool output from flooding a small context window
async def ask_user(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
"""Elicitation callback: the server needs a choice only the human can make."""
options = params.requested_schema["properties"]["component"]["enum"]
answer = input(f"{params.message} {options}: ").strip()
return types.ElicitResult(action="accept", content={"component": answer}) if answer else \
types.ElicitResult(action="decline")
def to_ollama_tool(tool: types.Tool) -> dict:
"""MCP tool definition -> Ollama / OpenAI-style function definition."""
return {"type": "function",
"function": {"name": tool.name, "description": tool.description or "", "parameters": tool.input_schema}}
def approved(tool: types.Tool, arguments: dict) -> bool:
"""Annotations are untrusted hints from the server; anything not read-only needs a human yes."""
if tool.annotations and tool.annotations.read_only_hint:
return True
return input(f"Allow {tool.name}({json.dumps(arguments)})? [y/N] ").strip().lower() == "y"
async def run(question: str) -> str:
llm = AsyncClient() # honours OLLAMA_HOST, default http://127.0.0.1:11434
async with Client(MCP_URL, elicitation_callback=ask_user) as mcp:
tools = {t.name: t for t in (await mcp.list_tools()).tools}
messages = [
{"role": "system", "content": "Answer from the documentation. Use search_docs, then cite page names."},
{"role": "user", "content": question},
]
for _ in range(MAX_STEPS):
reply = await llm.chat(model=MODEL, messages=messages, tools=[to_ollama_tool(t) for t in tools.values()])
messages.append(reply.message)
if not reply.message.tool_calls:
return reply.message.content or "" # final answer
for call in reply.message.tool_calls:
name, arguments = call.function.name, dict(call.function.arguments)
tool = tools.get(name)
if tool is None:
text = f"Unknown tool '{name}'. Available: {', '.join(tools)}"
elif not approved(tool, arguments):
text = "The user declined this tool call."
else:
result = await mcp.call_tool(name, arguments) # input_required rounds happen inside
text = "".join(c.text for c in result.content if isinstance(c, types.TextContent))
if result.is_error:
text = f"Tool error: {text}" # let the model see it and retry
messages.append({"role": "tool", "tool_name": name, "content": text[:MAX_TOOL_CHARS]})
return "Stopped: step limit reached."
if __name__ == "__main__":
print(asyncio.run(run(sys.argv[1] if len(sys.argv) > 1 else "How do I solve a linear system?")))
Design points worth copying:
-
Bounded loop.
MAX_STEPSguarantees termination even if a small model keeps calling tools. -
Tool result role. Ollama expects a message with
roletool, thetool_nameand the result as a string; the model’s owntool_callsmessage must stay in the history (messages.append(reply.message)). -
Errors are data. Unknown tool names and
isErrorresults go back to the model as text, not as exceptions. -
Approval gate. The read-only shortcut trusts a hint the server sent. That is acceptable for the
docs-assistantyou wrote yourself; for third-party servers, prompt for every call or keep an allow-list in the host (see MCP Security and Human-in-the-Loop). -
Elicitation. The callback runs inside
call_tool; the loop does not need a special case. The model calledsearch_docswithoutcomponent, so the server asked and the human answered. -
Small models.
llama3.1:8bhandles one or two tools reliably and degrades as tool lists grow, which is one more reason to expose few, well-described tools (see Designing Good MCP Servers). Give Ollama a context window large enough for the tool schemas, as described on Ollama.
Ollama was not installed on the machine that produced this page, so the loop was not run against a live model.
To test the wiring, I ran it and the LangChain variant above against a scripted stand-in for Ollama’s /api/chat
endpoint (via OLLAMA_HOST) that first requested search_docs without a component and then answered from the tool
result. The run confirmed what the MCP side does: the tool list reached the "model" as a function definition, the
elicitation question was asked and answered (algebra), the tool result came back as a tool message, and the loop
ended with the final answer. It confirms nothing about how llama3.1:8b chooses tools or arguments; try it with
ollama pull llama3.1:8b before relying on it.
Where to go next
-
Connecting Hosts configures existing hosts (Claude Code, VS Code, and others) as clients instead of writing your own.
-
Authorization covers the OAuth flow a remote client performs before its first call.
-
Testing & Debugging shows the Inspector and the contract script that exercise a server without writing a client.
-
MCP Security covers untrusted tool descriptions and approval policy for agents.
-
Tool Design and Agent Loop & Architectures cover what the model does with the tools.