auth token
Browse files- echo_server.py +129 -4
- server.py +1 -1
echo_server.py
CHANGED
@@ -1,8 +1,133 @@
|
|
1 |
from mcp.server.fastmcp import FastMCP
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
-
|
|
|
|
|
|
|
4 |
|
|
|
|
|
5 |
|
6 |
-
|
7 |
-
|
8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
from mcp.server.fastmcp import FastMCP
|
2 |
+
from typing import Optional, Literal
|
3 |
+
import httpx
|
4 |
+
import os
|
5 |
+
import re
|
6 |
+
from contextvars import ContextVar
|
7 |
|
8 |
+
try:
|
9 |
+
from fastmcp.server.auth.verifier import TokenVerifier # type: ignore
|
10 |
+
except Exception: # pragma: no cover - fallback for alternate installs
|
11 |
+
TokenVerifier = object # type: ignore
|
12 |
|
13 |
+
# Context variable to store the verified bearer token per-request
|
14 |
+
_current_bearer_token: ContextVar[Optional[str]] = ContextVar("current_bearer_token", default=None)
|
15 |
|
16 |
+
|
17 |
+
class OpenWeatherTokenVerifier(TokenVerifier): # type: ignore[misc]
|
18 |
+
"""Minimal verifier that accepts an OpenWeather API key as the bearer token.
|
19 |
+
|
20 |
+
For safety, we perform a lightweight format check (32 hex chars typical for OpenWeather keys).
|
21 |
+
On success, we stash the token in a contextvar and return claims including it.
|
22 |
+
"""
|
23 |
+
|
24 |
+
_hex32 = re.compile(r"^[a-fA-F0-9]{32}$")
|
25 |
+
|
26 |
+
def verify_token(self, token: str) -> dict: # type: ignore[override]
|
27 |
+
if not token:
|
28 |
+
raise ValueError("Missing bearer token")
|
29 |
+
# Basic format validation (doesn't call OpenWeather)
|
30 |
+
if not self._hex32.match(token):
|
31 |
+
# Allow override via env to support alternative key formats if needed
|
32 |
+
allow_any = os.getenv("OPENWEATHER_ALLOW_ANY_TOKEN", "false").lower() in {"1", "true", "yes"}
|
33 |
+
if not allow_any:
|
34 |
+
raise ValueError("Invalid OpenWeather token format; expected 32 hex characters")
|
35 |
+
_current_bearer_token.set(token)
|
36 |
+
return {"auth_type": "openweather", "openweather_token": token}
|
37 |
+
|
38 |
+
|
39 |
+
mcp = FastMCP(name="OpenWeatherServer", stateless_http=True, auth=OpenWeatherTokenVerifier())
|
40 |
+
|
41 |
+
BASE_URL = "https://api.openweathermap.org"
|
42 |
+
DEFAULT_TIMEOUT_SECONDS = 15.0
|
43 |
+
|
44 |
+
|
45 |
+
def _require_token() -> str:
|
46 |
+
# Priority: verified bearer token → env var → error
|
47 |
+
token = _current_bearer_token.get()
|
48 |
+
if token:
|
49 |
+
return token
|
50 |
+
env_token = os.getenv("OPENWEATHER_API_KEY")
|
51 |
+
if env_token:
|
52 |
+
return env_token
|
53 |
+
raise ValueError(
|
54 |
+
"OpenWeather token required. Provide as HTTP Authorization: Bearer <token> or set OPENWEATHER_API_KEY."
|
55 |
+
)
|
56 |
+
|
57 |
+
|
58 |
+
def _get(path: str, params: dict) -> dict:
|
59 |
+
try:
|
60 |
+
with httpx.Client(timeout=httpx.Timeout(DEFAULT_TIMEOUT_SECONDS)) as client:
|
61 |
+
resp = client.get(f"{BASE_URL}{path}", params=params)
|
62 |
+
resp.raise_for_status()
|
63 |
+
return resp.json()
|
64 |
+
except httpx.HTTPStatusError as e:
|
65 |
+
status = e.response.status_code if e.response is not None else None
|
66 |
+
text = e.response.text if e.response is not None else str(e)
|
67 |
+
raise ValueError(f"OpenWeather API error (status={status}): {text}")
|
68 |
+
except Exception as e:
|
69 |
+
raise RuntimeError(f"OpenWeather request failed: {e}")
|
70 |
+
|
71 |
+
|
72 |
+
@mcp.tool(description="Get current weather using One Call 3.0 (current segment). Token is taken from Authorization bearer or OPENWEATHER_API_KEY env.")
|
73 |
+
def current_weather(
|
74 |
+
lat: float,
|
75 |
+
lon: float,
|
76 |
+
units: Literal["standard", "metric", "imperial"] = "metric",
|
77 |
+
lang: str = "en",
|
78 |
+
) -> dict:
|
79 |
+
token = _require_token()
|
80 |
+
params = {
|
81 |
+
"lat": lat,
|
82 |
+
"lon": lon,
|
83 |
+
"appid": token,
|
84 |
+
"units": units,
|
85 |
+
"lang": lang,
|
86 |
+
"exclude": "minutely,hourly,daily,alerts",
|
87 |
+
}
|
88 |
+
data = _get("/data/3.0/onecall", params)
|
89 |
+
return {
|
90 |
+
"coord": {"lat": lat, "lon": lon},
|
91 |
+
"units": units,
|
92 |
+
"lang": lang,
|
93 |
+
"current": data.get("current", data),
|
94 |
+
}
|
95 |
+
|
96 |
+
|
97 |
+
@mcp.tool(description="Get One Call 3.0 data for coordinates. Use 'exclude' to omit segments (comma-separated). Token comes from bearer or env.")
|
98 |
+
def onecall(
|
99 |
+
lat: float,
|
100 |
+
lon: float,
|
101 |
+
exclude: Optional[str] = None,
|
102 |
+
units: Literal["standard", "metric", "imperial"] = "metric",
|
103 |
+
lang: str = "en",
|
104 |
+
) -> dict:
|
105 |
+
token = _require_token()
|
106 |
+
params = {
|
107 |
+
"lat": lat,
|
108 |
+
"lon": lon,
|
109 |
+
"appid": token,
|
110 |
+
"units": units,
|
111 |
+
"lang": lang,
|
112 |
+
}
|
113 |
+
if exclude:
|
114 |
+
params["exclude"] = exclude
|
115 |
+
return _get("/data/3.0/onecall", params)
|
116 |
+
|
117 |
+
|
118 |
+
@mcp.tool(description="Get 5-day/3-hour forecast by city name. Token comes from bearer or env.")
|
119 |
+
def forecast_city(
|
120 |
+
city: str,
|
121 |
+
units: Literal["standard", "metric", "imperial"] = "metric",
|
122 |
+
lang: str = "en",
|
123 |
+
) -> dict:
|
124 |
+
token = _require_token()
|
125 |
+
params = {"q": city, "appid": token, "units": units, "lang": lang}
|
126 |
+
return _get("/data/2.5/forecast", params)
|
127 |
+
|
128 |
+
|
129 |
+
@mcp.tool(description="Get air pollution data for coordinates. Token comes from bearer or env.")
|
130 |
+
def air_pollution(lat: float, lon: float) -> dict:
|
131 |
+
token = _require_token()
|
132 |
+
params = {"lat": lat, "lon": lon, "appid": token}
|
133 |
+
return _get("/data/2.5/air_pollution", params)
|
server.py
CHANGED
@@ -42,7 +42,7 @@ async def index(request: Request):
|
|
42 |
app.mount("/echo", echo_mcp.streamable_http_app())
|
43 |
app.mount("/math", math_mcp.streamable_http_app())
|
44 |
|
45 |
-
PORT = int(os.environ.get("PORT", "
|
46 |
|
47 |
if __name__ == "__main__":
|
48 |
import uvicorn
|
|
|
42 |
app.mount("/echo", echo_mcp.streamable_http_app())
|
43 |
app.mount("/math", math_mcp.streamable_http_app())
|
44 |
|
45 |
+
PORT = int(os.environ.get("PORT", "7860"))
|
46 |
|
47 |
if __name__ == "__main__":
|
48 |
import uvicorn
|