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.

sequenceDiagram participant App as Host application participant C as MCP client participant S as MCP server participant M as LLM App->>C: connect(url or command) C->>S: server/discover (or initialize on a 2025 server) S-->>C: supported versions, capabilities App->>C: list tools C->>S: tools/list S-->>C: tool definitions App->>M: question + tool definitions M-->>App: tool call (name, arguments) App->>C: call tool C->>S: tools/call alt server needs input S-->>C: input_required (inputRequests, requestState) C->>App: ask the user App-->>C: answers C->>S: tools/call again (inputResponses, requestState) end S-->>C: complete result C-->>App: result App->>M: tool result M-->>App: final answer

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:

Python client over stdio: tools, resources and prompts (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:

Two servers, one tool list (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-adapters 0.3.2 requires mcp below 2.0.0. Installing it next to mcp 2.x downgrades the SDK to 1.30.0 and breaks anything that imports v2 modules — including FastMCP 4, which needs mcp 2. Give the adapters their own virtual environment (the same advice as for fastapi-mcp on MCP inside FastAPI).

  • Being a v1 client, it speaks the initialize handshake. A v2 server still serves it, and a server that elicits sends elicitation/create down the session instead of returning input_required. To let that work, pass callbacks=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 ToolMessage with 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):

A LangChain agent over MCP tools and a local model
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):

pom.xml (excerpt, Spring AI 2.0.1)
<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:

application.properties
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:

Listing, calling and using the MCP tools
@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:

An elicitation handler for protocol 2025-11-25 (compiled, not exercised: the Spring server does not elicit)
@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:

LangChain4j client against the Spring server
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' } }.

TypeScript client over Streamable HTTP (@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:

TypeScript client over stdio
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:

Attempt 1 — the server asks (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>"
  }
}
Attempt 2 — the client retries with new id, answers under the same key, and the echoed state
{
  "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

client.call_tool, get_prompt and read_resource resolve input_required themselves, dispatch each request to elicitation_callback and retry.

client.callTool and its siblings resolve it themselves through the handler registered with setRequestHandler('elicitation/create', …​).

Capability

Passing elicitation_callback declares the capability; without it the server refuses the call ("Client did not declare the form elicitation capability").

Declare capabilities: { elicitation: { form: {} } } in the client options.

Round cap

input_required_max_rounds, default 10, then InputRequiredRoundsExceededError.

inputRequired: { maxRounds }, default 10, then a typed InputRequiredRoundsExceeded SDK error.

requestState

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

client.session.call_tool(…​, allow_input_required=True) returns an InputRequiredResult; retry with input_responses= and request_state=.

inputRequired: { autoFulfill: false } in the options, then allowInputRequired: true per request, wrapping the result schema with withInputRequired().

2025 servers

The same callback answers a server-initiated elicitation/create.

The same handler answers it; inputRequired has no effect on 2025-era connections.

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.

Python, automatic: the callback is all the code you write
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.

Python, manual: drive the loop yourself
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.

TypeScript, automatic
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.

TypeScript, manual
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).

flowchart LR U[User question] --> L subgraph Agent[Agent process] L[Loop: max steps] MC[MCP client] end L -->|messages + tool schemas| O[Ollama llama3.1:8b] O -->|tool_calls| L L -->|approval gate| MC MC -->|tools/call| S[docs-assistant server] S -->|result or input_required| MC MC -->|tool message| L L -->|no tool calls: final answer| U

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.

A minimal agent: Ollama 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_STEPS guarantees termination even if a small model keeps calling tools.

  • Tool result role. Ollama expects a message with role tool, the tool_name and the result as a string; the model’s own tool_calls message must stay in the history (messages.append(reply.message)).

  • Errors are data. Unknown tool names and isError results 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-assistant you 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 called search_docs without component, so the server asked and the human answered.

  • Small models. llama3.1:8b handles 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