File size: 12,706 Bytes
26d2110
7f46631
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26d2110
 
 
 
 
 
 
5bf115d
26d2110
5bf115d
26d2110
 
5bf115d
26d2110
 
7f46631
26d2110
 
7f46631
 
26d2110
7f46631
 
 
26d2110
7f46631
 
 
26d2110
 
7f46631
 
26d2110
 
 
 
b2eeebf
26d2110
 
 
7f46631
26d2110
 
5bf115d
7f46631
 
26d2110
 
 
7f46631
 
 
 
26d2110
 
 
 
 
 
5bf115d
 
 
26d2110
 
7f46631
26d2110
 
 
7f46631
26d2110
7f46631
 
26d2110
 
 
7f46631
26d2110
 
5bf115d
26d2110
7f46631
 
 
 
 
 
26d2110
 
 
 
7f46631
26d2110
 
 
 
 
 
 
 
 
 
 
 
 
 
7f46631
 
 
 
 
 
 
26d2110
 
 
 
7f46631
26d2110
 
 
 
5bf115d
26d2110
7f46631
26d2110
 
5bf115d
26d2110
7f46631
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26d2110
7f46631
 
26d2110
 
7f46631
 
26d2110
 
5bf115d
26d2110
 
7f46631
26d2110
 
5bf115d
7f46631
5bf115d
 
26d2110
 
 
 
 
7f46631
26d2110
7f46631
5bf115d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26d2110
7f46631
26d2110
 
5bf115d
 
 
26d2110
5bf115d
 
 
 
 
 
 
26d2110
7f46631
26d2110
 
 
 
 
 
 
 
 
 
7f46631
26d2110
 
5bf115d
26d2110
7f46631
26d2110
 
7f46631
 
 
5bf115d
26d2110
5bf115d
 
26d2110
 
5bf115d
 
26d2110
7f46631
 
 
26d2110
5bf115d
26d2110
 
7f46631
26d2110
 
 
 
 
7f46631
 
 
 
 
 
 
 
 
 
26d2110
 
 
 
 
 
 
7f46631
26d2110
 
7f46631
 
26d2110
 
5bf115d
26d2110
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
"""
api.py  β€”  The main web server for this weather assistant app.

HOW THIS APP WORKS:
    When someone sends a question like "What's the weather in Tokyo?", this file
    handles it end-to-end:

      1. Receives the question via HTTP (like a web form submission)
      2. Passes it to an AI language model (Qwen2.5) running on HuggingFace
      3. The AI decides it needs real weather data, so it asks for the
         get_current_temperature tool to be called
      4. This server calls that tool (defined in weather_mcp_server.py)
      5. The tool fetches live weather from the internet
      6. The result is sent back to the AI, which writes a natural response
      7. That response is returned to whoever asked the question

WHAT IS FastAPI?
    FastAPI is a Python library for building web servers. It lets you define
    "endpoints" β€” URLs that accept requests and return responses. Think of it
    like building a simple API that other apps or curl commands can talk to.

WHAT IS MCP (Model Context Protocol)?
    MCP is a standard way for AI models to use external tools. Instead of the
    AI just generating text, it can say "I need to call this tool with these
    arguments." This server listens for those requests and executes the tools.
    weather_mcp_server.py defines the tools; this file calls them.

WHAT IS HuggingFace?
    HuggingFace is a platform that hosts AI models and lets you run them via
    their API. This app uses their "Inference API" to run the Qwen2.5 language
    model without needing to host it ourselves.

ENDPOINTS (URLs this server responds to):
    POST /ask    Send a question, get an answer
                 Request:  { "question": "What's the temp in Tokyo?" }
                 Response: { "answer": "It's currently 72Β°F and sunny in Tokyo..." }

    GET  /health Just checks that the server is running
                 Response: { "status": "ok" }

ENVIRONMENT VARIABLES (settings loaded from the environment, not hardcoded):
    HF_TOKEN        Your HuggingFace API token β€” needed to use the AI model
    MCP_SERVER_URL  Where the weather tool server is running
                    (defaults to http://localhost:8000/sse)
"""

