From 50239a954f25e647911637c3bf456c5a2d16f39d Mon Sep 17 00:00:00 2001 From: Se7en Date: Thu, 9 Jan 2025 22:58:23 +0800 Subject: [PATCH] Update python quickstart to use FastMCP API --- weather-server-python/pyproject.toml | 4 +- weather-server-python/src/weather/__init__.py | 9 - weather-server-python/src/weather/server.py | 203 ------------------ weather-server-python/uv.lock | 110 +++++++++- weather-server-python/weather.py | 94 ++++++++ 5 files changed, 200 insertions(+), 220 deletions(-) delete mode 100644 weather-server-python/src/weather/__init__.py delete mode 100644 weather-server-python/src/weather/server.py create mode 100644 weather-server-python/weather.py diff --git a/weather-server-python/pyproject.toml b/weather-server-python/pyproject.toml index 926d7f7..6500a62 100644 --- a/weather-server-python/pyproject.toml +++ b/weather-server-python/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "weather" version = "0.1.0" -description = "Add your description here" +description = "A simple MCP weather server" readme = "README.md" requires-python = ">=3.10" dependencies = [ "httpx>=0.28.1", - "mcp>=1.1.1", + "mcp[cli]>=1.2.0", ] [build-system] diff --git a/weather-server-python/src/weather/__init__.py b/weather-server-python/src/weather/__init__.py deleted file mode 100644 index 50073fa..0000000 --- a/weather-server-python/src/weather/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from . import server -import asyncio - -def main(): - """Main entry point for the package.""" - asyncio.run(server.main()) - -# Optionally expose other important items at package level -__all__ = ['main', 'server'] diff --git a/weather-server-python/src/weather/server.py b/weather-server-python/src/weather/server.py deleted file mode 100644 index d222ff7..0000000 --- a/weather-server-python/src/weather/server.py +++ /dev/null @@ -1,203 +0,0 @@ -from typing import Any - -import httpx -from mcp.server.models import InitializationOptions -import mcp.types as types -from mcp.server import NotificationOptions, Server -import mcp.server.stdio -import asyncio -NWS_API_BASE = "https://api.weather.gov" -USER_AGENT = "weather-app/1.0" - -server = Server("weather") -@server.list_tools() -async def handle_list_tools() -> list[types.Tool]: - """ - List available tools. - Each tool specifies its arguments using JSON Schema validation. - """ - return [ - types.Tool( - name="get-alerts", - description="Get weather alerts for a state", - inputSchema={ - "type": "object", - "properties": { - "state": { - "type": "string", - "description": "Two-letter state code (e.g. CA, NY)", - }, - }, - "required": ["state"], - }, - ), - types.Tool( - name="get-forecast", - description="Get weather forecast for a location", - inputSchema={ - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "Latitude of the location", - }, - "longitude": { - "type": "number", - "description": "Longitude of the location", - }, - }, - "required": ["latitude", "longitude"], - }, - ), - ] - -async def make_nws_request(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None: - """Make a request to the NWS API with proper error handling.""" - headers = { - "User-Agent": USER_AGENT, - "Accept": "application/geo+json" - } - - try: - response = await client.get(url, headers=headers, timeout=30.0) - response.raise_for_status() - return response.json() - except Exception: - return None - -def format_alert(feature: dict) -> str: - """Format an alert feature into a concise string.""" - props = feature["properties"] - return ( - f"Event: {props.get('event', 'Unknown')}\n" - f"Area: {props.get('areaDesc', 'Unknown')}\n" - f"Severity: {props.get('severity', 'Unknown')}\n" - f"Status: {props.get('status', 'Unknown')}\n" - f"Headline: {props.get('headline', 'No headline')}\n" - "---" - ) -@server.call_tool() -async def handle_call_tool( - name: str, arguments: dict | None -) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - """ - Handle tool execution requests. - Tools can fetch weather data and notify clients of changes. - """ - if not arguments: - raise ValueError("Missing arguments") - - if name == "get-alerts": - state = arguments.get("state") - if not state: - raise ValueError("Missing state parameter") - - # Convert state to uppercase to ensure consistent format - state = state.upper() - if len(state) != 2: - raise ValueError("State must be a two-letter code (e.g. CA, NY)") - - async with httpx.AsyncClient() as client: - alerts_url = f"{NWS_API_BASE}/alerts?area={state}" - alerts_data = await make_nws_request(client, alerts_url) - - if not alerts_data: - return [types.TextContent(type="text", text="Failed to retrieve alerts data")] - - features = alerts_data.get("features", []) - if not features: - return [types.TextContent(type="text", text=f"No active alerts for {state}")] - - # Format each alert into a concise string - formatted_alerts = [format_alert(feature) for feature in features] - alerts_text = f"Active alerts for {state}:\n\n" + "\n".join(formatted_alerts) - - return [ - types.TextContent( - type="text", - text=alerts_text - ) - ] - elif name == "get-forecast": - try: - latitude = float(arguments.get("latitude")) - longitude = float(arguments.get("longitude")) - except (TypeError, ValueError): - return [types.TextContent( - type="text", - text="Invalid coordinates. Please provide valid numbers for latitude and longitude." - )] - - # Basic coordinate validation - if not (-90 <= latitude <= 90) or not (-180 <= longitude <= 180): - return [types.TextContent( - type="text", - text="Invalid coordinates. Latitude must be between -90 and 90, longitude between -180 and 180." - )] - - async with httpx.AsyncClient() as client: - # First get the grid point - lat_str = f"{latitude}" - lon_str = f"{longitude}" - points_url = f"{NWS_API_BASE}/points/{lat_str},{lon_str}" - points_data = await make_nws_request(client, points_url) - - if not points_data: - return [types.TextContent(type="text", text=f"Failed to retrieve grid point data for coordinates: {latitude}, {longitude}. This location may not be supported by the NWS API (only US locations are supported).")] - - # Extract forecast URL from the response - properties = points_data.get("properties", {}) - forecast_url = properties.get("forecast") - - if not forecast_url: - return [types.TextContent(type="text", text="Failed to get forecast URL from grid point data")] - - # Get the forecast - forecast_data = await make_nws_request(client, forecast_url) - - if not forecast_data: - return [types.TextContent(type="text", text="Failed to retrieve forecast data")] - - # Format the forecast periods - periods = forecast_data.get("properties", {}).get("periods", []) - if not periods: - return [types.TextContent(type="text", text="No forecast periods available")] - - # Format each period into a concise string - formatted_forecast = [] - for period in periods: - forecast_text = ( - f"{period.get('name', 'Unknown')}:\n" - f"Temperature: {period.get('temperature', 'Unknown')}°{period.get('temperatureUnit', 'F')}\n" - f"Wind: {period.get('windSpeed', 'Unknown')} {period.get('windDirection', '')}\n" - f"{period.get('shortForecast', 'No forecast available')}\n" - "---" - ) - formatted_forecast.append(forecast_text) - - forecast_text = f"Forecast for {latitude}, {longitude}:\n\n" + "\n".join(formatted_forecast) - - return [types.TextContent( - type="text", - text=forecast_text - )] - else: - raise ValueError(f"Unknown tool: {name}") -async def main(): - # Run the server using stdin/stdout streams - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="weather", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/weather-server-python/uv.lock b/weather-server-python/uv.lock index 91211e0..605fe97 100644 --- a/weather-server-python/uv.lock +++ b/weather-server-python/uv.lock @@ -39,7 +39,7 @@ name = "click" version = "8.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } wheels = [ @@ -119,21 +119,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + [[package]] name = "mcp" -version = "1.1.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx" }, { name = "httpx-sse" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "sse-starlette" }, { name = "starlette" }, + { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/d5/15b9ff17c9e03bb6d936a0e1202aca69e3fa93508e749e620ae4f4fbd884/mcp-1.1.1.tar.gz", hash = "sha256:44d9c12461b640c4431618a3ebf1be4178d35511e9169e25c9b1afeb59313b99", size = 57790 } +sdist = { url = "https://files.pythonhosted.org/packages/ab/a5/b08dc846ebedae9f17ced878e6975826e90e448cd4592f532f6a88a925a7/mcp-1.2.0.tar.gz", hash = "sha256:2b06c7ece98d6ea9e6379caa38d74b432385c338fb530cb82e2c70ea7add94f5", size = 102973 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/30/dcffd09aa623b5a40e8ccb4227e935e4636cc4bec8d54ef9609768c24a53/mcp-1.1.1-py3-none-any.whl", hash = "sha256:29f693a54ca1d3730e625dcc06732bc83b52d64b993d253881de576a1d66feed", size = 36574 }, + { url = "https://files.pythonhosted.org/packages/af/84/fca78f19ac8ce6c53ba416247c71baa53a9e791e98d3c81edbc20a77d6d1/mcp-1.2.0-py3-none-any.whl", hash = "sha256:1d0e77d8c14955a5aea1f5aa1f444c8e531c09355c829b20e42f7a142bc0755f", size = 66468 }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] [[package]] @@ -225,6 +254,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 }, ] +[[package]] +name = "pydantic-settings" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -260,6 +343,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225 }, ] +[[package]] +name = "typer" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -289,11 +387,11 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, - { name = "mcp" }, + { name = "mcp", extra = ["cli"] }, ] [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", specifier = ">=1.1.1" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.2.0" }, ] diff --git a/weather-server-python/weather.py b/weather-server-python/weather.py new file mode 100644 index 0000000..74a3015 --- /dev/null +++ b/weather-server-python/weather.py @@ -0,0 +1,94 @@ +from typing import Any +import httpx +from mcp.server.fastmcp import FastMCP + +# Initialize FastMCP server +mcp = FastMCP("weather") + +# Constants +NWS_API_BASE = "https://api.weather.gov" +USER_AGENT = "weather-app/1.0" + +async def make_nws_request(url: str) -> dict[str, Any] | None: + """Make a request to the NWS API with proper error handling.""" + headers = { + "User-Agent": USER_AGENT, + "Accept": "application/geo+json" + } + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, timeout=30.0) + response.raise_for_status() + return response.json() + except Exception: + return None + +def format_alert(feature: dict) -> str: + """Format an alert feature into a readable string.""" + props = feature["properties"] + return f""" +Event: {props.get('event', 'Unknown')} +Area: {props.get('areaDesc', 'Unknown')} +Severity: {props.get('severity', 'Unknown')} +Description: {props.get('description', 'No description available')} +Instructions: {props.get('instruction', 'No specific instructions provided')} +""" + +@mcp.tool() +async def get_alerts(state: str) -> str: + """Get weather alerts for a US state. + + Args: + state: Two-letter US state code (e.g. CA, NY) + """ + url = f"{NWS_API_BASE}/alerts/active/area/{state}" + data = await make_nws_request(url) + + if not data or "features" not in data: + return "Unable to fetch alerts or no alerts found." + + if not data["features"]: + return "No active alerts for this state." + + alerts = [format_alert(feature) for feature in data["features"]] + return "\n---\n".join(alerts) + +@mcp.tool() +async def get_forecast(latitude: float, longitude: float) -> str: + """Get weather forecast for a location. + + Args: + latitude: Latitude of the location + longitude: Longitude of the location + """ + # First get the forecast grid endpoint + points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}" + points_data = await make_nws_request(points_url) + + if not points_data: + return "Unable to fetch forecast data for this location." + + # Get the forecast URL from the points response + forecast_url = points_data["properties"]["forecast"] + forecast_data = await make_nws_request(forecast_url) + + if not forecast_data: + return "Unable to fetch detailed forecast." + + # Format the periods into a readable forecast + periods = forecast_data["properties"]["periods"] + forecasts = [] + for period in periods[:5]: # Only show next 5 periods + forecast = f""" +{period['name']}: +Temperature: {period['temperature']}°{period['temperatureUnit']} +Wind: {period['windSpeed']} {period['windDirection']} +Forecast: {period['detailedForecast']} +""" + forecasts.append(forecast) + + return "\n---\n".join(forecasts) + +if __name__ == "__main__": + # Initialize and run the server + mcp.run(transport='stdio')