From 8ee4c9ac59c59de364fb7ce2cc45fed5b7bd7271 Mon Sep 17 00:00:00 2001 From: Stefano Date: Fri, 18 Sep 2026 12:14:54 +0200 Subject: [PATCH] fix(mcp): support fastmcp 3 and 4, parse adapter requests The adapter converted each fastmcp tool to the MCP protocol Tool and read inputSchema/outputSchema. MCP SDK 2, pulled in by fastmcp 4, renamed those fields to snake_case, so mypy fails and every listing warns. fastmcp's own Tool exposes parameters and output_schema unchanged across both majors, so read them directly and widen the extra to fastmcp>=3,<5. The adapter subscriptions never passed data_type, so handlers received raw dicts and crashed on the first attribute access. Declare the request models on both subscriptions and add an end-to-end test over the in-memory transport covering tool listing, a successful call and an error call. Supersedes #288. --- sdk/CHANGELOG.md | 5 + sdk/eggai/adapters/mcp/mcp.py | 14 ++- sdk/poetry.lock | 195 +++++++++++++++++++++++++--------- sdk/pyproject.toml | 2 +- sdk/tests/test_mcp.py | 104 ++++++++++++++++++ 5 files changed, 259 insertions(+), 61 deletions(-) create mode 100644 sdk/tests/test_mcp.py diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md index 1685114e..aa6d1206 100644 --- a/sdk/CHANGELOG.md +++ b/sdk/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- MCP adapter reads tool schemas from fastmcp's own `Tool` instead of the + protocol `Tool`, so it works on fastmcp 3 and 4 without touching the + fields MCP SDK 2 renamed. The `mcp` extra now allows `fastmcp>=3,<5`. +- MCP adapter subscriptions declare `data_type`, so handlers receive parsed + request models instead of raw dicts. Covered by a new end-to-end test. - `setup_tracing` type errors: exporter selection is typed, `endpoint` is passed explicitly, and the unused provider reassignment is gone. - A2A adapter type errors: `A2AConfig` imports `SecurityScheme` diff --git a/sdk/eggai/adapters/mcp/mcp.py b/sdk/eggai/adapters/mcp/mcp.py index 866c19b3..105d4f78 100644 --- a/sdk/eggai/adapters/mcp/mcp.py +++ b/sdk/eggai/adapters/mcp/mcp.py @@ -29,6 +29,7 @@ def c(suffix: str) -> Channel: @agent.subscribe( channel=c("list.in"), + data_type=ToolListRequestMessage, auto_offset_reset="latest", group_id=name + "_tools_list_in", ) @@ -39,15 +40,11 @@ async def handle_tool_list_request(message: ToolListRequestMessage): mcp_tools = await mcp_server.list_tools() tools: list[ExternalTool] = [] for tool in mcp_tools: - # fastmcp 3.x returns FunctionTool objects whose schemas live on - # `parameters`/`output_schema`; convert to the MCP protocol Tool to - # read the canonical `inputSchema`/`outputSchema` fields. - mcp_tool = tool.to_mcp_tool() external_tool = ExternalTool( - name=mcp_tool.name, - description=mcp_tool.description or "", - parameters=mcp_tool.inputSchema or {}, - return_type=mcp_tool.outputSchema or {}, + name=tool.name, + description=tool.description or "", + parameters=tool.parameters, + return_type=tool.output_schema or {}, ) tools.append(external_tool) @@ -59,6 +56,7 @@ async def handle_tool_list_request(message: ToolListRequestMessage): @agent.subscribe( channel=c("calls.in"), + data_type=ToolCallRequestMessage, auto_offset_reset="latest", group_id=name + "_tool_calls_in", ) diff --git a/sdk/poetry.lock b/sdk/poetry.lock index f0993a73..a0ecb282 100644 --- a/sdk/poetry.lock +++ b/sdk/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. [[package]] name = "a2a-sdk" @@ -417,7 +417,7 @@ files = [ {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] -markers = {main = "extra == \"a2a\" or extra == \"otel\" or extra == \"mcp\""} +markers = {main = "extra == \"a2a\" or extra == \"otel\""} [[package]] name = "cffi" @@ -1084,40 +1084,40 @@ pydantic = ["pydantic (>=1.7.4,!=1.8,!=1.8.1,<3.0.0)"] [[package]] name = "fastmcp" -version = "3.4.7" +version = "4.0.5" description = "The fast, Pythonic way to build MCP servers and clients." optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"mcp\"" files = [ - {file = "fastmcp-3.4.7-py3-none-any.whl", hash = "sha256:e4e7698cb4af5bc667b1901685261fa2f3526dc73d243a461fca42500c8dbe56"}, - {file = "fastmcp-3.4.7.tar.gz", hash = "sha256:43117aca886f5ee2f6a569bba91cef02b59c339aad04ba29950ff18d251c822a"}, + {file = "fastmcp-4.0.5-py3-none-any.whl", hash = "sha256:1d30e5ba6b711318bf2ddc929cf9c0d6299a6330fbf42b2925dd120984176dc8"}, + {file = "fastmcp-4.0.5.tar.gz", hash = "sha256:4234f9dfc396310ed3a04c2b5314f85394cab161023f9de2fa06842be96288cb"}, ] [package.dependencies] -fastmcp-slim = {version = "3.4.7", extras = ["client", "server"]} +fastmcp-slim = {version = "4.0.5", extras = ["client", "server"]} [package.extras] -anthropic = ["fastmcp-slim[anthropic] (==3.4.7)"] -apps = ["fastmcp-slim[apps] (==3.4.7)"] -azure = ["fastmcp-slim[azure] (==3.4.7)"] -code-mode = ["fastmcp-slim[code-mode] (==3.4.7)"] -gemini = ["fastmcp-slim[gemini] (==3.4.7)"] -openai = ["fastmcp-slim[openai] (==3.4.7)"] -tasks = ["fastmcp-slim[tasks] (==3.4.7)"] +anthropic = ["fastmcp-slim[anthropic] (==4.0.5)"] +apps = ["fastmcp-slim[apps] (==4.0.5)"] +azure = ["fastmcp-slim[azure] (==4.0.5)"] +code-mode = ["fastmcp-slim[code-mode] (==4.0.5)"] +gemini = ["fastmcp-slim[gemini] (==4.0.5)"] +openai = ["fastmcp-slim[openai] (==4.0.5)"] +tasks = ["fastmcp-tasks (==4.0.5)"] [[package]] name = "fastmcp-slim" -version = "3.4.7" +version = "4.0.5" description = "The dependency-slim FastMCP package." optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"mcp\"" files = [ - {file = "fastmcp_slim-3.4.7-py3-none-any.whl", hash = "sha256:6c931a0089705f3f2935428ef9b2bc74ad94140adc64aab84d116d103e694b3a"}, - {file = "fastmcp_slim-3.4.7.tar.gz", hash = "sha256:06b32a358320a7dc2b2ee040ba89ea55ddc20763dff2949f384f7974b13b5d8f"}, + {file = "fastmcp_slim-4.0.5-py3-none-any.whl", hash = "sha256:2bb5bc1ff0a0f3ae063b97f647ea3730d32d5481050231818c161214b9047f67"}, + {file = "fastmcp_slim-4.0.5.tar.gz", hash = "sha256:5dc5395bcacb0eb405787393298891210c821d3f66334c2695e5e435d88fe575"}, ] [package.dependencies] @@ -1125,17 +1125,18 @@ authlib = {version = ">=1.6.11", optional = true, markers = "extra == \"client\" cyclopts = {version = ">=4.0.0", optional = true, markers = "extra == \"server\""} exceptiongroup = {version = ">=1.2.2", optional = true, markers = "extra == \"client\" or extra == \"server\""} griffelib = {version = ">=2.0.0", optional = true, markers = "extra == \"server\""} -httpx = {version = ">=0.28.1,<1.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} -joserfc = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} +httpx2 = {version = ">=2.5.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +joserfc = {version = ">=1.5.0", optional = true, markers = "extra == \"server\""} jsonref = {version = ">=1.1.0", optional = true, markers = "extra == \"server\""} jsonschema-path = {version = ">=0.3.4", optional = true, markers = "extra == \"server\""} -mcp = {version = ">=1.24.0,<2.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +mcp = {version = ">=2.0.0,<3.0.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +mcp-types = ">=2.0.0,<3.0.0" openapi-pydantic = {version = ">=0.5.1", optional = true, markers = "extra == \"server\""} -opentelemetry-api = {version = ">=1.20.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} +opentelemetry-api = {version = ">=1.28.0", optional = true, markers = "extra == \"client\" or extra == \"server\""} packaging = {version = ">=24.0", optional = true, markers = "extra == \"server\""} platformdirs = ">=4.0.0" py-key-value-aio = {version = ">=0.4.4,<0.5.0", extras = ["filetree", "keyring", "memory"], optional = true, markers = "extra == \"client\" or extra == \"server\""} -pydantic = {version = ">=2.11.7", extras = ["email"]} +pydantic = {version = ">=2.12.0", extras = ["email"]} pydantic-settings = ">=2.0.0" pyperclip = {version = ">=1.9.0", optional = true, markers = "extra == \"server\""} python-dotenv = ">=1.1.0" @@ -1144,22 +1145,21 @@ pyyaml = {version = ">=6.0,<7.0", optional = true, markers = "extra == \"server\ rich = ">=13.9.4" starlette = {version = ">=1.0.1", optional = true, markers = "extra == \"client\" or extra == \"server\""} typing-extensions = ">=4.0.0" -uncalled-for = {version = ">=0.2.0", optional = true, markers = "extra == \"server\""} +uncalled-for = {version = ">=0.4.0", optional = true, markers = "extra == \"server\""} uvicorn = {version = ">=0.35", optional = true, markers = "extra == \"server\""} watchfiles = {version = ">=1.0.0", optional = true, markers = "extra == \"server\""} websockets = {version = ">=15.0.1", optional = true, markers = "extra == \"server\""} [package.extras] -anthropic = ["anthropic (>=0.48.0)"] +anthropic = ["anthropic (>=0.48.0,<2)"] apps = ["prefab-ui (>=0.18.0)"] azure = ["azure-identity (>=1.16.0)", "pyjwt (>=2.12.0)"] -client = ["authlib (>=1.6.11)", "exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "starlette (>=1.0.1)"] -code-mode = ["pydantic-monty (==0.0.17)"] +client = ["authlib (>=1.6.11)", "exceptiongroup (>=1.2.2)", "httpx2 (>=2.5.0)", "mcp (>=2.0.0,<3.0.0)", "opentelemetry-api (>=1.28.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "starlette (>=1.0.1)"] +code-mode = ["pydantic-monty (==0.0.21)"] gemini = ["google-genai (>=1.18.0)", "jsonref (>=1.1.0)"] -mcp = ["exceptiongroup (>=1.2.2)", "httpx (>=0.28.1,<1.0)", "mcp (>=1.24.0,<2.0)", "opentelemetry-api (>=1.20.0)", "starlette (>=1.0.1)"] +mcp = ["exceptiongroup (>=1.2.2)", "httpx2 (>=2.5.0)", "mcp (>=2.0.0,<3.0.0)", "opentelemetry-api (>=1.28.0)", "starlette (>=1.0.1)"] openai = ["openai (>=1.102.0)"] -server = ["authlib (>=1.6.11)", "cyclopts (>=4.0.0)", "exceptiongroup (>=1.2.2)", "griffelib (>=2.0.0)", "httpx (>=0.28.1,<1.0)", "joserfc (>=1.1.0)", "jsonref (>=1.1.0)", "jsonschema-path (>=0.3.4)", "mcp (>=1.24.0,<2.0)", "openapi-pydantic (>=0.5.1)", "opentelemetry-api (>=1.20.0)", "packaging (>=24.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "pyperclip (>=1.9.0)", "python-multipart (>=0.0.26)", "pyyaml (>=6.0,<7.0)", "starlette (>=1.0.1)", "uncalled-for (>=0.2.0)", "uvicorn (>=0.35)", "watchfiles (>=1.0.0)", "websockets (>=15.0.1)"] -tasks = ["pydocket (>=0.20.0)"] +server = ["authlib (>=1.6.11)", "cyclopts (>=4.0.0)", "exceptiongroup (>=1.2.2)", "griffelib (>=2.0.0)", "httpx2 (>=2.5.0)", "joserfc (>=1.5.0)", "jsonref (>=1.1.0)", "jsonschema-path (>=0.3.4)", "mcp (>=2.0.0,<3.0.0)", "openapi-pydantic (>=0.5.1)", "opentelemetry-api (>=1.28.0)", "packaging (>=24.0)", "py-key-value-aio[filetree,keyring,memory] (>=0.4.4,<0.5.0)", "pyperclip (>=1.9.0)", "python-multipart (>=0.0.26)", "pyyaml (>=6.0,<7.0)", "starlette (>=1.0.1)", "uncalled-for (>=0.4.0)", "uvicorn (>=0.35)", "watchfiles (>=1.0.0)", "websockets (>=15.0.1)"] [[package]] name = "faststream" @@ -1361,7 +1361,7 @@ description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"a2a\" or extra == \"mcp\"" +markers = "extra == \"mcp\" or extra == \"a2a\"" files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1374,7 +1374,7 @@ description = "A minimal low-level HTTP client." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"a2a\" or extra == \"mcp\"" +markers = "extra == \"a2a\"" files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1390,6 +1390,29 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<1.0)"] +[[package]] +name = "httpcore2" +version = "2.13.0" +description = "A minimal low-level HTTP client." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\" and sys_platform != \"emscripten\"" +files = [ + {file = "httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e"}, + {file = "httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db"}, +] + +[package.dependencies] +h11 = ">=0.16" +truststore = ">=0.10" + +[package.extras] +asyncio = ["anyio (>=4.5.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.34.0,<1.0)"] + [[package]] name = "httpx" version = "0.28.1" @@ -1397,7 +1420,7 @@ description = "The next generation HTTP client." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"a2a\" or extra == \"mcp\"" +markers = "extra == \"a2a\"" files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1423,12 +1446,54 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"a2a\" or extra == \"mcp\"" +markers = "extra == \"a2a\"" files = [ {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +[[package]] +name = "httpx2" +version = "2.13.0" +description = "The next generation HTTP client." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a"}, + {file = "httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44"}, +] + +[package.dependencies] +anyio = {version = ">=4.10", markers = "sys_platform != \"emscripten\""} +httpcore2 = {version = "2.13.0", markers = "sys_platform != \"emscripten\""} +httpx2-jsfetch = {version = "*", markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\""} +idna = ">=3.18" +truststore = {version = ">=0.10", markers = "sys_platform != \"emscripten\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version < \"3.13\""} + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.2) ; platform_python_implementation != \"CPython\""] +cli = ["click (>=8.4.2)", "pygments (==2.*)", "rich (>=10,<16)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +ws = ["wsproto (>=1.2)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version <= \"3.13\""] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +description = "httpx2 transports for Emscripten/Pyodide, backed by the JavaScript fetch API." +optional = true +python-versions = ">=3.12" +groups = ["main"] +markers = "sys_platform == \"emscripten\" and python_version >= \"3.12\" and extra == \"mcp\"" +files = [ + {file = "httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32"}, + {file = "httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60"}, +] + [[package]] name = "id" version = "1.6.1" @@ -2004,46 +2069,59 @@ markers = {main = "extra == \"cli\""} [[package]] name = "mcp" -version = "1.29.1" +version = "2.2.0" description = "Model Context Protocol SDK" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"mcp\"" files = [ - {file = "mcp-1.29.1-py3-none-any.whl", hash = "sha256:b6310eeb59153300c4ab8b9aec4c52f4819a2d6a8e429eb43d908bed7c783648"}, - {file = "mcp-1.29.1.tar.gz", hash = "sha256:1967ba4c315f7a375146209949f45950d18b0efd2f913d7cf3400bc723ee5f04"}, + {file = "mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81"}, + {file = "mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd"}, ] [package.dependencies] -anyio = ">=4.5" -httpx = ">=0.27.1,<1.0.0" -httpx-sse = ">=0.4" -jsonschema = ">=4.20.0" -pydantic = [ - {version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""}, - {version = ">=2.12.0,<3.0.0", markers = "python_version >= \"3.14\""}, +anyio = [ + {version = ">=4.9", markers = "python_version < \"3.14\""}, + {version = ">=4.10", markers = "python_version >= \"3.14\""}, ] -pydantic-settings = ">=2.5.2" +httpx2 = ">=2.5.0" +jsonschema = ">=4.20.0" +mcp-types = "2.2.0" +opentelemetry-api = ">=1.28.0" +pydantic = ">=2.12.0" pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" -pywin32 = [ - {version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""}, - {version = ">=311", markers = "sys_platform == \"win32\" and python_version >= \"3.14\""}, -] -sse-starlette = ">=1.6.1" +pywin32 = {version = ">=311", markers = "sys_platform == \"win32\""} +sse-starlette = ">=3.0.0" starlette = [ {version = ">=0.27", markers = "python_version < \"3.14\""}, {version = ">=0.48.0", markers = "python_version >= \"3.14\""}, ] -typing-extensions = ">=4.9.0" +typing-extensions = ">=4.13.0" typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] rich = ["rich (>=13.9.4)"] -ws = ["websockets (>=15.0.1)"] + +[[package]] +name = "mcp-types" +version = "2.2.0" +description = "Model Context Protocol wire types" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\"" +files = [ + {file = "mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13"}, + {file = "mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad"}, +] + +[package.dependencies] +pydantic = ">=2.12.0" +typing-extensions = ">=4.13.0" [[package]] name = "mdurl" @@ -2885,7 +2963,7 @@ description = "Python for Windows Extensions" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform == \"win32\" and extra == \"mcp\"" +markers = "extra == \"mcp\" and sys_platform == \"win32\"" files = [ {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, @@ -3563,6 +3641,19 @@ files = [ ] markers = {main = "extra == \"mcp\" and python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"mcp\" and sys_platform != \"emscripten\"" +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + [[package]] name = "twine" version = "7.0.0" @@ -4099,4 +4190,4 @@ otel = ["opentelemetry-api", "opentelemetry-exporter-otlp-proto-grpc", "opentele [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "24a38b4db0becb12e5e5e97396155719666f449df104254c40ef2ca19da05703" +content-hash = "59e37b5cd75c9dc95bc18b0a99e481f5d52edbc58d6cbfac6bec4c2d9f696c9a" diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 0ac340e8..f70d01eb 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -38,7 +38,7 @@ faststream = {extras = ["kafka", "redis"], version = ">=0.6,<0.8"} click = {version = "^8.0.0", optional = true} jinja2 = {version = "^3.1.6", optional = true} a2a-sdk = {version = "^0.3.0", optional = true} -fastmcp = {version = "^3.0.0", optional = true} +fastmcp = {version = ">=3,<5", optional = true} opentelemetry-api = {version = "^1.25", optional = true} opentelemetry-sdk = {version = "^1.25", optional = true} opentelemetry-exporter-otlp-proto-grpc = {version = "^1.25", optional = true} diff --git a/sdk/tests/test_mcp.py b/sdk/tests/test_mcp.py new file mode 100644 index 00000000..2af4bd2e --- /dev/null +++ b/sdk/tests/test_mcp.py @@ -0,0 +1,104 @@ +import asyncio +import uuid + +import pytest + +from eggai import Agent, Channel +from eggai.transport import InMemoryTransport, eggai_set_default_transport + +pytest.importorskip("fastmcp") + +from fastmcp import FastMCP # noqa: E402 + +from eggai.adapters.mcp.mcp import run_mcp_adapter # noqa: E402 +from eggai.adapters.mcp.models import ( # noqa: E402 + ToolCallRequest, + ToolCallRequestMessage, + ToolCallResponseMessage, + ToolListRequest, + ToolListRequestMessage, + ToolListResponseMessage, +) + + +@pytest.mark.asyncio +async def test_mcp_adapter_lists_and_calls_tools(): + eggai_set_default_transport(lambda: InMemoryTransport()) + + server = FastMCP("calc") + + @server.tool + def add(a: int, b: int) -> int: + """Add two integers.""" + return a + b + + name = f"calc-{uuid.uuid4().hex[:8]}" + list_responses: list[ToolListResponseMessage] = [] + call_responses: list[ToolCallResponseMessage] = [] + + client = Agent(f"{name}-client") + + @client.subscribe( + channel=Channel(f"tools.{name}.list.out"), data_type=ToolListResponseMessage + ) + async def on_list(message: ToolListResponseMessage): + list_responses.append(message) + + @client.subscribe( + channel=Channel(f"tools.{name}.calls.out"), data_type=ToolCallResponseMessage + ) + async def on_call(message: ToolCallResponseMessage): + call_responses.append(message) + + await client.start() + adapter = asyncio.create_task(run_mcp_adapter(name, server)) + await asyncio.sleep(0.2) + + list_id = uuid.uuid4() + await Channel(f"tools.{name}.list.in").publish( + ToolListRequestMessage( + source="test", + data=ToolListRequest(call_id=list_id, adapter_name=name), + ) + ) + ok_id = uuid.uuid4() + await Channel(f"tools.{name}.calls.in").publish( + ToolCallRequestMessage( + source="test", + data=ToolCallRequest( + call_id=ok_id, tool_name="add", parameters={"a": 2, "b": 3} + ), + ) + ) + bad_id = uuid.uuid4() + await Channel(f"tools.{name}.calls.in").publish( + ToolCallRequestMessage( + source="test", + data=ToolCallRequest(call_id=bad_id, tool_name="missing", parameters={}), + ) + ) + await asyncio.sleep(0.5) + + adapter.cancel() + await adapter + await client.stop() + + assert len(list_responses) == 1 + listed = list_responses[0].data + assert listed.call_id == list_id + assert [t.name for t in listed.tools] == ["add"] + tool = listed.tools[0] + assert tool.description == "Add two integers." + assert tool.parameters["properties"] == { + "a": {"type": "integer"}, + "b": {"type": "integer"}, + } + assert tool.parameters["required"] == ["a", "b"] + assert tool.return_type["properties"] == {"result": {"type": "integer"}} + + by_id = {r.data.call_id: r.data for r in call_responses} + assert set(by_id) == {ok_id, bad_id} + assert by_id[ok_id].is_error is False + assert by_id[ok_id].data["structured_content"] == {"result": 5} + assert by_id[bad_id].is_error is True + assert "missing" in by_id[bad_id].data