diff --git a/mcp-client-python/README.md b/mcp-client-python/README.md index e9b58c2b..d8ba6ece 100644 --- a/mcp-client-python/README.md +++ b/mcp-client-python/README.md @@ -1,3 +1,13 @@ # An LLM-Powered Chatbot MCP Client written in Python See the [Build an MCP client](https://modelcontextprotocol.io/docs/develop/build-client) tutorial for more information. + +## Structured output + +`call_tool` validates every result against the tool's declared output schema, so the spec's client-side SHOULD needs no code here. + +The two channels go to different readers: `content` is forwarded to the model, while `structured_content` is used as data — the client counts the items it returns. See [Structured Content](https://modelcontextprotocol.io/specification/draft/server/tools#structured-content). + +`Client(transport, mode="auto")` probes `server/discover` and falls back to the `2025-11-25` handshake; `client.protocol_version` reports which era you got. See [Protocol versions](https://py.sdk.modelcontextprotocol.io/v2/protocol-versions/). + +Requires the `mcp` 2.0 prereleases, so `pyproject.toml` sets `[tool.uv] prerelease = "allow"`. diff --git a/mcp-client-python/client.py b/mcp-client-python/client.py index 44d5d5da..538ff6b9 100644 --- a/mcp-client-python/client.py +++ b/mcp-client-python/client.py @@ -5,20 +5,21 @@ from anthropic import Anthropic from dotenv import load_dotenv -from mcp import ClientSession, StdioServerParameters +from mcp import Client, StdioServerParameters from mcp.client.stdio import stdio_client +from mcp_types import TextContent load_dotenv() # load environment variables from .env # Claude model constant -ANTHROPIC_MODEL = "claude-sonnet-4-5" +ANTHROPIC_MODEL = "claude-sonnet-5" MAX_TOOL_TURNS = 10 class MCPClient: def __init__(self): # Initialize session and client objects - self.session: ClientSession | None = None + self.client: Client | None = None self.exit_stack = AsyncExitStack() self._anthropic: Anthropic | None = None @@ -50,24 +51,23 @@ async def connect_to_server(self, server_script_path: str): else: server_params = StdioServerParameters(command="node", args=[server_script_path], env=None) - stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) - self.stdio, self.write = stdio_transport - self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write)) - - await self.session.initialize() + # "auto" probes server/discover, falling back to the 2025-11-25 handshake. + self.client = await self.exit_stack.enter_async_context( + Client(stdio_client(server_params), mode="auto") + ) # List available tools - response = await self.session.list_tools() + response = await self.client.list_tools() tools = response.tools - print("\nConnected to server with tools:", [tool.name for tool in tools]) + print(f"\nConnected over protocol {self.client.protocol_version} with tools:", [tool.name for tool in tools]) async def process_query(self, query: str) -> str: """Process a query using Claude and available tools""" messages = [{"role": "user", "content": query}] - tools_response = await self.session.list_tools() + tools_response = await self.client.list_tools() available_tools = [ - {"name": tool.name, "description": tool.description, "input_schema": tool.inputSchema} + {"name": tool.name, "description": tool.description, "input_schema": tool.input_schema} for tool in tools_response.tools ] @@ -90,13 +90,23 @@ async def process_query(self, query: str) -> str: tool_results = [] for tool_use in tool_uses: - result = await self.session.call_tool(tool_use.name, tool_use.input) + # call_tool validates the result against the declared schema. + result = await self.client.call_tool(tool_use.name, tool_use.input) final_text.append(f"[Calling tool {tool_use.name} with args {tool_use.input}]") + + # structured_content is data the application can use directly. + if isinstance(result.structured_content, list): + final_text.append(f"[{tool_use.name} returned {len(result.structured_content)} items]") + + # content is a list of block types; forward only the text ones. tool_results.append( { "type": "tool_result", "tool_use_id": tool_use.id, - "content": result.content, + "content": "\n".join( + block.text for block in result.content if isinstance(block, TextContent) + ), + "is_error": bool(result.is_error), } ) @@ -119,8 +129,9 @@ async def chat_loop(self): print("Type your queries or 'quit' to exit.") while True: + # input() blocks, so keep it off the event loop. try: - query = input("\nQuery: ").strip() + query = (await asyncio.to_thread(input, "\nQuery: ")).strip() except (EOFError, KeyboardInterrupt): break diff --git a/mcp-client-python/pyproject.toml b/mcp-client-python/pyproject.toml index d7c14d48..4d23827c 100644 --- a/mcp-client-python/pyproject.toml +++ b/mcp-client-python/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "anthropic>=0.87.0", - "mcp>=1.28.1", + "mcp>=2.0.0b2", "python-dotenv>=1.2.2", ] @@ -15,6 +15,11 @@ dev = [ "ruff>=0.15.8", ] +# The 2026-07-28 protocol revision only ships in the mcp 2.0 prereleases, so +# allow them without every command needing --prerelease=allow. +[tool.uv] +prerelease = "allow" + [tool.ruff] line-length = 120 target-version = "py310" diff --git a/mcp-client-python/uv.lock b/mcp-client-python/uv.lock index 789388fb..41b3fca2 100644 --- a/mcp-client-python/uv.lock +++ b/mcp-client-python/uv.lock @@ -8,6 +8,9 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'win32'", ] +[options] +prerelease-mode = "allow" + [[package]] name = "annotated-types" version = "0.7.0" @@ -283,6 +286,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -299,21 +315,28 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -442,13 +465,14 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, @@ -460,9 +484,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/aa/c5d38e0199304494be6370667d04d93d8681d9bdc864d56678250dbd3f3b/mcp-2.0.0b2.tar.gz", hash = "sha256:0528d0d38ae798fbff251616ec687faaaa8f5309571e0b2bc553c530fa10b8b1", size = 1590650, upload-time = "2026-07-14T16:47:57.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/a7/90/187d6283a304acc6954987992ef17e2515739a649f148dc8b67b302e1bbd/mcp-2.0.0b2-py3-none-any.whl", hash = "sha256:9c50ae5afa08960ab76d50aa3adab3184952d9bea7ef87f4a4a5ba68bdefcf0a", size = 334286, upload-time = "2026-07-14T16:47:54.768Z" }, ] [[package]] @@ -483,13 +507,38 @@ dev = [ [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.87.0" }, - { name = "mcp", specifier = ">=1.28.1" }, + { name = "mcp", specifier = ">=2.0.0b2" }, { name = "python-dotenv", specifier = ">=1.2.2" }, ] [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.15.8" }] +[[package]] +name = "mcp-types" +version = "2.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/21/db529130ac8edd1d844fc322862afeceaf2b7f610a591fa528002b022e07/mcp_types-2.0.0b2.tar.gz", hash = "sha256:094fa7160106819ab39a1586179c3a9f070bfd833d0a6f8fcb30a54a986cc402", size = 65877, upload-time = "2026-07-14T16:47:59.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/e1/c466ceacdaa929396d35ad45176398acd0c416fead0970d3ad22618a19d7/mcp_types-2.0.0b2-py3-none-any.whl", hash = "sha256:35c9c33abb90a77dc6ad1daecaa6407c788c2f32d14d52dad8f843c4f008eae2", size = 68944, upload-time = "2026-07-14T16:47:56.493Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -899,6 +948,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" diff --git a/weather-server-python/README.md b/weather-server-python/README.md index 0a80a27c..de665d95 100644 --- a/weather-server-python/README.md +++ b/weather-server-python/README.md @@ -1,3 +1,13 @@ # A Simple MCP Weather Server written in Python See the [Build an MCP server](https://modelcontextprotocol.io/docs/develop/build-server) tutorial for more information. + +## Structured output + +Both tools declare an output schema and return `structured_content`. `get_forecast` returns an object; `get_alerts` returns a top-level JSON array, which protocol revision `2026-07-28` is the first to allow — see [Structured Content](https://modelcontextprotocol.io/specification/draft/server/tools#structured-content) in the spec. + +`Alerts` is a `RootModel[list[Alert]]` rather than a plain `list[Alert]` because the SDK wraps non-object return types as `{"result": ...}`; a `RootModel` is taken as the schema exactly as written. See [Structured Output](https://py.sdk.modelcontextprotocol.io/v2/servers/structured-output/) in the SDK docs. + +Note that an array-rooted schema requires a `2026-07-28` client. This SDK does not project it down for older ones — it raises instead. + +Requires the `mcp` 2.0 prereleases, so `pyproject.toml` sets `[tool.uv] prerelease = "allow"`. diff --git a/weather-server-python/pyproject.toml b/weather-server-python/pyproject.toml index 03225971..97032748 100644 --- a/weather-server-python/pyproject.toml +++ b/weather-server-python/pyproject.toml @@ -4,9 +4,10 @@ version = "0.1.0" description = "A simple MCP weather server" readme = "README.md" requires-python = ">=3.10" +# httpx2 is a hard dependency of mcp, so it is not listed here — installing +# `mcp` already brings it in, and adding httpx would install a second HTTP stack. dependencies = [ - "httpx>=0.28.1", - "mcp[cli]>=1.26.0", + "mcp[cli]>=2.0.0b2", ] [build-system] @@ -18,6 +19,11 @@ dev = [ "ruff>=0.15.8", ] +# The 2026-07-28 protocol revision only ships in the mcp 2.0 prereleases, so +# allow them without every command needing --prerelease=allow. +[tool.uv] +prerelease = "allow" + [project.scripts] weather = "weather:main" diff --git a/weather-server-python/uv.lock b/weather-server-python/uv.lock index 614e1b91..7c5b84c9 100644 --- a/weather-server-python/uv.lock +++ b/weather-server-python/uv.lock @@ -8,6 +8,9 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'win32'", ] +[options] +prerelease-mode = "allow" + [[package]] name = "annotated-doc" version = "0.0.4" @@ -49,15 +52,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "certifi" -version = "2026.2.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, -] - [[package]] name = "cffi" version = "2.0.0" @@ -243,49 +237,41 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "httpcore2" }, { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -329,13 +315,14 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, @@ -347,9 +334,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/aa/c5d38e0199304494be6370667d04d93d8681d9bdc864d56678250dbd3f3b/mcp-2.0.0b2.tar.gz", hash = "sha256:0528d0d38ae798fbff251616ec687faaaa8f5309571e0b2bc553c530fa10b8b1", size = 1590650, upload-time = "2026-07-14T16:47:57.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/a7/90/187d6283a304acc6954987992ef17e2515739a649f148dc8b67b302e1bbd/mcp-2.0.0b2-py3-none-any.whl", hash = "sha256:9c50ae5afa08960ab76d50aa3adab3184952d9bea7ef87f4a4a5ba68bdefcf0a", size = 334286, upload-time = "2026-07-14T16:47:54.768Z" }, ] [package.optional-dependencies] @@ -358,6 +345,19 @@ cli = [ { name = "typer" }, ] +[[package]] +name = "mcp-types" +version = "2.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/21/db529130ac8edd1d844fc322862afeceaf2b7f610a591fa528002b022e07/mcp_types-2.0.0b2.tar.gz", hash = "sha256:094fa7160106819ab39a1586179c3a9f070bfd833d0a6f8fcb30a54a986cc402", size = 65877, upload-time = "2026-07-14T16:47:59.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/e1/c466ceacdaa929396d35ad45176398acd0c416fead0970d3ad22618a19d7/mcp_types-2.0.0b2-py3-none-any.whl", hash = "sha256:35c9c33abb90a77dc6ad1daecaa6407c788c2f32d14d52dad8f843c4f008eae2", size = 68944, upload-time = "2026-07-14T16:47:56.493Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -367,6 +367,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -798,6 +810,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -853,7 +874,6 @@ name = "weather" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "httpx" }, { name = "mcp", extra = ["cli"] }, ] @@ -863,10 +883,7 @@ dev = [ ] [package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.26.0" }, -] +requires-dist = [{ name = "mcp", extras = ["cli"], specifier = ">=2.0.0b2" }] [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.15.8" }] diff --git a/weather-server-python/weather.py b/weather-server-python/weather.py index 9e1446b6..f1047b69 100644 --- a/weather-server-python/weather.py +++ b/weather-server-python/weather.py @@ -1,20 +1,60 @@ from typing import Any -import httpx -from mcp.server.fastmcp import FastMCP +import httpx2 +from mcp.server import MCPServer +from pydantic import BaseModel, Field, RootModel -# Initialize FastMCP server -mcp = FastMCP("weather") +# Initialize the MCP server +mcp = MCPServer("weather") # Constants NWS_API_BASE = "https://api.weather.gov" USER_AGENT = "weather-app/1.0" +class Alert(BaseModel): + """One active weather alert.""" + + event: str = Field(description="The kind of weather event") + area: str = Field(description="The area the alert covers") + severity: str = Field(description="How severe the event is") + description: str = Field(description="What is happening") + instructions: str = Field(description="What people in the area should do") + + +class Alerts(RootModel[list[Alert]]): + """The output schema of get_alerts: a top-level array, not an object. + + A plain `-> list[Alert]` annotation would be wrapped as `{"result": [...]}`. + A RootModel is a BaseModel, so it is taken as the schema as written, and a + RootModel over a list serializes as the bare list. Revision 2026-07-28 is + the first to allow a non-object root. + """ + + +class Period(BaseModel): + """One period of a forecast.""" + + name: str + temperature: int + temperature_unit: str + wind_speed: str + wind_direction: str + detailed_forecast: str + + +class Forecast(BaseModel): + """The output schema of get_forecast: the object case, for contrast.""" + + latitude: float = Field(description="Latitude the forecast is for") + longitude: float = Field(description="Longitude the forecast is for") + periods: list[Period] = Field(description="The forecast periods, soonest first") + + 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: + async with httpx2.AsyncClient() as client: try: response = await client.get(url, headers=headers, timeout=30.0) response.raise_for_status() @@ -23,40 +63,36 @@ async def make_nws_request(url: str) -> dict[str, Any] | None: 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: +async def get_alerts(state: str) -> Alerts: """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}" + url = f"{NWS_API_BASE}/alerts/active/area/{state.upper()}" 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) + raise ValueError(f"Unable to fetch alerts for {state.upper()}.") + + # An empty result is an empty array, not an error. + return Alerts( + [ + Alert( + 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"), + ) + for props in (feature["properties"] for feature in data["features"]) + ] + ) @mcp.tool() -async def get_forecast(latitude: float, longitude: float) -> str: +async def get_forecast(latitude: float, longitude: float) -> Forecast: """Get weather forecast for a location. Args: @@ -68,28 +104,34 @@ async def get_forecast(latitude: float, longitude: float) -> str: points_data = await make_nws_request(points_url) if not points_data: - return "Unable to fetch forecast data for this location." + raise ValueError("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) + raise ValueError("Unable to fetch detailed forecast.") + + periods = forecast_data["properties"]["periods"][:5] # Only show next 5 periods + if not periods: + raise ValueError("No forecast periods available.") + + return Forecast( + latitude=latitude, + longitude=longitude, + periods=[ + Period( + name=period["name"], + temperature=period["temperature"], + temperature_unit=period["temperatureUnit"], + wind_speed=period["windSpeed"], + wind_direction=period["windDirection"], + detailed_forecast=period["detailedForecast"], + ) + for period in periods + ], + ) def main():