import asyncio
import json
import os
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from huggingface_hub import InferenceClient
from mcp import ClientSession
from mcp.client.sse import sse_client
from pydantic import BaseModel

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

# The AI model we're using. Qwen2.5 is an open-source language model made by
# Alibaba, hosted for free on HuggingFace.
MODEL = "Qwen/Qwen2.5-7B-Instruct"

# Your HuggingFace token, loaded from an environment variable (not hardcoded
# here for security reasons).
HF_TOKEN = os.environ.get("HF_TOKEN")

# The URL of the weather tool server (weather_mcp_server.py). SSE (Server-Sent
# Events) is the communication protocol they use to talk to each other.
MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://localhost:8000/sse")

# This is the instruction we give the AI at the start of every conversation.
# It tells the AI what its job is and how to behave.
SYSTEM_PROMPT = (
    "You are a helpful assistant with access to real-time weather data. "
    "When the user asks about temperature or weather, always use the "
    "get_current_temperature tool to fetch live data before answering. "
    "Present results conversationally. If the user asks Any other question, tell them you only 'answer weather questions' And refuse to answer the question   "
)

# ---------------------------------------------------------------------------
# Startup: wait for the weather tool server to be ready
# ---------------------------------------------------------------------------


# This function runs when the web server first starts up. FastAPI calls it
# automatically before accepting any requests.
@asynccontextmanager
async def lifespan(app: FastAPI):
    print("Waiting for MCP server to be ready...", flush=True)

    # Try up to 20 times (once per second) to connect to the weather tool
    # server. Both servers start at the same time, so this gives the tool
    # server time to finish booting before we start accepting requests.
    for _ in range(20):
        try:
            async with sse_client(MCP_SERVER_URL) as (read, write):
                async with ClientSession(read, write) as session:
                    await session.initialize()
                    tools = await session.list_tools()
                    print(
                        f"MCP ready β€” {len(tools.tools)} tool(s) available", flush=True
                    )
                    break
        except Exception:
            await asyncio.sleep(1)  # Wait 1 second before trying again
    else:
        print("WARNING: MCP server did not become ready in time", flush=True)

    yield  # Hand control back to FastAPI β€” start accepting requests


# Create the FastAPI web server instance.
app = FastAPI(title="Weather via MCP + Qwen2.5", lifespan=lifespan)

# ---------------------------------------------------------------------------
# Functions for talking to the weather tool server (MCP)
# ---------------------------------------------------------------------------


async def fetch_mcp_tools() -> list[dict]:
    """
    Ask the weather tool server what tools it has available.

    Returns a list of tool definitions formatted the way the AI model expects
    them β€” including the tool's name, what it does, and what arguments it takes.
    """
    async with sse_client(MCP_SERVER_URL) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools_result = await session.list_tools()
            # Reformat each tool into the structure the AI model expects
            return [
                {
                    "type": "function",
                    "function": {
                        "name": t.name,
                        "description": t.description,
                        "parameters": t.inputSchema,
                    },
                }
                for t in tools_result.tools
            ]


async def call_mcp_tool(tool_name: str, tool_args: dict) -> str:
    """
    Call a specific tool on the weather tool server and return its result.

    For example: call_mcp_tool("get_current_temperature", {"location": "Tokyo"})
    connects to weather_mcp_server.py, runs that function, and returns the
    weather data as a string.
    """
    async with sse_client(MCP_SERVER_URL) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            result = await session.call_tool(tool_name, tool_args)
            # The result may contain multiple content blocks; join them into one string
            return "\n".join(
                block.text for block in result.content if hasattr(block, "text")
            )


# ---------------------------------------------------------------------------
# The agentic loop β€” the core logic of this app
# ---------------------------------------------------------------------------


