We learned about tools and wrote them ourselves. This is cute, but an application writing all of its own tools is not scalable. In software development, we put functionality in libraries and frameworks and reuse it across projects. MCP (the Model Context Protocol) is all about exposing tool catalogs to models. An MCP server is maintained either by a SaaS provider or a company's internal AI platform team.
An MCP server publishes a catalog of tools for other AI applications to use. As we saw in Part 3, each tool in the catalog has a name, typed parameters and a description. An AI application uses an MCP client to retrieve that catalog and hand it to a model. AI engineers develop both MCP clients and servers, similar to how a backend engineer builds APIs and calls other APIs as well. Communication happens via the MCP protocol, which is based on the JSON-RPC 2.0 protocol. These messages are sent over either streamable HTTP or stdio. With streamable HTTP, the server sits behind a URL, which suits shared and hosted deployments. Clients using stdio spawn the MCP server as a subprocess and talk over stdin and stdout.
Note that the model never speaks MCP. That talking is done by the tools, which get the tool catalog from the MCP server and feed it back to the model. This catalog is similar to the local tool catalog from Part 3. And when the model requests a specific tool, it is a tool that sends a request to the MCP server. Are you still following? Let's look at an example.
Once you developed your tools, building an MCP server around it is actually pretty straightforward. Here we will expose our tools calculate_refund_cost
and search_payments_knowledge_base
over MCP. Create app/mcp_server.py
:
from langchain_mcp_adapters.tools import to_fastmcp
from mcp.server.fastmcp import FastMCP
from app.tools import calculate_refund_cost, issue_refund
from app.rag import search_payments_knowledge_base
mcp = FastMCP(
"payiq-tools",
tools=[
to_fastmcp(calculate_refund_cost),
to_fastmcp(search_payments_knowledge_base),
],
)
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Start the server with python -m app.mcp_server
and lets curl that catalog to try to out. First, initialize the mcp session to get the session id:
$ curl -sS http://localhost:8000/mcp \
-X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-D - \
-o /dev/null \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' |
awk -F': ' 'tolower($1) == "mcp-session-id" {print $2}' |
tr -d '\r'
208a02151828414894c1b0b0c33e3c2a
And then use it to get the catalog:
$ curl http://localhost:8000/mcp \
-X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: 208a02151828414894c1b0b0c33e3c2a" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| sed -n 's/^data: //p' | jq .
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "calculate_refund_cost",
"description": "Calculate what [...] processing fees.",
"inputSchema": {
"description": "Calculate what refunding [...] the processing fees.",
"properties": {
"original_charge_eur": {
"title": "Original Charge Eur",
"type": "number"
},
"refund_amount_eur": {
"title": "Refund Amount Eur",
"type": "number"
},
"processing_fee_pct": {
"title": "Processing Fee Pct",
"type": "number"
},
"processing_fee_fixed_eur": {
"title": "Processing Fee Fixed Eur",
"type": "number"
},
"refund_admin_fee_eur": {
"default": 0.25,
"title": "Refund Admin Fee Eur",
"type": "number"
}
},
"required": [
"original_charge_eur",
"refund_amount_eur",
"processing_fee_pct",
"processing_fee_fixed_eur"
],
"title": "calculate_refund_cost",
"type": "object"
}
},
{
"name": "search_payments_knowledge_base",
"description": "Search internal [...]ine\".",
"inputSchema": {
"description": "Search internal notes on processing fees, [...] rather than guessing.\n\nArgs:\n query: What you need to know, e.g. \"card processing fees\" or\n \"chargeback response deadline\".",
"properties": {
"query": {
"title": "Query",
"type": "string"
}
},
"required": [
"query"
],
"title": "search_payments_knowledge_base",
"type": "object"
}
}
]
}
}
Exactly the same structure as the tool catalog we saw in Part 3! Imagine that your company has a bunch of tools to access git repositories, ticket systems, monitoring tooling and more. All of these can be exposed to AI applications company-wide via such MCP servers.
Now we want to our application to use this tool catalog. Create 05_tool_mcp.py
(tip: compare it to Part 3):
import asyncio
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_mcp_adapters.client import MultiServerMCPClient
load_dotenv()
async def main():
client = MultiServerMCPClient(
{
"payiq": {
"transport": "streamable_http",
"url": "http://localhost:8000/mcp",
}
}
)
tools = await client.get_tools()
tool_map = {t.name: t for t in tools}
model = init_chat_model("anthropic:claude-sonnet-5")
model_with_tools = model.bind_tools(tools)
question = (
"Customer paid €480 on a European consumer card. "
"What does it cost us and how much costs a refund?"
)
msg = await model_with_tools.ainvoke(question)
print(msg.tool_calls)
tool_messages = []
for tool_call in msg.tool_calls:
tool_messages.append(
await tool_map[tool_call["name"]].ainvoke(tool_call)
)
next_msg = await model_with_tools.ainvoke(
[{"role": "user", "content": question}, msg, *tool_messages]
)
print(next_msg.tool_calls)
if __name__ == "__main__":
asyncio.run(main())
When I ran this snippet, the model first requested the search_payments_knowledge_base
tool and then decided that it also wanted to use calculate_refund_cost
:
$ python 05_tool_mcp.py
[{'name': 'search_payments_knowledge_base', 'args': {'query': 'European consumer card processing fees'}, 'id': 'toolu_01QWJJYSSySs2r9KLpMTNPgA', 'type': 'tool_call'}]
[{'name': 'calculate_refund_cost', 'args': {'original_charge_eur': 480, 'refund_amount_eur': 480, 'processing_fee_pct': 1.8, 'processing_fee_fixed_eur': 0.25}, 'id': 'toolu_01Lwosv4XVE3QJTSpeCbj4xz', 'type': 'tool_call'}]
After reading the snippet above, you probably screamed at the screen: "Keep looping on those tool calls until you get the final answer!". And you'd be right, we should do that. As we'll soon discover, that loop is what separates a model from an agent. Things are starting to feel pretty advanced, and the next article takes the final step to become AGENTIC!
Find all code samples in the companion repo here:
[https://github.com/BjornvdLaan/ai-engineering-articles-code-samples]