Model Context ProtocolMCP Python SDK 2.2 · LangChain 1.4 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your pathNext lesson →

Tool calling with a model

Tool choice is the step where a model reads the tool definitions it was sent and answers with a tool name and arguments, or with no tool at all.

Last updated: 29 Sep, 2026 · MCP 2.2

The last lesson turned the shop server's tools into the shape a model API takes. Here a real model, openai/gpt-oss-120b on Groq, receives them and picks one. It needs langchain[mcp], langchain-groq and the Groq key from the setup lesson.

The bind_tools call

python
from langchain.chat_models import init_chat_model

model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0)
reply = model.bind_tools(model_tools).invoke(messages)   # model_tools from to_model_tools
reply.tool_calls   # [{'name': ..., 'args': {...}, 'id': ...}], or [] for no tool

The model and its instructions

init_chat_model builds a chat model from a provider:model string and reads GROQ_API_KEY from the environment. temperature=0 asks for the least varied answer. The system message keeps the model to what the tools return, so it does not invent an answer the shop never gave.

python
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage, SystemMessage

model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0)

SYSTEM = (
    "You are the support assistant for a small online shop. "
    "Answer in one or two short sentences, using only what the tools returned."
)

The choose_tool function

bind_tools sends the tool definitions with every request. The reply's tool_calls lists the calls the model wants; an empty list means it answered in words instead. choose_tool returns the first call in the same shape as model_asked_for in Tools without MCP, or None.

python
def choose_tool(message, model_tools):
    """Ask the model which tool to call, and with what."""
    llm = model.bind_tools(model_tools)
    reply = llm.invoke([SystemMessage(SYSTEM), HumanMessage(message)])
    if not reply.tool_calls:
        return None
    call = reply.tool_calls[0]
    return {"name": call["name"], "arguments": call["args"]}

Add to_model_tools from MCP tools with an LLM below it, so the file has everything it calls.

Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop.py
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
from mcp.types import ToolAnnotations

mcp = MCPServer("Shop support", log_level="WARNING")

ORDERS = {
    "A17": {"item": "blue mug", "status": "shipped", "total": 12.50},
    "B42": {"item": "desk lamp", "status": "processing", "total": 48.00},
}

ARTICLES = {
    "Where is my order?": "orders",
    "Changing an order": "orders",
    "How refunds work": "refunds",
    "Refunds for damaged items": "refunds",
    "Resetting your password": "account",
}


class Order(BaseModel):
    id: str
    item: str
    status: Literal["processing", "shipped", "delivered"]
    total: float


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def lookup_order(order_id: str) -> Order:
    """Look up an order by its id."""
    if order_id not in ORDERS:
        raise ToolError(f"No order with id {order_id!r}. Order ids look like A17.")
    return Order(id=order_id, **ORDERS[order_id])


@mcp.tool()
def search_help(
    query: Annotated[str, Field(description="Words to look for in the help articles.")],
    topic: Literal["orders", "refunds", "account"] | None = None,
    limit: Annotated[int, Field(ge=1, le=5)] = 3,
) -> str:
    """Search the help centre and return matching article titles."""
    found = [title for title, t in ARTICLES.items() if query.lower() in title.lower() and topic in (None, t)]
    return "; ".join(found[:limit]) or "No articles found."


@mcp.tool(
    title="Refund an order",
    annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False),
)
def refund_order(order_id: str, reason: str) -> str:
    """Refund the full total of an order to the customer's card."""
    if order_id not in ORDERS:
        raise ToolError(f"No order with id {order_id!r}.")
    return f"Refunded {ORDERS[order_id]['total']:.2f} for order {order_id}: {reason}."


@mcp.resource("policy://refunds", mime_type="text/markdown")
def refund_policy() -> str:
    """The shop's refund policy."""
    return "# Refunds\n\nFull refund within 30 days of delivery. Damaged items: refund or replacement."


@mcp.resource("orders://{order_id}", mime_type="application/json")
def order_record(order_id: str) -> Order:
    """The full record for one order."""
    if order_id not in ORDERS:
        raise ResourceNotFoundError(f"No order with id {order_id!r}.")
    return Order(id=order_id, **ORDERS[order_id])


@mcp.prompt(title="Reply to a customer")
def reply_to_customer(ticket: str, tone: str = "friendly") -> list[Message]:
    """Draft a reply to a support ticket."""
    return [
        UserMessage(f"Write a {tone} reply to this support ticket:\n\n{ticket}"),
        AssistantMessage("Hello, and thank you for getting in touch."),
    ]


if __name__ == "__main__":
    mcp.run(transport="streamable-http", port=8200)

Three messages, three choices

The tool list comes from the shop server, in memory, as in the earlier lessons. The same three questions go to the model:

ExampleAPI keychoose.py, continued
import asyncio

from mcp import Client
from shop import mcp


async def offered_tools():
    async with Client(mcp) as client:
        listed = await client.list_tools()
    return to_model_tools(listed.tools)


model_tools = asyncio.run(offered_tools())

for message in [
    "Where is my order B42?",
    "Please refund order A17, it arrived broken",
    "How do I reset my password?",
]:
    print(choose_tool(message, model_tools))
  • B42 became a lookup_order call, with the order id taken from the message.
  • The refund request became refund_order for A17, and the model wrote its own reason, Item arrived broken, instead of copying the whole message.
  • The password question went to search_help with the query reset password and the topic account, one of the three values the Literal allows, plus the default limit.

Offering only lookup_order

A model can only call tools that are in its request. Offer it lookup_order alone and send the refund request and the password question again. Replace the for loop at the end of choose.py with these lines:

ExampleAPI keychoose.py, continued
only_lookup = [tool for tool in model_tools if tool["function"]["name"] == "lookup_order"]
print(choose_tool("Please refund order A17, it arrived broken", only_lookup))
print(choose_tool("How do I reset my password?", only_lookup))
  • The refund request became a lookup of A17, because refund_order was not in the request. The model used the closest tool it had.
  • The password question got no tool, so choose_tool returned None: nothing offered fits, and the model answered in words instead.

Tool choice by a model vs by your own code

Your dispatch (tools without MCP)A model with bind_tools
Who picks the toolYour code, from a name you passThe model, from the message and the descriptions
Needs a keyNoYes, a free Groq key here
Same answer every runYesUsually at temperature 0, not guaranteed
Can pick no toolOnly if you code itYes, it answers in words

When a model chooses the tool

  • Any request a person writes in their own words, where no fixed rule could map it to a tool.
  • Choosing between tools whose descriptions overlap, such as a lookup and a help search.
  • Deciding that no tool fits, and answering or asking a question instead.
Watch out. The model can pick the wrong tool or send arguments your server rejects, so the rules that matter stay in the server, as the elicitation lesson does for refunds.
Try it yourself
  • Send "order a17" in lower case and read the order id the model sends.
  • Remove the system message from the list in choose_tool and compare the replies.
  • Offer an empty list of tools and print what choose_tool returns for the first message.

You understood something today that you didn't yesterday.