Add beginner-friendly documentation to api.py and weather_mcp_server.py
Browse filesExplains FastAPI, MCP, async/await, HuggingFace, and the overall architecture
for readers unfamiliar with these technologies.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- api.py +137 -26
- weather_mcp_server.py +101 -14
api.py
CHANGED
|
@@ -1,14 +1,47 @@
|
|
| 1 |
"""
|
| 2 |
-
api.py β
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
The
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import asyncio
|
|
@@ -24,13 +57,23 @@ from mcp.client.sse import sse_client
|
|
| 24 |
from huggingface_hub import InferenceClient
|
| 25 |
|
| 26 |
# ---------------------------------------------------------------------------
|
| 27 |
-
#
|
| 28 |
# ---------------------------------------------------------------------------
|
| 29 |
|
|
|
|
|
|
|
| 30 |
MODEL = "Qwen/Qwen2.5-7B-Instruct"
|
|
|
|
|
|
|
|
|
|
| 31 |
HF_TOKEN = os.environ.get("HF_TOKEN")
|
|
|
|
|
|
|
|
|
|
| 32 |
MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://localhost:8000/sse")
|
| 33 |
|
|
|
|
|
|
|
| 34 |
SYSTEM_PROMPT = (
|
| 35 |
"You are a helpful assistant with access to real-time weather data. "
|
| 36 |
"When the user asks about temperature or weather, always use the "
|
|
@@ -39,12 +82,18 @@ SYSTEM_PROMPT = (
|
|
| 39 |
)
|
| 40 |
|
| 41 |
# ---------------------------------------------------------------------------
|
| 42 |
-
# Startup: wait for
|
| 43 |
# ---------------------------------------------------------------------------
|
| 44 |
|
|
|
|
|
|
|
| 45 |
@asynccontextmanager
|
| 46 |
async def lifespan(app: FastAPI):
|
| 47 |
print("Waiting for MCP server to be ready...", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
for _ in range(20):
|
| 49 |
try:
|
| 50 |
async with sse_client(MCP_SERVER_URL) as (read, write):
|
|
@@ -54,23 +103,32 @@ async def lifespan(app: FastAPI):
|
|
| 54 |
print(f"MCP ready β {len(tools.tools)} tool(s) available", flush=True)
|
| 55 |
break
|
| 56 |
except Exception:
|
| 57 |
-
await asyncio.sleep(1)
|
| 58 |
else:
|
| 59 |
print("WARNING: MCP server did not become ready in time", flush=True)
|
| 60 |
-
yield
|
| 61 |
|
|
|
|
| 62 |
|
|
|
|
|
|
|
| 63 |
app = FastAPI(title="Weather via MCP + Qwen2.5", lifespan=lifespan)
|
| 64 |
|
| 65 |
# ---------------------------------------------------------------------------
|
| 66 |
-
#
|
| 67 |
# ---------------------------------------------------------------------------
|
| 68 |
|
| 69 |
async def fetch_mcp_tools() -> list[dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
async with sse_client(MCP_SERVER_URL) as (read, write):
|
| 71 |
async with ClientSession(read, write) as session:
|
| 72 |
await session.initialize()
|
| 73 |
tools_result = await session.list_tools()
|
|
|
|
| 74 |
return [
|
| 75 |
{
|
| 76 |
"type": "function",
|
|
@@ -85,39 +143,70 @@ async def fetch_mcp_tools() -> list[dict]:
|
|
| 85 |
|
| 86 |
|
| 87 |
async def call_mcp_tool(tool_name: str, tool_args: dict) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
async with sse_client(MCP_SERVER_URL) as (read, write):
|
| 89 |
async with ClientSession(read, write) as session:
|
| 90 |
await session.initialize()
|
| 91 |
result = await session.call_tool(tool_name, tool_args)
|
|
|
|
| 92 |
return "\n".join(
|
| 93 |
block.text for block in result.content if hasattr(block, "text")
|
| 94 |
)
|
| 95 |
|
| 96 |
# ---------------------------------------------------------------------------
|
| 97 |
-
#
|
| 98 |
# ---------------------------------------------------------------------------
|
| 99 |
|
| 100 |
async def run(user_message: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
client = InferenceClient(model=MODEL, token=HF_TOKEN)
|
|
|
|
|
|
|
| 102 |
tools = await fetch_mcp_tools()
|
| 103 |
|
|
|
|
|
|
|
| 104 |
messages = [
|
| 105 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 106 |
{"role": "user", "content": user_message},
|
| 107 |
]
|
| 108 |
|
|
|
|
| 109 |
response = client.chat.completions.create(
|
| 110 |
messages=messages,
|
| 111 |
-
tools=tools,
|
| 112 |
-
tool_choice="auto",
|
| 113 |
-
max_tokens=512,
|
| 114 |
-
temperature=0.2,
|
| 115 |
)
|
| 116 |
|
| 117 |
choice = response.choices[0]
|
| 118 |
assistant_msg = choice.message
|
| 119 |
|
|
|
|
| 120 |
while choice.finish_reason == "tool_calls" and assistant_msg.tool_calls:
|
|
|
|
|
|
|
| 121 |
messages.append({
|
| 122 |
"role": "assistant",
|
| 123 |
"content": assistant_msg.content or "",
|
|
@@ -134,16 +223,18 @@ async def run(user_message: str) -> str:
|
|
| 134 |
],
|
| 135 |
})
|
| 136 |
|
|
|
|
| 137 |
for tc in assistant_msg.tool_calls:
|
| 138 |
fn_name = tc.function.name
|
| 139 |
-
fn_args = json.loads(tc.function.arguments)
|
| 140 |
tool_result = await call_mcp_tool(fn_name, fn_args)
|
| 141 |
messages.append({
|
| 142 |
"role": "tool",
|
| 143 |
-
"tool_call_id": tc.id,
|
| 144 |
"content": tool_result,
|
| 145 |
})
|
| 146 |
|
|
|
|
| 147 |
response = client.chat.completions.create(
|
| 148 |
messages=messages,
|
| 149 |
tools=tools,
|
|
@@ -154,26 +245,44 @@ async def run(user_message: str) -> str:
|
|
| 154 |
choice = response.choices[0]
|
| 155 |
assistant_msg = choice.message
|
| 156 |
|
|
|
|
| 157 |
return assistant_msg.content or "(no response)"
|
| 158 |
|
| 159 |
# ---------------------------------------------------------------------------
|
| 160 |
-
#
|
| 161 |
# ---------------------------------------------------------------------------
|
| 162 |
|
|
|
|
|
|
|
|
|
|
| 163 |
class AskRequest(BaseModel):
|
| 164 |
-
question: str
|
| 165 |
|
| 166 |
class AskResponse(BaseModel):
|
| 167 |
-
answer: str
|
| 168 |
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
@app.get("/health")
|
| 171 |
async def health():
|
|
|
|
| 172 |
return {"status": "ok"}
|
| 173 |
|
| 174 |
|
| 175 |
@app.post("/ask", response_model=AskResponse)
|
| 176 |
async def ask(req: AskRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
if not req.question.strip():
|
| 178 |
raise HTTPException(status_code=400, detail="question must not be empty")
|
| 179 |
answer = await run(req.question)
|
|
@@ -181,9 +290,11 @@ async def ask(req: AskRequest):
|
|
| 181 |
|
| 182 |
|
| 183 |
# ---------------------------------------------------------------------------
|
| 184 |
-
# Entry point
|
| 185 |
# ---------------------------------------------------------------------------
|
| 186 |
|
|
|
|
|
|
|
| 187 |
if __name__ == "__main__":
|
| 188 |
import uvicorn
|
| 189 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
|
| 1 |
"""
|
| 2 |
+
api.py β The main web server for this weather assistant app.
|
| 3 |
+
|
| 4 |
+
HOW THIS APP WORKS:
|
| 5 |
+
When someone sends a question like "What's the weather in Tokyo?", this file
|
| 6 |
+
handles it end-to-end:
|
| 7 |
+
|
| 8 |
+
1. Receives the question via HTTP (like a web form submission)
|
| 9 |
+
2. Passes it to an AI language model (Qwen2.5) running on HuggingFace
|
| 10 |
+
3. The AI decides it needs real weather data, so it asks for the
|
| 11 |
+
get_current_temperature tool to be called
|
| 12 |
+
4. This server calls that tool (defined in weather_mcp_server.py)
|
| 13 |
+
5. The tool fetches live weather from the internet
|
| 14 |
+
6. The result is sent back to the AI, which writes a natural response
|
| 15 |
+
7. That response is returned to whoever asked the question
|
| 16 |
+
|
| 17 |
+
WHAT IS FastAPI?
|
| 18 |
+
FastAPI is a Python library for building web servers. It lets you define
|
| 19 |
+
"endpoints" β URLs that accept requests and return responses. Think of it
|
| 20 |
+
like building a simple API that other apps or curl commands can talk to.
|
| 21 |
+
|
| 22 |
+
WHAT IS MCP (Model Context Protocol)?
|
| 23 |
+
MCP is a standard way for AI models to use external tools. Instead of the
|
| 24 |
+
AI just generating text, it can say "I need to call this tool with these
|
| 25 |
+
arguments." This server listens for those requests and executes the tools.
|
| 26 |
+
weather_mcp_server.py defines the tools; this file calls them.
|
| 27 |
+
|
| 28 |
+
WHAT IS HuggingFace?
|
| 29 |
+
HuggingFace is a platform that hosts AI models and lets you run them via
|
| 30 |
+
their API. This app uses their "Inference API" to run the Qwen2.5 language
|
| 31 |
+
model without needing to host it ourselves.
|
| 32 |
+
|
| 33 |
+
ENDPOINTS (URLs this server responds to):
|
| 34 |
+
POST /ask Send a question, get an answer
|
| 35 |
+
Request: { "question": "What's the temp in Tokyo?" }
|
| 36 |
+
Response: { "answer": "It's currently 72Β°F and sunny in Tokyo..." }
|
| 37 |
+
|
| 38 |
+
GET /health Just checks that the server is running
|
| 39 |
+
Response: { "status": "ok" }
|
| 40 |
+
|
| 41 |
+
ENVIRONMENT VARIABLES (settings loaded from the environment, not hardcoded):
|
| 42 |
+
HF_TOKEN Your HuggingFace API token β needed to use the AI model
|
| 43 |
+
MCP_SERVER_URL Where the weather tool server is running
|
| 44 |
+
(defaults to http://localhost:8000/sse)
|
| 45 |
"""
|
| 46 |
|
| 47 |
import asyncio
|
|
|
|
| 57 |
from huggingface_hub import InferenceClient
|
| 58 |
|
| 59 |
# ---------------------------------------------------------------------------
|
| 60 |
+
# Configuration
|
| 61 |
# ---------------------------------------------------------------------------
|
| 62 |
|
| 63 |
+
# The AI model we're using. Qwen2.5 is an open-source language model made by
|
| 64 |
+
# Alibaba, hosted for free on HuggingFace.
|
| 65 |
MODEL = "Qwen/Qwen2.5-7B-Instruct"
|
| 66 |
+
|
| 67 |
+
# Your HuggingFace token, loaded from an environment variable (not hardcoded
|
| 68 |
+
# here for security reasons).
|
| 69 |
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 70 |
+
|
| 71 |
+
# The URL of the weather tool server (weather_mcp_server.py). SSE (Server-Sent
|
| 72 |
+
# Events) is the communication protocol they use to talk to each other.
|
| 73 |
MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://localhost:8000/sse")
|
| 74 |
|
| 75 |
+
# This is the instruction we give the AI at the start of every conversation.
|
| 76 |
+
# It tells the AI what its job is and how to behave.
|
| 77 |
SYSTEM_PROMPT = (
|
| 78 |
"You are a helpful assistant with access to real-time weather data. "
|
| 79 |
"When the user asks about temperature or weather, always use the "
|
|
|
|
| 82 |
)
|
| 83 |
|
| 84 |
# ---------------------------------------------------------------------------
|
| 85 |
+
# Startup: wait for the weather tool server to be ready
|
| 86 |
# ---------------------------------------------------------------------------
|
| 87 |
|
| 88 |
+
# This function runs when the web server first starts up. FastAPI calls it
|
| 89 |
+
# automatically before accepting any requests.
|
| 90 |
@asynccontextmanager
|
| 91 |
async def lifespan(app: FastAPI):
|
| 92 |
print("Waiting for MCP server to be ready...", flush=True)
|
| 93 |
+
|
| 94 |
+
# Try up to 20 times (once per second) to connect to the weather tool
|
| 95 |
+
# server. Both servers start at the same time, so this gives the tool
|
| 96 |
+
# server time to finish booting before we start accepting requests.
|
| 97 |
for _ in range(20):
|
| 98 |
try:
|
| 99 |
async with sse_client(MCP_SERVER_URL) as (read, write):
|
|
|
|
| 103 |
print(f"MCP ready β {len(tools.tools)} tool(s) available", flush=True)
|
| 104 |
break
|
| 105 |
except Exception:
|
| 106 |
+
await asyncio.sleep(1) # Wait 1 second before trying again
|
| 107 |
else:
|
| 108 |
print("WARNING: MCP server did not become ready in time", flush=True)
|
|
|
|
| 109 |
|
| 110 |
+
yield # Hand control back to FastAPI β start accepting requests
|
| 111 |
|
| 112 |
+
|
| 113 |
+
# Create the FastAPI web server instance.
|
| 114 |
app = FastAPI(title="Weather via MCP + Qwen2.5", lifespan=lifespan)
|
| 115 |
|
| 116 |
# ---------------------------------------------------------------------------
|
| 117 |
+
# Functions for talking to the weather tool server (MCP)
|
| 118 |
# ---------------------------------------------------------------------------
|
| 119 |
|
| 120 |
async def fetch_mcp_tools() -> list[dict]:
|
| 121 |
+
"""
|
| 122 |
+
Ask the weather tool server what tools it has available.
|
| 123 |
+
|
| 124 |
+
Returns a list of tool definitions formatted the way the AI model expects
|
| 125 |
+
them β including the tool's name, what it does, and what arguments it takes.
|
| 126 |
+
"""
|
| 127 |
async with sse_client(MCP_SERVER_URL) as (read, write):
|
| 128 |
async with ClientSession(read, write) as session:
|
| 129 |
await session.initialize()
|
| 130 |
tools_result = await session.list_tools()
|
| 131 |
+
# Reformat each tool into the structure the AI model expects
|
| 132 |
return [
|
| 133 |
{
|
| 134 |
"type": "function",
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
async def call_mcp_tool(tool_name: str, tool_args: dict) -> str:
|
| 146 |
+
"""
|
| 147 |
+
Call a specific tool on the weather tool server and return its result.
|
| 148 |
+
|
| 149 |
+
For example: call_mcp_tool("get_current_temperature", {"location": "Tokyo"})
|
| 150 |
+
connects to weather_mcp_server.py, runs that function, and returns the
|
| 151 |
+
weather data as a string.
|
| 152 |
+
"""
|
| 153 |
async with sse_client(MCP_SERVER_URL) as (read, write):
|
| 154 |
async with ClientSession(read, write) as session:
|
| 155 |
await session.initialize()
|
| 156 |
result = await session.call_tool(tool_name, tool_args)
|
| 157 |
+
# The result may contain multiple content blocks; join them into one string
|
| 158 |
return "\n".join(
|
| 159 |
block.text for block in result.content if hasattr(block, "text")
|
| 160 |
)
|
| 161 |
|
| 162 |
# ---------------------------------------------------------------------------
|
| 163 |
+
# The agentic loop β the core logic of this app
|
| 164 |
# ---------------------------------------------------------------------------
|
| 165 |
|
| 166 |
async def run(user_message: str) -> str:
|
| 167 |
+
"""
|
| 168 |
+
Send a user's question to the AI and return its final answer.
|
| 169 |
+
|
| 170 |
+
This is called an "agentic loop" because the AI can go back and forth
|
| 171 |
+
multiple times β asking for tool results, getting them, then deciding
|
| 172 |
+
whether to ask for more or give a final answer.
|
| 173 |
+
|
| 174 |
+
Typical flow:
|
| 175 |
+
1. We send: [system prompt] + [user question] + [available tools]
|
| 176 |
+
2. AI responds: "I need to call get_current_temperature for Tokyo"
|
| 177 |
+
3. We call that tool and get the weather data
|
| 178 |
+
4. We send the weather data back to the AI
|
| 179 |
+
5. AI responds with a natural language answer β we return that
|
| 180 |
+
"""
|
| 181 |
+
# Create a client that can talk to HuggingFace's AI inference API
|
| 182 |
client = InferenceClient(model=MODEL, token=HF_TOKEN)
|
| 183 |
+
|
| 184 |
+
# Get the list of available tools from the weather server
|
| 185 |
tools = await fetch_mcp_tools()
|
| 186 |
|
| 187 |
+
# Build the conversation history. The AI sees this as a back-and-forth chat.
|
| 188 |
+
# "system" sets the AI's behavior; "user" is the human's message.
|
| 189 |
messages = [
|
| 190 |
{"role": "system", "content": SYSTEM_PROMPT},
|
| 191 |
{"role": "user", "content": user_message},
|
| 192 |
]
|
| 193 |
|
| 194 |
+
# Send the conversation to the AI model and get its first response
|
| 195 |
response = client.chat.completions.create(
|
| 196 |
messages=messages,
|
| 197 |
+
tools=tools, # Tell the AI what tools it can use
|
| 198 |
+
tool_choice="auto", # Let the AI decide whether to use a tool
|
| 199 |
+
max_tokens=512, # Maximum length of the AI's response
|
| 200 |
+
temperature=0.2, # Low temperature = more focused, less random responses
|
| 201 |
)
|
| 202 |
|
| 203 |
choice = response.choices[0]
|
| 204 |
assistant_msg = choice.message
|
| 205 |
|
| 206 |
+
# If the AI wants to call a tool, handle it and loop back
|
| 207 |
while choice.finish_reason == "tool_calls" and assistant_msg.tool_calls:
|
| 208 |
+
|
| 209 |
+
# Add the AI's tool request to the conversation history
|
| 210 |
messages.append({
|
| 211 |
"role": "assistant",
|
| 212 |
"content": assistant_msg.content or "",
|
|
|
|
| 223 |
],
|
| 224 |
})
|
| 225 |
|
| 226 |
+
# Execute each tool the AI requested and add results to the conversation
|
| 227 |
for tc in assistant_msg.tool_calls:
|
| 228 |
fn_name = tc.function.name
|
| 229 |
+
fn_args = json.loads(tc.function.arguments) # Parse the JSON arguments string
|
| 230 |
tool_result = await call_mcp_tool(fn_name, fn_args)
|
| 231 |
messages.append({
|
| 232 |
"role": "tool",
|
| 233 |
+
"tool_call_id": tc.id, # Links this result to the specific tool call
|
| 234 |
"content": tool_result,
|
| 235 |
})
|
| 236 |
|
| 237 |
+
# Send the updated conversation (now including tool results) back to the AI
|
| 238 |
response = client.chat.completions.create(
|
| 239 |
messages=messages,
|
| 240 |
tools=tools,
|
|
|
|
| 245 |
choice = response.choices[0]
|
| 246 |
assistant_msg = choice.message
|
| 247 |
|
| 248 |
+
# The AI has finished β return its final text response
|
| 249 |
return assistant_msg.content or "(no response)"
|
| 250 |
|
| 251 |
# ---------------------------------------------------------------------------
|
| 252 |
+
# Request/response shapes
|
| 253 |
# ---------------------------------------------------------------------------
|
| 254 |
|
| 255 |
+
# These classes define the exact structure of data that goes in and out of
|
| 256 |
+
# the /ask endpoint. FastAPI uses them to validate requests and format responses.
|
| 257 |
+
|
| 258 |
class AskRequest(BaseModel):
|
| 259 |
+
question: str # The user's weather question
|
| 260 |
|
| 261 |
class AskResponse(BaseModel):
|
| 262 |
+
answer: str # The AI's response
|
| 263 |
|
| 264 |
+
# ---------------------------------------------------------------------------
|
| 265 |
+
# Endpoints (URLs the server responds to)
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
|
| 268 |
@app.get("/health")
|
| 269 |
async def health():
|
| 270 |
+
"""Simple health check β returns OK if the server is running."""
|
| 271 |
return {"status": "ok"}
|
| 272 |
|
| 273 |
|
| 274 |
@app.post("/ask", response_model=AskResponse)
|
| 275 |
async def ask(req: AskRequest):
|
| 276 |
+
"""
|
| 277 |
+
Main endpoint β accepts a weather question and returns an AI-generated answer.
|
| 278 |
+
|
| 279 |
+
Example request:
|
| 280 |
+
POST /ask
|
| 281 |
+
{ "question": "What's the temperature in Paris?" }
|
| 282 |
+
|
| 283 |
+
Example response:
|
| 284 |
+
{ "answer": "It's currently 58Β°F and partly cloudy in Paris, France." }
|
| 285 |
+
"""
|
| 286 |
if not req.question.strip():
|
| 287 |
raise HTTPException(status_code=400, detail="question must not be empty")
|
| 288 |
answer = await run(req.question)
|
|
|
|
| 290 |
|
| 291 |
|
| 292 |
# ---------------------------------------------------------------------------
|
| 293 |
+
# Entry point (only used when running locally, not in Docker)
|
| 294 |
# ---------------------------------------------------------------------------
|
| 295 |
|
| 296 |
+
# This block only runs if you start the file directly: `python api.py`
|
| 297 |
+
# In Docker/HuggingFace Spaces, supervisord starts uvicorn directly instead.
|
| 298 |
if __name__ == "__main__":
|
| 299 |
import uvicorn
|
| 300 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
weather_mcp_server.py
CHANGED
|
@@ -1,37 +1,95 @@
|
|
| 1 |
"""
|
| 2 |
-
weather_mcp_server.py
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import httpx
|
| 11 |
from mcp.server.fastmcp import FastMCP
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
mcp = FastMCP("weather-server", host="0.0.0.0", port=8000)
|
| 14 |
|
| 15 |
|
| 16 |
def _geocode(location: str) -> tuple[float, float, str]:
|
| 17 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
resp = httpx.get(
|
| 19 |
"https://geocoding-api.open-meteo.com/v1/search",
|
| 20 |
params={"name": location, "count": 1, "language": "en", "format": "json"},
|
| 21 |
timeout=10,
|
| 22 |
)
|
| 23 |
-
resp.raise_for_status()
|
| 24 |
data = resp.json()
|
| 25 |
results = data.get("results")
|
| 26 |
if not results:
|
| 27 |
raise ValueError(f"Location not found: {location!r}")
|
| 28 |
r = results[0]
|
|
|
|
| 29 |
name = f"{r['name']}, {r.get('admin1', '')}, {r.get('country', '')}".strip(", ")
|
| 30 |
return r["latitude"], r["longitude"], name
|
| 31 |
|
| 32 |
|
| 33 |
def _fetch_temperature(lat: float, lon: float, unit: str) -> dict:
|
| 34 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
unit_param = "fahrenheit" if unit == "fahrenheit" else "celsius"
|
| 36 |
resp = httpx.get(
|
| 37 |
"https://api.open-meteo.com/v1/forecast",
|
|
@@ -41,7 +99,7 @@ def _fetch_temperature(lat: float, lon: float, unit: str) -> dict:
|
|
| 41 |
"current": "temperature_2m,weathercode,wind_speed_10m",
|
| 42 |
"temperature_unit": unit_param,
|
| 43 |
"wind_speed_unit": "mph",
|
| 44 |
-
"timezone": "auto",
|
| 45 |
},
|
| 46 |
timeout=10,
|
| 47 |
)
|
|
@@ -49,7 +107,10 @@ def _fetch_temperature(lat: float, lon: float, unit: str) -> dict:
|
|
| 49 |
return resp.json()
|
| 50 |
|
| 51 |
|
| 52 |
-
# WMO
|
|
|
|
|
|
|
|
|
|
| 53 |
WMO_CODES = {
|
| 54 |
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
|
| 55 |
45: "Fog", 48: "Icy fog",
|
|
@@ -61,22 +122,39 @@ WMO_CODES = {
|
|
| 61 |
}
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
@mcp.tool()
|
| 65 |
def get_current_temperature(location: str, unit: str = "fahrenheit") -> str:
|
| 66 |
"""
|
| 67 |
Get the current temperature for any city or location.
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
Args:
|
| 70 |
location: City name or place (e.g. "Chicago", "Paris", "Tokyo").
|
| 71 |
unit: Temperature unit β "fahrenheit" or "celsius". Defaults to fahrenheit.
|
| 72 |
|
| 73 |
Returns:
|
| 74 |
-
A plain-text summary of current conditions
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
"""
|
| 76 |
unit = unit.lower()
|
| 77 |
if unit not in ("fahrenheit", "celsius"):
|
| 78 |
return f"Invalid unit {unit!r}. Use 'fahrenheit' or 'celsius'."
|
| 79 |
|
|
|
|
| 80 |
try:
|
| 81 |
lat, lon, resolved = _geocode(location)
|
| 82 |
except ValueError as e:
|
|
@@ -84,16 +162,18 @@ def get_current_temperature(location: str, unit: str = "fahrenheit") -> str:
|
|
| 84 |
except httpx.HTTPError as e:
|
| 85 |
return f"Geocoding request failed: {e}"
|
| 86 |
|
|
|
|
| 87 |
try:
|
| 88 |
data = _fetch_temperature(lat, lon, unit)
|
| 89 |
except httpx.HTTPError as e:
|
| 90 |
return f"Weather request failed: {e}"
|
| 91 |
|
|
|
|
| 92 |
current = data["current"]
|
| 93 |
temp = current["temperature_2m"]
|
| 94 |
wind = current["wind_speed_10m"]
|
| 95 |
code = current.get("weathercode", 0)
|
| 96 |
-
condition = WMO_CODES.get(code, f"Weather code {code}")
|
| 97 |
symbol = "Β°F" if unit == "fahrenheit" else "Β°C"
|
| 98 |
|
| 99 |
return (
|
|
@@ -104,11 +184,18 @@ def get_current_temperature(location: str, unit: str = "fahrenheit") -> str:
|
|
| 104 |
)
|
| 105 |
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
if __name__ == "__main__":
|
| 108 |
import os
|
|
|
|
|
|
|
|
|
|
| 109 |
transport = os.environ.get("MCP_TRANSPORT", "sse")
|
| 110 |
if transport == "stdio":
|
| 111 |
mcp.run(transport="stdio")
|
| 112 |
else:
|
| 113 |
-
# SSE β persistent server
|
| 114 |
mcp.run(transport="sse")
|
|
|
|
| 1 |
"""
|
| 2 |
+
weather_mcp_server.py β The weather tool server for this app.
|
| 3 |
+
|
| 4 |
+
WHAT THIS FILE DOES:
|
| 5 |
+
This file is a "tool server" β it doesn't talk to users directly. Instead,
|
| 6 |
+
it waits for api.py to ask it to do things, like "fetch the current
|
| 7 |
+
temperature in Tokyo." It does the work and sends the result back.
|
| 8 |
+
|
| 9 |
+
WHAT IS MCP (Model Context Protocol)?
|
| 10 |
+
MCP is a standard developed by Anthropic that defines how AI models can use
|
| 11 |
+
external tools. The problem it solves: AI models only know what they were
|
| 12 |
+
trained on β they can't look things up in real time. MCP lets you plug in
|
| 13 |
+
tools that the AI can call to get live data.
|
| 14 |
+
|
| 15 |
+
There are two sides to MCP:
|
| 16 |
+
- MCP Server (this file): defines and runs the tools
|
| 17 |
+
- MCP Client (api.py): connects to this server and calls the tools
|
| 18 |
+
on behalf of the AI
|
| 19 |
+
|
| 20 |
+
Think of it like a power outlet and a plug β MCP is the standard that
|
| 21 |
+
makes them compatible.
|
| 22 |
+
|
| 23 |
+
WHAT IS FastMCP?
|
| 24 |
+
FastMCP is a Python library that makes it easy to build MCP servers. Without
|
| 25 |
+
it, you'd have to implement the MCP protocol by hand. With it, you just write
|
| 26 |
+
normal Python functions and add a @mcp.tool() decorator, and FastMCP handles
|
| 27 |
+
all the communication details automatically.
|
| 28 |
+
|
| 29 |
+
HOW THE WEATHER DATA WORKS:
|
| 30 |
+
This app uses Open-Meteo (open-meteo.com), a free weather API that doesn't
|
| 31 |
+
require an account or API key. Getting weather for a city takes two steps:
|
| 32 |
+
1. Geocoding: convert the city name ("Tokyo") into coordinates (lat/lon)
|
| 33 |
+
2. Weather fetch: use those coordinates to get current conditions
|
| 34 |
+
|
| 35 |
+
HOW THIS SERVER COMMUNICATES:
|
| 36 |
+
This server uses SSE (Server-Sent Events) β a way for one server to talk to
|
| 37 |
+
another over HTTP. It runs on port 8000 and api.py connects to it there.
|
| 38 |
+
SSE is used instead of stdio (standard input/output) because both servers
|
| 39 |
+
run as separate processes inside Docker and need to communicate over the
|
| 40 |
+
network, not through a shared terminal.
|
| 41 |
"""
|
| 42 |
|
| 43 |
import httpx
|
| 44 |
from mcp.server.fastmcp import FastMCP
|
| 45 |
|
| 46 |
+
# Create the MCP server instance.
|
| 47 |
+
# "weather-server" is just a name that identifies this server.
|
| 48 |
+
# host/port tell it to listen on all network interfaces on port 8000.
|
| 49 |
mcp = FastMCP("weather-server", host="0.0.0.0", port=8000)
|
| 50 |
|
| 51 |
|
| 52 |
def _geocode(location: str) -> tuple[float, float, str]:
|
| 53 |
+
"""
|
| 54 |
+
Convert a city name into GPS coordinates (latitude and longitude).
|
| 55 |
+
|
| 56 |
+
AI models work with place names like "Tokyo" or "Paris", but weather APIs
|
| 57 |
+
need exact coordinates. This function bridges that gap by calling Open-Meteo's
|
| 58 |
+
free geocoding API.
|
| 59 |
+
|
| 60 |
+
Returns a tuple of (latitude, longitude, full_place_name).
|
| 61 |
+
For example: "Tokyo" β (35.6895, 139.6917, "Tokyo, Tokyo, Japan")
|
| 62 |
+
|
| 63 |
+
Raises ValueError if the location isn't found.
|
| 64 |
+
"""
|
| 65 |
resp = httpx.get(
|
| 66 |
"https://geocoding-api.open-meteo.com/v1/search",
|
| 67 |
params={"name": location, "count": 1, "language": "en", "format": "json"},
|
| 68 |
timeout=10,
|
| 69 |
)
|
| 70 |
+
resp.raise_for_status() # Raises an error if the HTTP request failed
|
| 71 |
data = resp.json()
|
| 72 |
results = data.get("results")
|
| 73 |
if not results:
|
| 74 |
raise ValueError(f"Location not found: {location!r}")
|
| 75 |
r = results[0]
|
| 76 |
+
# Build a full place name like "Chicago, Illinois, United States"
|
| 77 |
name = f"{r['name']}, {r.get('admin1', '')}, {r.get('country', '')}".strip(", ")
|
| 78 |
return r["latitude"], r["longitude"], name
|
| 79 |
|
| 80 |
|
| 81 |
def _fetch_temperature(lat: float, lon: float, unit: str) -> dict:
|
| 82 |
+
"""
|
| 83 |
+
Fetch current weather conditions from Open-Meteo using GPS coordinates.
|
| 84 |
+
|
| 85 |
+
Requests three pieces of data:
|
| 86 |
+
- temperature_2m: air temperature measured 2 meters above ground
|
| 87 |
+
- weathercode: a WMO standard code representing current conditions
|
| 88 |
+
(e.g. 0 = clear sky, 61 = slight rain)
|
| 89 |
+
- wind_speed_10m: wind speed measured 10 meters above ground
|
| 90 |
+
|
| 91 |
+
Returns the raw JSON response from Open-Meteo.
|
| 92 |
+
"""
|
| 93 |
unit_param = "fahrenheit" if unit == "fahrenheit" else "celsius"
|
| 94 |
resp = httpx.get(
|
| 95 |
"https://api.open-meteo.com/v1/forecast",
|
|
|
|
| 99 |
"current": "temperature_2m,weathercode,wind_speed_10m",
|
| 100 |
"temperature_unit": unit_param,
|
| 101 |
"wind_speed_unit": "mph",
|
| 102 |
+
"timezone": "auto", # Automatically detect the timezone for this location
|
| 103 |
},
|
| 104 |
timeout=10,
|
| 105 |
)
|
|
|
|
| 107 |
return resp.json()
|
| 108 |
|
| 109 |
|
| 110 |
+
# WMO (World Meteorological Organization) weather codes.
|
| 111 |
+
# Open-Meteo returns a numeric code for current conditions β this table
|
| 112 |
+
# translates those numbers into human-readable descriptions.
|
| 113 |
+
# This is a subset of the full WMO code table (codes go up to 99).
|
| 114 |
WMO_CODES = {
|
| 115 |
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
|
| 116 |
45: "Fog", 48: "Icy fog",
|
|
|
|
| 122 |
}
|
| 123 |
|
| 124 |
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
# The MCP tool β this is what the AI model can call
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
# @mcp.tool() is a "decorator" β it registers this function with the MCP server
|
| 130 |
+
# so that api.py can discover it and offer it to the AI model. Without this
|
| 131 |
+
# decorator, the function would just be a regular Python function that nothing
|
| 132 |
+
# outside this file knows about.
|
| 133 |
@mcp.tool()
|
| 134 |
def get_current_temperature(location: str, unit: str = "fahrenheit") -> str:
|
| 135 |
"""
|
| 136 |
Get the current temperature for any city or location.
|
| 137 |
|
| 138 |
+
This is the function the AI model calls when it needs weather data.
|
| 139 |
+
The AI passes in the location name and preferred unit, and gets back
|
| 140 |
+
a plain-text summary it can include in its response to the user.
|
| 141 |
+
|
| 142 |
Args:
|
| 143 |
location: City name or place (e.g. "Chicago", "Paris", "Tokyo").
|
| 144 |
unit: Temperature unit β "fahrenheit" or "celsius". Defaults to fahrenheit.
|
| 145 |
|
| 146 |
Returns:
|
| 147 |
+
A plain-text summary of current conditions, for example:
|
| 148 |
+
Current conditions in Tokyo, Tokyo, Japan:
|
| 149 |
+
Temperature: 68Β°F
|
| 150 |
+
Conditions: Partly cloudy
|
| 151 |
+
Wind speed: 12 mph
|
| 152 |
"""
|
| 153 |
unit = unit.lower()
|
| 154 |
if unit not in ("fahrenheit", "celsius"):
|
| 155 |
return f"Invalid unit {unit!r}. Use 'fahrenheit' or 'celsius'."
|
| 156 |
|
| 157 |
+
# Step 1: Convert the city name to GPS coordinates
|
| 158 |
try:
|
| 159 |
lat, lon, resolved = _geocode(location)
|
| 160 |
except ValueError as e:
|
|
|
|
| 162 |
except httpx.HTTPError as e:
|
| 163 |
return f"Geocoding request failed: {e}"
|
| 164 |
|
| 165 |
+
# Step 2: Fetch the current weather for those coordinates
|
| 166 |
try:
|
| 167 |
data = _fetch_temperature(lat, lon, unit)
|
| 168 |
except httpx.HTTPError as e:
|
| 169 |
return f"Weather request failed: {e}"
|
| 170 |
|
| 171 |
+
# Step 3: Extract the relevant fields from the response and format them
|
| 172 |
current = data["current"]
|
| 173 |
temp = current["temperature_2m"]
|
| 174 |
wind = current["wind_speed_10m"]
|
| 175 |
code = current.get("weathercode", 0)
|
| 176 |
+
condition = WMO_CODES.get(code, f"Weather code {code}") # Fall back to the raw code if unknown
|
| 177 |
symbol = "Β°F" if unit == "fahrenheit" else "Β°C"
|
| 178 |
|
| 179 |
return (
|
|
|
|
| 184 |
)
|
| 185 |
|
| 186 |
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
# Entry point β how this server starts up
|
| 189 |
+
# ---------------------------------------------------------------------------
|
| 190 |
+
|
| 191 |
if __name__ == "__main__":
|
| 192 |
import os
|
| 193 |
+
# MCP_TRANSPORT can be set to "stdio" for local testing (communicates via
|
| 194 |
+
# terminal input/output). In Docker/HuggingFace Spaces it defaults to "sse"
|
| 195 |
+
# so api.py can connect to it over the network.
|
| 196 |
transport = os.environ.get("MCP_TRANSPORT", "sse")
|
| 197 |
if transport == "stdio":
|
| 198 |
mcp.run(transport="stdio")
|
| 199 |
else:
|
| 200 |
+
# SSE (Server-Sent Events) β runs as a persistent HTTP server on port 8000
|
| 201 |
mcp.run(transport="sse")
|