A practical, working demonstration of the Model Context Protocol (MCP) applied to a real-world use case: AI-powered travel journey planning using multiple live public APIs.
- What is MCP?
- Why MCP? The Core Problem It Solves
- Architecture: With vs Without MCP
- Technical Deep Dive
- This Project
- Getting Started
- Key Takeaways
The Model Context Protocol (MCP) is an open standard, introduced by Anthropic in late 2024, that defines how AI language models connect to external data sources and tools. It is to AI integrations what REST is to web APIs — a universal contract that decouples producers of data (MCP Servers) from consumers of data (MCP Clients, i.e. LLM-powered apps).
MCP is transport-agnostic (supports stdio, HTTP/SSE, WebSocket) and language-agnostic, with official SDKs in Python and TypeScript.
Official spec: https://modelcontextprotocol.io
Before MCP, every AI application had to build its own custom integration for every data source:
App A ──── custom glue code ──── Database
App A ──── custom glue code ──── Weather API
App A ──── custom glue code ──── Wikipedia
App B ──── custom glue code ──── Database ← duplicated!
App B ──── custom glue code ──── Weather API ← duplicated!
App B ──── custom glue code ──── Wikipedia ← duplicated!
This is the M × N integration problem: M applications × N data sources = M×N custom integrations to build and maintain.
MCP introduces a standard server layer that any client can connect to:
App A ──┐
App B ──┼──── MCP Client ──── MCP Server ──── Database
App C ──┘ └── Weather API
└── Wikipedia
Now it's M + N: M clients connect to N servers using one shared protocol.
Developer writes:
┌─────────────────────────────────────────────┐
│ for each city: │
│ coords = call_geocoding_api(city) │
│ weather = call_weather_api(coords) │
│ info = call_wikipedia_api(city) │
│ fx = call_currency_api(city) │
│ │
│ context = dump_all_to_json(all_data) │
│ prompt = f"Here is ALL data:\n{context}" │
│ answer = llm.complete(prompt) │
└─────────────────────────────────────────────┘
Problems:
| Issue | Impact |
|---|---|
| All data fetched upfront, blindly | Token waste — LLM receives data it may not need |
| Developer decides what to fetch | Rigid — LLM cannot ask for more data on demand |
| No reusability | Every new app duplicates the same HTTP calls |
| Tight coupling | Changing an API breaks the whole app |
| No tool composability | Cannot mix-and-match data sources easily |
The web UI shows a concrete metric: "N chars pre-loaded". This is the exact byte size of the JSON blob that gets pasted into the LLM prompt before the LLM has said a single word.
For a 4-city trip (Paris, Rome, Athens, Istanbul) this is typically ~8,000–10,000 characters blindly injected into the prompt, because the code fetches all 4 data points (coordinates, weather, Wikipedia extract, currency rate) for every city unconditionally:
4 cities × 4 API calls = 16 HTTP requests, always
→ All 16 results serialized to JSON
→ Entire JSON blob prepended to the prompt
→ LLM charged tokens for ALL of it, whether it needed it or not
With MCP, the LLM instead calls only the tools it decides are relevant. If the user asks only about weather, only get_weather() is called. If one city already uses EUR, get_currency_rate() is skipped for that city entirely. The prompt stays small and precise.
┌─────────────────────────┐ ┌──────────────────────────────────┐
│ MCP CLIENT │ │ MCP SERVER │
│ (mcp_core.py + Gemini) │ │ (mcp_server.py) │
│ │ stdio │ │
│ 1. Connect & discover │────────▶│ Exposes tools: │
│ available tools │◀────────│ • get_coordinates(city) │
│ │ │ • get_weather(city) │
│ 2. Send user prompt │ │ • get_place_info(city) │
│ to Gemini with │ │ • get_currency_rate(from, to) │
│ tool definitions │ │ │
│ │ │ Each tool: │
│ 3. Gemini decides │ │ - calls real public APIs │
│ which tool to call │ │ - returns structured JSON │
│ and with what args │ │ - is independently testable │
│ │ stdio │ │
│ 4. Client calls tool │────────▶│ Executes tool, calls APIs │
│ on MCP server │◀────────│ Returns result │
│ │ │ │
│ 5. Result fed back │ └──────────────────────────────────┘
│ to Gemini │
│ │
│ 6. Repeat until │
│ Gemini has enough │
│ → final answer │
└─────────────────────────┘
MCP uses JSON-RPC 2.0 over a chosen transport. In this project we use stdio (standard input/output), meaning the client spawns the server as a subprocess and communicates via pipes. This is the simplest and most portable transport.
A typical session looks like:
Client → Server: {"jsonrpc":"2.0","method":"initialize","params":{...},"id":1}
Server → Client: {"jsonrpc":"2.0","result":{"capabilities":{...}},"id":1}
Client → Server: {"jsonrpc":"2.0","method":"tools/list","id":2}
Server → Client: {"jsonrpc":"2.0","result":{"tools":[
{"name":"get_weather","description":"...","inputSchema":{...}},
...
]},"id":2}
Client → Server: {"jsonrpc":"2.0","method":"tools/call",
"params":{"name":"get_weather","arguments":{"city":"Paris"}},"id":3}
Server → Client: {"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"{...}"}]},"id":3}
This is fully handled by the mcp Python SDK — you never write raw JSON-RPC.
One of MCP's most powerful features is automatic tool discovery. The client does not need to know in advance what tools exist:
# Client discovers tools at runtime — zero hardcoding
tools_response = await session.list_tools()
available_tools = tools_response.tools
# → [get_coordinates, get_weather, get_place_info, get_currency_rate]
# Converts MCP tool schema to LLM-native format automatically
gemini_tools = [
types.Tool(function_declarations=[
types.FunctionDeclaration(
name=t.name,
description=t.description,
parameters=t.inputSchema, # ← JSON Schema, directly usable by Gemini
)
for t in available_tools
])
]Add a new tool to mcp_server.py? The client picks it up automatically on the next request — no client code changes needed.
The LLM drives the entire data-fetching process autonomously:
while True:
# 1. LLM reasons about what it still needs
response = gemini.generate_content(contents, tools=gemini_tools)
# 2. If no tool calls → LLM has everything it needs → done
if no_function_calls(response):
return response.text
# 3. Otherwise, execute the requested tool calls
for fc in response.function_calls:
result = await mcp_session.call_tool(fc.name, fc.args)
# Feed result back into conversation
contents.append(tool_result(fc.name, result))
# 4. Loop — LLM may call more tools based on what it just learnedThis means the LLM can:
- Call tools conditionally (e.g., only get currency if the country uses a non-EUR currency)
- Call tools sequentially (e.g., geocode first, then use coordinates for weather)
- Call tools in parallel (when the LLM groups multiple tool calls in one response)
- Decide not to call a tool if it already has enough information
Defining a tool on the MCP server is as simple as decorating a Python function:
# mcp_server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Travel Journey Server")
@mcp.tool()
def get_weather(city: str) -> dict:
"""
Get current weather and 3-day forecast for a city.
The docstring becomes the tool description that Gemini uses to decide when to call it.
"""
geo = _geocode(city)
# ... call Open-Meteo API ...
return {"city": ..., "current": {...}, "forecast_3_days": [...]}
mcp.run(transport="stdio")The @mcp.tool() decorator:
- Extracts the function signature to build the JSON Schema (
inputSchema) - Uses the docstring as the tool description for the LLM
- Handles serialization/deserialization automatically
| Tool | API | What it provides |
|---|---|---|
get_coordinates |
Open-Meteo Geocoding | lat/lon, timezone, country |
get_weather |
Open-Meteo Forecast | Current weather + 3-day forecast |
get_place_info |
Wikipedia REST API | City summary and description |
get_currency_rate |
Frankfurter.app | Live FX rates between any two currencies |
MCP/
│
├── mcp_server.py # MCP Server — exposes 4 travel tools
│ # Each tool = one @mcp.tool() decorated function
│ # Runs as a stdio subprocess
│
├── mcp_core.py # Core async MCP client logic
│ # Connects to server, runs the Gemini agentic loop
│ # Model fallback chain: gemini-2.5-flash → 2.0-flash → 2.0-flash-lite
│
├── app.py # Flask web server
│ # Single route: POST /plan → calls mcp_core → returns JSON
│
├── templates/
│ └── index.html # Single-page web UI
│ # Shows live tool calls + renders answer as Markdown
│
├── without_mcp.py # ⚡ Comparison script — same task WITHOUT MCP
│ # Manually fetches all data, dumps into prompt
│
├── mcp_client.py # CLI version of the MCP client (for terminal use)
│
├── requirements.txt # mcp[cli], httpx, google-genai, flask, python-dotenv
├── .env.example # Template — copy to .env and add GEMINI_API_KEY
└── .gitignore
git clone https://github.com/skepee-LAB/mcp-travel-planner
cd mcp-travel-planner
pip install -r requirements.txtcp .env.example .env
# Edit .env and set: GEMINI_API_KEY=your_key_hereGet a free key (no credit card) at: https://aistudio.google.com/app/apikey
# Windows
set PYTHONUTF8=1 && python app.py
# macOS/Linux
PYTHONUTF8=1 python app.pyThen open http://localhost:5000
# Traditional approach (no MCP)
python without_mcp.py
# MCP approach
python mcp_client.py| Without MCP | With MCP | |
|---|---|---|
| Who decides what to fetch | Developer (hardcoded) | LLM (at runtime) |
| Data fetched | Everything, upfront, blindly | Only what's needed |
| Token efficiency | Low — full data dump in prompt (~8–10K chars for 4 cities) | High — only relevant data fetched |
| Reusability | Zero — tied to one app | Full — any MCP client can connect |
| Tool discovery | None — developer must know all APIs | Automatic — client asks server |
| Adding a new data source | Edit every app that needs it | Add one @mcp.tool() to the server |
| Separation of concerns | None — logic mixed in app | Clean — server owns data, client owns conversation |
| LLM autonomy | None — LLM is passive | Full — LLM reasons about what it needs |
MCP delivers value at two distinct levels:
Layer 1 — Standardised integration (the "plumbing")
MCP gives you a universal connector. Instead of writing custom glue code for every API in every app, you write the integration once as an MCP server. Any MCP-compatible host (your Flask app, VS Code Copilot, Claude Desktop, etc.) can plug into it with zero extra code. This is the M+N solution to the M×N integration problem described above.
Layer 2 — LLM-driven efficiency (the "intelligence")
This is the deeper insight. Because the LLM is the one calling the tools, it brings reasoning to the data-fetching process. The LLM doesn't just use the tools — it decides which tools to call, in what order, with what arguments, and whether to call them at all:
| Situation | Without MCP | With MCP |
|---|---|---|
| User asks only about weather | Fetches weather + Wikipedia + currency anyway | Calls only get_weather() |
| City already uses EUR | Still fetches currency rate | Skips get_currency_rate() — unnecessary |
| User asks a follow-up question | Re-fetches everything again | Calls only the missing piece |
| LLM needs coords before weather | Developer must hard-code that order | LLM sequences the calls itself |
The combined effect:
MCP = standard interface + LLM as the decision-maker
└─ any app can use it └─ no wasted API calls, no wasted tokens
Without MCP, the developer is the decision-maker (rigid, hardcoded, always fetches everything). With MCP, the LLM is the decision-maker (flexible, context-aware, fetches only what it needs). The protocol is what makes that hand-off safe and standardised.
MCP shines when:
- You have multiple data sources that an LLM needs to query
- Different apps need access to the same data (write once, use everywhere)
- The set of tools may grow over time without breaking existing clients
- You want the LLM to decide what to fetch rather than pre-fetching everything
- You want your tools to be usable from VS Code Copilot, Claude Desktop, or any other MCP host
MCP adds overhead (subprocess spawning, JSON-RPC handshake). For a simple, single-purpose app with one fixed data source, a direct API call is simpler. MCP is the right choice when reusability, composability, and LLM autonomy matter.
MIT