async def run(user_message: str) -> str:
    """
    Send a user's question to the AI and return its final answer.

    This is called an "agentic loop" because the AI can go back and forth
    multiple times β€” asking for tool results, getting them, then deciding
    whether to ask for more or give a final answer.

    Typical flow:
      1. We send: [system prompt] + [user question] + [available tools]
      2. AI responds: "I need to call get_current_temperature for Tokyo"
      3. We call that tool and get the weather data
      4. We send the weather data back to the AI
      5. AI responds with a natural language answer β€” we return that
    """
    # Create a client that can talk to HuggingFace's AI inference API
    client = InferenceClient(model=MODEL, token=HF_TOKEN)

    # Get the list of available tools from the weather server
    tools = await fetch_mcp_tools()

    # Build the conversation history. The AI sees this as a back-and-forth chat.
    # "system" sets the AI's behavior; "user" is the human's message.
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_message},
    ]

    # Send the conversation to the AI model and get its first response
    response = client.chat.completions.create(
        messages=messages,
        tools=tools,  # Tell the AI what tools it can use
        tool_choice="auto",  # Let the AI decide whether to use a tool
        max_tokens=512,  # Maximum length of the AI's response
        temperature=0.2,  # Low temperature = more focused, less random responses
    )

    choice = response.choices[0]
    assistant_msg = choice.message

    # If the AI wants to call a tool, handle it and loop back
    while choice.finish_reason == "tool_calls" and assistant_msg.tool_calls:
        # Add the AI's tool request to the conversation history
        messages.append(
            {
                "role": "assistant",
                "content": assistant_msg.content or "",
                "tool_calls": [
                    {
                        "id": tc.id,
                        "type": "function",
                        "function": {
                            "name": tc.function.name,
                            "arguments": tc.function.arguments,
                        },
                    }
                    for tc in assistant_msg.tool_calls
                ],
            }
        )

        # Execute each tool the AI requested and add results to the conversation
        for tc in assistant_msg.tool_calls:
            fn_name = tc.function.name
            fn_args = json.loads(
                tc.function.arguments
            )  # Parse the JSON arguments string
            tool_result = await call_mcp_tool(fn_name, fn_args)
            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": tc.id,  # Links this result to the specific tool call
                    "content": tool_result,
                }
            )

        # Send the updated conversation (now including tool results) back to the AI
        response = client.chat.completions.create(
            messages=messages,
            tools=tools,
            tool_choice="auto",
            max_tokens=512,
            temperature=0.2,
        )
        choice = response.choices[0]
        assistant_msg = choice.message

    # The AI has finished β€” return its final text response
    return assistant_msg.content or "(no response)"


# ---------------------------------------------------------------------------
# Request/response shapes
# ---------------------------------------------------------------------------

# These classes define the exact structure of data that goes in and out of
# the /ask endpoint. FastAPI uses them to validate requests and format responses.


class AskRequest(BaseModel):
    question: str  # The user's weather question


class AskResponse(BaseModel):
    answer: str  # The AI's response


# ---------------------------------------------------------------------------
# Endpoints (URLs the server responds to)
# ---------------------------------------------------------------------------


@app.get("/health")
async def health():
    """Simple health check β€” returns OK if the server is running."""
    return {"status": "ok"}


@app.post("/ask", response_model=AskResponse)
async def ask(req: AskRequest):
    """
    Main endpoint β€” accepts a weather question and returns an AI-generated answer.

    Example request:
        POST /ask
        { "question": "What's the temperature in Paris?" }

    Example response:
        { "answer": "It's currently 58Β°F and partly cloudy in Paris, France." }
    """
    if not req.question.strip():
        raise HTTPException(status_code=400, detail="question must not be empty")
    answer = await run(req.question)
    return AskResponse(answer=answer)


# ---------------------------------------------------------------------------
# Entry point (only used when running locally, not in Docker)
# ---------------------------------------------------------------------------

# This block only runs if you start the file directly: `python api.py`
# In Docker/HuggingFace Spaces, supervisord starts uvicorn directly instead.
if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=7860)