From 76325b7846d6ff25d153fd33205acd50f3a01c70 Mon Sep 17 00:00:00 2001 From: Thomas Sheffler Date: Mon, 27 Jan 2025 19:04:20 -0800 Subject: [PATCH 01/37] progress_token is 0 on first tool-call and the return is taken mistakenly --- src/mcp/server/fastmcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 45f17914..5c819df3 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -599,7 +599,7 @@ class Context(BaseModel): else None ) - if not progress_token: + if progress_token == None: return await self.request_context.session.send_progress_notification( From 2dc5fbfa3ddb076da33535b83d68f51f2bc43bbb Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 28 Jan 2025 11:19:13 +0000 Subject: [PATCH 02/37] fix: add test for #176 --- tests/issues/test_176_progress_token.py | 46 +++++++++++++++++++++++++ tests/shared/test_sse.py | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/issues/test_176_progress_token.py diff --git a/tests/issues/test_176_progress_token.py b/tests/issues/test_176_progress_token.py new file mode 100644 index 00000000..ed8ab128 --- /dev/null +++ b/tests/issues/test_176_progress_token.py @@ -0,0 +1,46 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mcp.server.fastmcp import Context +from mcp.shared.context import RequestContext + +pytestmark = pytest.mark.anyio + + +async def test_progress_token_zero_first_call(): + """Test that progress notifications work when progress_token is 0 on first call.""" + + # Create mock session with progress notification tracking + mock_session = AsyncMock() + mock_session.send_progress_notification = AsyncMock() + + # Create request context with progress token 0 + mock_meta = MagicMock() + mock_meta.progressToken = 0 # This is the key test case - token is 0 + + request_context = RequestContext( + request_id="test-request", session=mock_session, meta=mock_meta + ) + + # Create context with our mocks + ctx = Context(request_context=request_context, fastmcp=MagicMock()) + + # Test progress reporting + await ctx.report_progress(0, 10) # First call with 0 + await ctx.report_progress(5, 10) # Middle progress + await ctx.report_progress(10, 10) # Complete + + # Verify progress notifications + assert ( + mock_session.send_progress_notification.call_count == 3 + ), "All progress notifications should be sent" + mock_session.send_progress_notification.assert_any_call( + progress_token=0, progress=0.0, total=10.0 + ) + mock_session.send_progress_notification.assert_any_call( + progress_token=0, progress=5.0, total=10.0 + ) + mock_session.send_progress_notification.assert_any_call( + progress_token=0, progress=10.0, total=10.0 + ) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 9d32fff3..a229cb1a 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -164,6 +164,7 @@ async def http_client(server, server_url) -> AsyncGenerator[httpx.AsyncClient, N async def test_raw_sse_connection(http_client: httpx.AsyncClient) -> None: """Test the SSE connection establishment simply with an HTTP client.""" async with anyio.create_task_group(): + async def connection_test() -> None: async with http_client.stream("GET", "/sse") as response: assert response.status_code == 200 @@ -211,7 +212,6 @@ async def initialized_sse_client_session( yield session - @pytest.mark.anyio async def test_sse_client_happy_request_and_response( initialized_sse_client_session: ClientSession, From 53bfac0cc2dbd3d757544c542d7f9cf26a53ab4c Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Wed, 29 Jan 2025 10:01:56 +0000 Subject: [PATCH 03/37] fix: make progress_token check E711 compat --- src/mcp/server/fastmcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 5c819df3..46977c46 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -599,7 +599,7 @@ class Context(BaseModel): else None ) - if progress_token == None: + if progress_token is None: return await self.request_context.session.send_progress_notification( From 0d48a4494fc60d350ed1b10a88edc8b06f716d65 Mon Sep 17 00:00:00 2001 From: jeremy Date: Wed, 29 Jan 2025 20:41:39 -0500 Subject: [PATCH 04/37] relax pydantic, pydantic-settings, and uvicorn --- pyproject.toml | 6 ++-- tests/server/fastmcp/test_func_metadata.py | 42 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 31a54944..05494d85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,11 @@ dependencies = [ "anyio>=4.5", "httpx>=0.27", "httpx-sse>=0.4", - "pydantic>=2.10.1,<3.0.0", + "pydantic>=2.7.2,<3.0.0", "starlette>=0.27", "sse-starlette>=1.6.1", - "pydantic-settings>=2.6.1", - "uvicorn>=0.30", + "pydantic-settings>=2.5.2", + "uvicorn>=0.23.1", ] [project.optional-dependencies] diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index 7173b43b..79922799 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -235,8 +235,48 @@ async def test_lambda_function(): def test_complex_function_json_schema(): + """Test JSON schema generation for complex function arguments. + + Note: This test accepts two equivalent JSON Schema formats for models with defaults: + 1. Pre-pydantic 2.7.2: + { + "$ref": "#/$defs/Model", + "default": {} + } + + 2. Pydantic 2.7.2+: + { + "allOf": [ + { + "$ref": "#/$defs/Model" + } + ], + "default": {} + } + + Both formats are valid JSON Schema and represent the same validation rules. + The newer format using allOf is more correct according to the JSON Schema spec + as it properly composes the reference with additional properties. + + This change in format does not affect runtime behavior since: + 1. Both schemas validate the same way + 2. The actual model classes and validation logic are unchanged + 3. func_metadata uses model_validate/model_dump, not the schema directly + """ meta = func_metadata(complex_arguments_fn) - assert meta.arg_model.model_json_schema() == { + actual_schema = meta.arg_model.model_json_schema() + + # Create a copy of the actual schema to normalize + normalized_schema = actual_schema.copy() + + # Normalize the my_model_a_with_default field to handle both pydantic formats + if 'allOf' in actual_schema['properties']['my_model_a_with_default']: + normalized_schema['properties']['my_model_a_with_default'] = { + '$ref': '#/$defs/SomeInputModelA', + 'default': {} + } + + assert normalized_schema == { "$defs": { "InnerModel": { "properties": {"x": {"title": "X", "type": "integer"}}, From defd52b2d691cd7694efaf20e1e02bfe7712302b Mon Sep 17 00:00:00 2001 From: jeremy Date: Thu, 30 Jan 2025 10:21:03 -0500 Subject: [PATCH 05/37] Fix comment about pydantic versioning --- tests/server/fastmcp/test_func_metadata.py | 27 ++++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index 79922799..b68fb902 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -237,26 +237,23 @@ async def test_lambda_function(): def test_complex_function_json_schema(): """Test JSON schema generation for complex function arguments. - Note: This test accepts two equivalent JSON Schema formats for models with defaults: - 1. Pre-pydantic 2.7.2: + Note: Different versions of pydantic output slightly different + JSON Schema formats for model fields with defaults. The format changed in 2.9.0: + + 1. Before 2.9.0: + { + "allOf": [{"$ref": "#/$defs/Model"}], + "default": {} + } + + 2. Since 2.9.0: { "$ref": "#/$defs/Model", "default": {} } - 2. Pydantic 2.7.2+: - { - "allOf": [ - { - "$ref": "#/$defs/Model" - } - ], - "default": {} - } - - Both formats are valid JSON Schema and represent the same validation rules. - The newer format using allOf is more correct according to the JSON Schema spec - as it properly composes the reference with additional properties. + Both formats are valid and functionally equivalent. This test accepts either format + to ensure compatibility across our supported pydantic versions. This change in format does not affect runtime behavior since: 1. Both schemas validate the same way From 0d3e02f6a2677189eef84533cf4dc8a20b758913 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 3 Feb 2025 11:33:32 +0000 Subject: [PATCH 06/37] fix: standardize resource response format Github-Issue:#141 --- src/mcp/client/session.py | 11 ++ tests/issues/test_141_resource_templates.py | 118 ++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/issues/test_141_resource_templates.py diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 27ca74d8..4858ede5 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -120,6 +120,17 @@ class ClientSession( types.ListResourcesResult, ) + async def list_resource_templates(self) -> types.ListResourceTemplatesResult: + """Send a resources/templates/list request.""" + return await self.send_request( + types.ClientRequest( + types.ListResourceTemplatesRequest( + method="resources/templates/list", + ) + ), + types.ListResourceTemplatesResult, + ) + async def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult: """Send a resources/read request.""" return await self.send_request( diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py new file mode 100644 index 00000000..d6526e9f --- /dev/null +++ b/tests/issues/test_141_resource_templates.py @@ -0,0 +1,118 @@ +import pytest +from pydantic import AnyUrl + +from mcp.server.fastmcp import FastMCP +from mcp.shared.memory import ( + create_connected_server_and_client_session as client_session, +) +from mcp.types import ( + ListResourceTemplatesResult, + TextResourceContents, +) + + +@pytest.mark.anyio +async def test_resource_template_edge_cases(): + """Test server-side resource template validation""" + mcp = FastMCP("Demo") + + # Test case 1: Template with multiple parameters + @mcp.resource("resource://users/{user_id}/posts/{post_id}") + def get_user_post(user_id: str, post_id: str) -> str: + return f"Post {post_id} by user {user_id}" + + # Test case 2: Template with optional parameter (should fail) + with pytest.raises(ValueError, match="Mismatch between URI parameters"): + + @mcp.resource("resource://users/{user_id}/profile") + def get_user_profile(user_id: str, optional_param: str | None = None) -> str: + return f"Profile for user {user_id}" + + # Test case 3: Template with mismatched parameters + with pytest.raises(ValueError, match="Mismatch between URI parameters"): + + @mcp.resource("resource://users/{user_id}/profile") + def get_user_profile_mismatch(different_param: str) -> str: + return f"Profile for user {different_param}" + + # Test case 4: Template with extra function parameters + with pytest.raises(ValueError, match="Mismatch between URI parameters"): + + @mcp.resource("resource://users/{user_id}/profile") + def get_user_profile_extra(user_id: str, extra_param: str) -> str: + return f"Profile for user {user_id}" + + # Test case 5: Template with missing function parameters + with pytest.raises(ValueError, match="Mismatch between URI parameters"): + + @mcp.resource("resource://users/{user_id}/profile/{section}") + def get_user_profile_missing(user_id: str) -> str: + return f"Profile for user {user_id}" + + # Verify valid template works + result = await mcp.read_resource("resource://users/123/posts/456") + assert result.content == "Post 456 by user 123" + assert result.mime_type == "text/plain" + + # Verify invalid parameters raise error + with pytest.raises(ValueError, match="Unknown resource"): + await mcp.read_resource("resource://users/123/posts") # Missing post_id + + with pytest.raises(ValueError, match="Unknown resource"): + await mcp.read_resource( + "resource://users/123/posts/456/extra" + ) # Extra path component + + +@pytest.mark.anyio +async def test_resource_template_client_interaction(): + """Test client-side resource template interaction""" + mcp = FastMCP("Demo") + + # Register some templated resources + @mcp.resource("resource://users/{user_id}/posts/{post_id}") + def get_user_post(user_id: str, post_id: str) -> str: + return f"Post {post_id} by user {user_id}" + + @mcp.resource("resource://users/{user_id}/profile") + def get_user_profile(user_id: str) -> str: + return f"Profile for user {user_id}" + + async with client_session(mcp._mcp_server) as session: + # Initialize the session + await session.initialize() + + # List available resources + resources = await session.list_resource_templates() + assert isinstance(resources, ListResourceTemplatesResult) + assert len(resources.resourceTemplates) == 2 + + # Verify resource templates are listed correctly + templates = [r.uriTemplate for r in resources.resourceTemplates] + assert "resource://users/{user_id}/posts/{post_id}" in templates + assert "resource://users/{user_id}/profile" in templates + + # Read a resource with valid parameters + result = await session.read_resource(AnyUrl("resource://users/123/posts/456")) + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.text == "Post 456 by user 123" + assert contents.mimeType == "text/plain" + + # Read another resource with valid parameters + result = await session.read_resource(AnyUrl("resource://users/789/profile")) + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.text == "Profile for user 789" + assert contents.mimeType == "text/plain" + + # Verify invalid resource URIs raise appropriate errors + with pytest.raises(Exception): # Specific exception type may vary + await session.read_resource( + AnyUrl("resource://users/123/posts") + ) # Missing post_id + + with pytest.raises(Exception): # Specific exception type may vary + await session.read_resource( + AnyUrl("resource://users/123/invalid") + ) # Invalid template From ce06b6291dd35fa9f6bc7d1b76c02f84eeebf2d0 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 3 Feb 2025 11:36:13 +0000 Subject: [PATCH 07/37] chore: update uv.lock --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 5538a930..7ff1a3ea 100644 --- a/uv.lock +++ b/uv.lock @@ -228,14 +228,14 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx-sse", specifier = ">=0.4" }, - { name = "pydantic", specifier = ">=2.10.1,<3.0.0" }, - { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "pydantic", specifier = ">=2.7.2,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=1.6.1" }, { name = "starlette", specifier = ">=0.27" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" }, - { name = "uvicorn", specifier = ">=0.30" }, + { name = "uvicorn", specifier = ">=0.23.1" }, ] [package.metadata.requires-dev] From a9ae38237de0bda58c7e9f05e0c487a48ab51740 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 3 Feb 2025 12:33:30 +0000 Subject: [PATCH 08/37] check uv lockfile --- .github/workflows/check-lock.yml | 25 +++++++++++++++++++++++++ .pre-commit-config.yaml | 9 +++++++++ 2 files changed, 34 insertions(+) create mode 100644 .github/workflows/check-lock.yml diff --git a/.github/workflows/check-lock.yml b/.github/workflows/check-lock.yml new file mode 100644 index 00000000..805b0f3c --- /dev/null +++ b/.github/workflows/check-lock.yml @@ -0,0 +1,25 @@ +name: Check uv.lock + +on: + pull_request: + paths: + - "pyproject.toml" + - "uv.lock" + push: + paths: + - "pyproject.toml" + - "uv.lock" + +jobs: + check-lock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Check uv.lock is up to date + run: uv lock --check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d75de49e..4fd4befe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,3 +13,12 @@ repos: - id: ruff-format - id: ruff args: [--fix, --exit-non-zero-on-fix] + + - repo: local + hooks: + - id: uv-lock-check + name: Check uv.lock is up to date + entry: uv lock --check + language: system + files: ^(pyproject\.toml|uv\.lock)$ + pass_filenames: false From 960b92355e181239b760e90951dc51b6afe13439 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 3 Feb 2025 13:25:30 +0000 Subject: [PATCH 09/37] add uv.lock for simple-chatbot --- examples/clients/simple-chatbot/uv.lock | 555 ++++++++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 examples/clients/simple-chatbot/uv.lock diff --git a/examples/clients/simple-chatbot/uv.lock b/examples/clients/simple-chatbot/uv.lock new file mode 100644 index 00000000..ee7cb2fa --- /dev/null +++ b/examples/clients/simple-chatbot/uv.lock @@ -0,0 +1,555 @@ +version = 1 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, +] + +[[package]] +name = "certifi" +version = "2024.12.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, + { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, + { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, + { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, + { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, + { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, + { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, + { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, + { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, + { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, + { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, + { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "mcp" +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/ab/a5/b08dc846ebedae9f17ced878e6975826e90e448cd4592f532f6a88a925a7/mcp-1.2.0.tar.gz", hash = "sha256:2b06c7ece98d6ea9e6379caa38d74b432385c338fb530cb82e2c70ea7add94f5", size = 102973 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/84/fca78f19ac8ce6c53ba416247c71baa53a9e791e98d3c81edbc20a77d6d1/mcp-1.2.0-py3-none-any.whl", hash = "sha256:1d0e77d8c14955a5aea1f5aa1f444c8e531c09355c829b20e42f7a142bc0755f", size = 66468 }, +] + +[[package]] +name = "mcp-simple-chatbot" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "mcp" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", specifier = ">=1.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "uvicorn", specifier = ">=0.32.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pydantic" +version = "2.10.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, +] + +[[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 = "pyright" +version = "1.1.392.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/df/3c6f6b08fba7ccf49b114dfc4bb33e25c299883fd763f93fad47ef8bc58d/pyright-1.1.392.post0.tar.gz", hash = "sha256:3b7f88de74a28dcfa90c7d90c782b6569a48c2be5f9d4add38472bdaac247ebd", size = 3789911 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/b1/a18de17f40e4f61ca58856b9ef9b0febf74ff88978c3f7776f910071f567/pyright-1.1.392.post0-py3-none-any.whl", hash = "sha256:252f84458a46fa2f0fd4e2f91fc74f50b9ca52c757062e93f6c250c0d8329eb2", size = 5595487 }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[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 = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "ruff" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/63/77ecca9d21177600f551d1c58ab0e5a0b260940ea7312195bd2a4798f8a8/ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0", size = 3553799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b9/0e168e4e7fb3af851f739e8f07889b91d1a33a30fca8c29fa3149d6b03ec/ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347", size = 11652408 }, + { url = "https://files.pythonhosted.org/packages/2c/22/08ede5db17cf701372a461d1cb8fdde037da1d4fa622b69ac21960e6237e/ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00", size = 11587553 }, + { url = "https://files.pythonhosted.org/packages/42/05/dedfc70f0bf010230229e33dec6e7b2235b2a1b8cbb2a991c710743e343f/ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4", size = 11020755 }, + { url = "https://files.pythonhosted.org/packages/df/9b/65d87ad9b2e3def67342830bd1af98803af731243da1255537ddb8f22209/ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d", size = 11826502 }, + { url = "https://files.pythonhosted.org/packages/93/02/f2239f56786479e1a89c3da9bc9391120057fc6f4a8266a5b091314e72ce/ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c", size = 11390562 }, + { url = "https://files.pythonhosted.org/packages/c9/37/d3a854dba9931f8cb1b2a19509bfe59e00875f48ade632e95aefcb7a0aee/ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f", size = 12548968 }, + { url = "https://files.pythonhosted.org/packages/fa/c3/c7b812bb256c7a1d5553433e95980934ffa85396d332401f6b391d3c4569/ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684", size = 13187155 }, + { url = "https://files.pythonhosted.org/packages/bd/5a/3c7f9696a7875522b66aa9bba9e326e4e5894b4366bd1dc32aa6791cb1ff/ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d", size = 12704674 }, + { url = "https://files.pythonhosted.org/packages/be/d6/d908762257a96ce5912187ae9ae86792e677ca4f3dc973b71e7508ff6282/ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df", size = 14529328 }, + { url = "https://files.pythonhosted.org/packages/2d/c2/049f1e6755d12d9cd8823242fa105968f34ee4c669d04cac8cea51a50407/ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247", size = 12385955 }, + { url = "https://files.pythonhosted.org/packages/91/5a/a9bdb50e39810bd9627074e42743b00e6dc4009d42ae9f9351bc3dbc28e7/ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e", size = 11810149 }, + { url = "https://files.pythonhosted.org/packages/e5/fd/57df1a0543182f79a1236e82a79c68ce210efb00e97c30657d5bdb12b478/ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe", size = 11479141 }, + { url = "https://files.pythonhosted.org/packages/dc/16/bc3fd1d38974f6775fc152a0554f8c210ff80f2764b43777163c3c45d61b/ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb", size = 12014073 }, + { url = "https://files.pythonhosted.org/packages/47/6b/e4ca048a8f2047eb652e1e8c755f384d1b7944f69ed69066a37acd4118b0/ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a", size = 12435758 }, + { url = "https://files.pythonhosted.org/packages/c2/40/4d3d6c979c67ba24cf183d29f706051a53c36d78358036a9cd21421582ab/ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145", size = 9796916 }, + { url = "https://files.pythonhosted.org/packages/c3/ef/7f548752bdb6867e6939489c87fe4da489ab36191525fadc5cede2a6e8e2/ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5", size = 10773080 }, + { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + +[[package]] +name = "starlette" +version = "0.45.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/4f/e1c9f4ec3dae67a94c9285ed275355d5f7cf0f3a5c34538c8ae5412af550/starlette-0.45.2.tar.gz", hash = "sha256:bba1831d15ae5212b22feab2f218bab6ed3cd0fc2dc1d4442443bb1ee52260e0", size = 2574026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/ab/fe4f57c83620b39dfc9e7687ebad59129ff05170b99422105019d9a65eec/starlette-0.45.2-py3-none-any.whl", hash = "sha256:4daec3356fb0cb1e723a5235e5beaf375d2259af27532958e2d79df549dad9da", size = 71505 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] From 888bdd3c34313061dd1d1d69ad565569e3ca0f9b Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Fri, 3 Jan 2025 23:44:43 +0000 Subject: [PATCH 10/37] tests for issue 88 --- tests/issues/test_88_random_error.py | 100 +++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/issues/test_88_random_error.py diff --git a/tests/issues/test_88_random_error.py b/tests/issues/test_88_random_error.py new file mode 100644 index 00000000..8b979ab9 --- /dev/null +++ b/tests/issues/test_88_random_error.py @@ -0,0 +1,100 @@ +"""Test to reproduce issue #88: Random error thrown on response.""" + +from datetime import timedelta +from pathlib import Path +from typing import Sequence + +import anyio +import pytest + +from mcp.client.session import ClientSession +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import McpError +from mcp.types import ( + EmbeddedResource, + ImageContent, + TextContent, +) + + +@pytest.mark.anyio +async def test_notification_validation_error(tmp_path: Path): + """Test that timeouts are handled gracefully and don't break the server. + + This test verifies that when a client request times out: + 1. The server task stays alive + 2. The server can still handle new requests + 3. The client can make new requests + 4. No resources are leaked + """ + + server = Server(name="test") + request_count = 0 + slow_request_complete = False + + @server.call_tool() + async def slow_tool( + name: str, arg + ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + nonlocal request_count, slow_request_complete + request_count += 1 + + if name == "slow": + # Long enough to ensure timeout + await anyio.sleep(0.2) + slow_request_complete = True + return [TextContent(type="text", text=f"slow {request_count}")] + elif name == "fast": + # Fast enough to complete before timeout + await anyio.sleep(0.01) + return [TextContent(type="text", text=f"fast {request_count}")] + return [TextContent(type="text", text=f"unknown {request_count}")] + + async def server_handler(read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + raise_exceptions=True, + ) + + async def client(read_stream, write_stream): + # Use a timeout that's: + # - Long enough for fast operations (>10ms) + # - Short enough for slow operations (<200ms) + # - Not too short to avoid flakiness + async with ClientSession( + read_stream, write_stream, read_timeout_seconds=timedelta(milliseconds=50) + ) as session: + await session.initialize() + + # First call should work (fast operation) + result = await session.call_tool("fast") + assert result.content == [TextContent(type="text", text="fast 1")] + assert not slow_request_complete + + # Second call should timeout (slow operation) + with pytest.raises(McpError) as exc_info: + await session.call_tool("slow") + assert "Timed out while waiting" in str(exc_info.value) + + # Wait for slow request to complete in the background + await anyio.sleep(0.3) + assert slow_request_complete + + # Third call should work (fast operation), + # proving server is still responsive + result = await session.call_tool("fast") + assert result.content == [TextContent(type="text", text="fast 3")] + + # Run server and client in separate task groups to avoid cancellation + server_writer, server_reader = anyio.create_memory_object_stream(1) + client_writer, client_reader = anyio.create_memory_object_stream(1) + + async with anyio.create_task_group() as tg: + tg.start_soon(server_handler, server_reader, client_writer) + # Wait for server to start and initialize + await anyio.sleep(0.1) + # Run client in a separate task to avoid cancellation + async with anyio.create_task_group() as client_tg: + client_tg.start_soon(client, client_reader, server_writer) From 827e494df493024bc63020920ba6d006127227cf Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 23 Jan 2025 20:10:02 +0000 Subject: [PATCH 11/37] feat: add request cancellation and in-flight request tracking This commit adds support for request cancellation and tracking of in-flight requests in the MCP protocol implementation. The key architectural changes are: 1. Request Lifecycle Management: - Added _in_flight dictionary to BaseSession to track active requests - Requests are tracked from receipt until completion/cancellation - Added proper cleanup via on_complete callback 2. Cancellation Support: - Added CancelledNotification handling in _receive_loop - Implemented cancel() method in RequestResponder - Uses anyio.CancelScope for robust cancellation - Sends error response on cancellation 3. Request Context: - Added request_ctx ContextVar for request context - Ensures proper cleanup after request handling - Maintains request state throughout lifecycle 4. Error Handling: - Improved error propagation for cancelled requests - Added proper cleanup of cancelled requests - Maintains consistency of in-flight tracking This change enables clients to cancel long-running requests and servers to properly clean up resources when requests are cancelled. Github-Issue:#88 --- src/mcp/server/lowlevel/server.py | 13 ++- src/mcp/shared/session.py | 64 ++++++++++++--- tests/shared/test_session.py | 126 ++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 tests/shared/test_session.py diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 13d4fd91..3d917226 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -453,10 +453,15 @@ class Server: logger.debug(f"Received message: {message}") match message: - case RequestResponder(request=types.ClientRequest(root=req)): - await self._handle_request( - message, req, session, raise_exceptions - ) + case ( + RequestResponder( + request=types.ClientRequest(root=req) + ) as responder + ): + with responder: + await self._handle_request( + message, req, session, raise_exceptions + ) case types.ClientNotification(root=notify): await self._handle_notification(notify) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 5e114ecf..ddfa9090 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -1,6 +1,6 @@ from contextlib import AbstractAsyncContextManager from datetime import timedelta -from typing import Generic, TypeVar +from typing import Any, Callable, Generic, TypeVar import anyio import anyio.lowlevel @@ -10,6 +10,7 @@ from pydantic import BaseModel from mcp.shared.exceptions import McpError from mcp.types import ( + CancelledNotification, ClientNotification, ClientRequest, ClientResult, @@ -44,21 +45,55 @@ class RequestResponder(Generic[ReceiveRequestT, SendResultT]): request_meta: RequestParams.Meta | None, request: ReceiveRequestT, session: "BaseSession", + on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any], ) -> None: self.request_id = request_id self.request_meta = request_meta self.request = request self._session = session - self._responded = False + self._completed = False + self._cancel_scope = anyio.CancelScope() + self._on_complete = on_complete + + def __enter__(self) -> "RequestResponder[ReceiveRequestT, SendResultT]": + self._cancel_scope.__enter__() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + try: + if self._completed: + self._on_complete(self) + finally: + self._cancel_scope.__exit__(exc_type, exc_val, exc_tb) async def respond(self, response: SendResultT | ErrorData) -> None: - assert not self._responded, "Request already responded to" - self._responded = True + assert not self._completed, "Request already responded to" + if not self.cancelled: + self._completed = True + + await self._session._send_response( + request_id=self.request_id, response=response + ) + + async def cancel(self) -> None: + """Cancel this request and mark it as completed.""" + self._cancel_scope.cancel() + self._completed = True # Mark as completed so it's removed from in_flight + # Send an error response to indicate cancellation await self._session._send_response( - request_id=self.request_id, response=response + request_id=self.request_id, + response=ErrorData(code=0, message="Request cancelled", data=None), ) + @property + def in_flight(self) -> bool: + return not self._completed and not self.cancelled + + @property + def cancelled(self) -> bool: + return self._cancel_scope is not None and self._cancel_scope.cancel_called + class BaseSession( AbstractAsyncContextManager, @@ -82,6 +117,7 @@ class BaseSession( RequestId, MemoryObjectSendStream[JSONRPCResponse | JSONRPCError] ] _request_id: int + _in_flight: dict[RequestId, RequestResponder[ReceiveRequestT, SendResultT]] def __init__( self, @@ -99,6 +135,7 @@ class BaseSession( self._receive_request_type = receive_request_type self._receive_notification_type = receive_notification_type self._read_timeout_seconds = read_timeout_seconds + self._in_flight = {} self._incoming_message_stream_writer, self._incoming_message_stream_reader = ( anyio.create_memory_object_stream[ @@ -219,6 +256,7 @@ class BaseSession( by_alias=True, mode="json", exclude_none=True ) ) + responder = RequestResponder( request_id=message.root.id, request_meta=validated_request.root.params.meta @@ -226,20 +264,28 @@ class BaseSession( else None, request=validated_request, session=self, + on_complete=lambda r: self._in_flight.pop(r.request_id, None), ) + self._in_flight[responder.request_id] = responder await self._received_request(responder) - if not responder._responded: + if not responder._completed: await self._incoming_message_stream_writer.send(responder) + elif isinstance(message.root, JSONRPCNotification): notification = self._receive_notification_type.model_validate( message.root.model_dump( by_alias=True, mode="json", exclude_none=True ) ) - - await self._received_notification(notification) - await self._incoming_message_stream_writer.send(notification) + # Handle cancellation notifications + if isinstance(notification.root, CancelledNotification): + cancelled_id = notification.root.params.requestId + if cancelled_id in self._in_flight: + await self._in_flight[cancelled_id].cancel() + else: + await self._received_notification(notification) + await self._incoming_message_stream_writer.send(notification) else: # Response or error stream = self._response_streams.pop(message.root.id, None) if stream: diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py new file mode 100644 index 00000000..65cf061e --- /dev/null +++ b/tests/shared/test_session.py @@ -0,0 +1,126 @@ +from typing import AsyncGenerator + +import anyio +import pytest + +import mcp.types as types +from mcp.client.session import ClientSession +from mcp.server.lowlevel.server import Server +from mcp.shared.exceptions import McpError +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import ( + CancelledNotification, + CancelledNotificationParams, + ClientNotification, + ClientRequest, + EmptyResult, +) + + +@pytest.fixture +def mcp_server() -> Server: + return Server(name="test server") + + +@pytest.fixture +async def client_connected_to_server( + mcp_server: Server, +) -> AsyncGenerator[ClientSession, None]: + async with create_connected_server_and_client_session(mcp_server) as client_session: + yield client_session + + +@pytest.mark.anyio +async def test_in_flight_requests_cleared_after_completion( + client_connected_to_server: ClientSession, +): + """Verify that _in_flight is empty after all requests complete.""" + # Send a request and wait for response + response = await client_connected_to_server.send_ping() + assert isinstance(response, EmptyResult) + + # Verify _in_flight is empty + assert len(client_connected_to_server._in_flight) == 0 + + +@pytest.mark.anyio +async def test_request_cancellation(): + """Test that requests can be cancelled while in-flight.""" + # The tool is already registered in the fixture + + ev_tool_called = anyio.Event() + ev_cancelled = anyio.Event() + request_id = None + + # Start the request in a separate task so we can cancel it + def make_server() -> Server: + server = Server(name="TestSessionServer") + + # Register the tool handler + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict | None) -> list: + nonlocal request_id, ev_tool_called + if name == "slow_tool": + request_id = server.request_context.request_id + ev_tool_called.set() + await anyio.sleep(10) # Long enough to ensure we can cancel + return [] + raise ValueError(f"Unknown tool: {name}") + + # Register the tool so it shows up in list_tools + @server.list_tools() + async def handle_list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="slow_tool", + description="A slow tool that takes 10 seconds to complete", + inputSchema={}, + ) + ] + + return server + + async def make_request(client_session): + nonlocal ev_cancelled + try: + await client_session.send_request( + ClientRequest( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams( + name="slow_tool", arguments={} + ), + ) + ), + types.CallToolResult, + ) + pytest.fail("Request should have been cancelled") + except McpError as e: + # Expected - request was cancelled + assert "Request cancelled" in str(e) + ev_cancelled.set() + + async with create_connected_server_and_client_session( + make_server() + ) as client_session: + async with anyio.create_task_group() as tg: + tg.start_soon(make_request, client_session) + + # Wait for the request to be in-flight + with anyio.fail_after(1): # Timeout after 1 second + await ev_tool_called.wait() + + # Send cancellation notification + assert request_id is not None + await client_session.send_notification( + ClientNotification( + CancelledNotification( + method="notifications/cancelled", + params=CancelledNotificationParams(requestId=request_id), + ) + ) + ) + + # Give cancellation time to process + with anyio.fail_after(1): + await ev_cancelled.wait() From 00a44692e74be46d24e2c0747d9b3211595a55d7 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Wed, 29 Jan 2025 23:01:07 -0600 Subject: [PATCH 12/37] init --- .gitignore | 3 +++ src/mcp/server/fastmcp/server.py | 21 +++++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index f27f8954..bb25f9e4 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# vscode +.vscode/ \ No newline at end of file diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 06b16e06..d8d54169 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -12,6 +12,7 @@ import uvicorn from pydantic import BaseModel, Field from pydantic.networks import AnyUrl from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import TypeAlias from mcp.server.fastmcp.exceptions import ResourceError from mcp.server.fastmcp.prompts import Prompt, PromptManager @@ -48,6 +49,8 @@ from mcp.types import ( logger = get_logger(__name__) +_Function: TypeAlias = Callable[..., Any] + class Settings(BaseSettings): """FastMCP server settings. @@ -165,7 +168,7 @@ class FastMCP: return Context(request_context=request_context, fastmcp=self) async def call_tool( - self, name: str, arguments: dict + self, name: str, arguments: dict[str, Any] ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """Call a tool by name with arguments.""" context = self.get_context() @@ -214,7 +217,7 @@ class FastMCP: def add_tool( self, - fn: Callable, + fn: _Function, name: str | None = None, description: str | None = None, ) -> None: @@ -230,7 +233,9 @@ class FastMCP: """ self._tool_manager.add_tool(fn, name=name, description=description) - def tool(self, name: str | None = None, description: str | None = None) -> Callable: + def tool( + self, name: str | None = None, description: str | None = None + ) -> Callable[[_Function], _Function]: """Decorator to register a tool. Tools can optionally request a Context object by adding a parameter with the @@ -263,7 +268,7 @@ class FastMCP: "Did you forget to call it? Use @tool() instead of @tool" ) - def decorator(fn: Callable) -> Callable: + def decorator(fn: _Function) -> _Function: self.add_tool(fn, name=name, description=description) return fn @@ -284,7 +289,7 @@ class FastMCP: name: str | None = None, description: str | None = None, mime_type: str | None = None, - ) -> Callable: + ) -> Callable[[_Function], _Function]: """Decorator to register a function as a resource. The function will be called when the resource is read to generate its content. @@ -328,7 +333,7 @@ class FastMCP: "Did you forget to call it? Use @resource('uri') instead of @resource" ) - def decorator(fn: Callable) -> Callable: + def decorator(fn: _Function) -> _Function: # Check if this should be a template has_uri_params = "{" in uri and "}" in uri has_func_params = bool(inspect.signature(fn).parameters) @@ -376,7 +381,7 @@ class FastMCP: def prompt( self, name: str | None = None, description: str | None = None - ) -> Callable: + ) -> Callable[[_Function], _Function]: """Decorator to register a prompt. Args: @@ -417,7 +422,7 @@ class FastMCP: "Did you forget to call it? Use @prompt() instead of @prompt" ) - def decorator(func: Callable) -> Callable: + def decorator(func: _Function) -> _Function: prompt = Prompt.from_function(func, name=name, description=description) self.add_prompt(prompt) return func From ca060014bbfeaf262bb7d16bd1f12302c5aafb81 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 3 Feb 2025 14:27:07 -0600 Subject: [PATCH 13/37] centralize type --- src/mcp/server/fastmcp/server.py | 18 ++++++++---------- src/mcp/types.py | 3 ++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index d8d54169..aa7c79bc 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -12,7 +12,6 @@ import uvicorn from pydantic import BaseModel, Field from pydantic.networks import AnyUrl from pydantic_settings import BaseSettings, SettingsConfigDict -from typing_extensions import TypeAlias from mcp.server.fastmcp.exceptions import ResourceError from mcp.server.fastmcp.prompts import Prompt, PromptManager @@ -26,6 +25,7 @@ from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.shared.context import RequestContext from mcp.types import ( + AnyFunction, EmbeddedResource, GetPromptResult, ImageContent, @@ -49,8 +49,6 @@ from mcp.types import ( logger = get_logger(__name__) -_Function: TypeAlias = Callable[..., Any] - class Settings(BaseSettings): """FastMCP server settings. @@ -217,7 +215,7 @@ class FastMCP: def add_tool( self, - fn: _Function, + fn: AnyFunction, name: str | None = None, description: str | None = None, ) -> None: @@ -235,7 +233,7 @@ class FastMCP: def tool( self, name: str | None = None, description: str | None = None - ) -> Callable[[_Function], _Function]: + ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a tool. Tools can optionally request a Context object by adding a parameter with the @@ -268,7 +266,7 @@ class FastMCP: "Did you forget to call it? Use @tool() instead of @tool" ) - def decorator(fn: _Function) -> _Function: + def decorator(fn: AnyFunction) -> AnyFunction: self.add_tool(fn, name=name, description=description) return fn @@ -289,7 +287,7 @@ class FastMCP: name: str | None = None, description: str | None = None, mime_type: str | None = None, - ) -> Callable[[_Function], _Function]: + ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a function as a resource. The function will be called when the resource is read to generate its content. @@ -333,7 +331,7 @@ class FastMCP: "Did you forget to call it? Use @resource('uri') instead of @resource" ) - def decorator(fn: _Function) -> _Function: + def decorator(fn: AnyFunction) -> AnyFunction: # Check if this should be a template has_uri_params = "{" in uri and "}" in uri has_func_params = bool(inspect.signature(fn).parameters) @@ -381,7 +379,7 @@ class FastMCP: def prompt( self, name: str | None = None, description: str | None = None - ) -> Callable[[_Function], _Function]: + ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a prompt. Args: @@ -422,7 +420,7 @@ class FastMCP: "Did you forget to call it? Use @prompt() instead of @prompt" ) - def decorator(func: _Function) -> _Function: + def decorator(func: AnyFunction) -> AnyFunction: prompt = Prompt.from_function(func, name=name, description=description) self.add_prompt(prompt) return func diff --git a/src/mcp/types.py b/src/mcp/types.py index a4fa254e..d1157aa6 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Generic, Literal, TypeVar +from typing import Annotated, Any, Callable, Generic, Literal, TypeAlias, TypeVar from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel from pydantic.networks import AnyUrl @@ -27,6 +27,7 @@ ProgressToken = str | int Cursor = str Role = Literal["user", "assistant"] RequestId = str | int +AnyFunction: TypeAlias = Callable[..., Any] class RequestParams(BaseModel): From 08cfbe522aae48365f74147b20636e8bd715174d Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 4 Feb 2025 13:58:44 +0000 Subject: [PATCH 14/37] fix: improve error handling and request cancellation for issue #88 --- src/mcp/shared/session.py | 34 ++++++++++++++++++---------- tests/issues/test_88_random_error.py | 27 +++++++++++++++------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index ddfa9090..e21bcbc1 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -1,3 +1,4 @@ +import logging from contextlib import AbstractAsyncContextManager from datetime import timedelta from typing import Any, Callable, Generic, TypeVar @@ -273,19 +274,28 @@ class BaseSession( await self._incoming_message_stream_writer.send(responder) elif isinstance(message.root, JSONRPCNotification): - notification = self._receive_notification_type.model_validate( - message.root.model_dump( - by_alias=True, mode="json", exclude_none=True + try: + notification = self._receive_notification_type.model_validate( + message.root.model_dump( + by_alias=True, mode="json", exclude_none=True + ) + ) + # Handle cancellation notifications + if isinstance(notification.root, CancelledNotification): + cancelled_id = notification.root.params.requestId + if cancelled_id in self._in_flight: + await self._in_flight[cancelled_id].cancel() + else: + await self._received_notification(notification) + await self._incoming_message_stream_writer.send( + notification + ) + except Exception as e: + # For other validation errors, log and continue + logging.warning( + f"Failed to validate notification: {e}. " + f"Message was: {message.root}" ) - ) - # Handle cancellation notifications - if isinstance(notification.root, CancelledNotification): - cancelled_id = notification.root.params.requestId - if cancelled_id in self._in_flight: - await self._in_flight[cancelled_id].cancel() - else: - await self._received_notification(notification) - await self._incoming_message_stream_writer.send(notification) else: # Response or error stream = self._response_streams.pop(message.root.id, None) if stream: diff --git a/tests/issues/test_88_random_error.py b/tests/issues/test_88_random_error.py index 8b979ab9..8609c209 100644 --- a/tests/issues/test_88_random_error.py +++ b/tests/issues/test_88_random_error.py @@ -30,19 +30,23 @@ async def test_notification_validation_error(tmp_path: Path): server = Server(name="test") request_count = 0 - slow_request_complete = False + slow_request_started = anyio.Event() + slow_request_complete = anyio.Event() @server.call_tool() async def slow_tool( name: str, arg ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: - nonlocal request_count, slow_request_complete + nonlocal request_count request_count += 1 if name == "slow": + # Signal that slow request has started + slow_request_started.set() # Long enough to ensure timeout await anyio.sleep(0.2) - slow_request_complete = True + # Signal completion + slow_request_complete.set() return [TextContent(type="text", text=f"slow {request_count}")] elif name == "fast": # Fast enough to complete before timeout @@ -71,7 +75,7 @@ async def test_notification_validation_error(tmp_path: Path): # First call should work (fast operation) result = await session.call_tool("fast") assert result.content == [TextContent(type="text", text="fast 1")] - assert not slow_request_complete + assert not slow_request_complete.is_set() # Second call should timeout (slow operation) with pytest.raises(McpError) as exc_info: @@ -79,8 +83,8 @@ async def test_notification_validation_error(tmp_path: Path): assert "Timed out while waiting" in str(exc_info.value) # Wait for slow request to complete in the background - await anyio.sleep(0.3) - assert slow_request_complete + with anyio.fail_after(1): # Timeout after 1 second + await slow_request_complete.wait() # Third call should work (fast operation), # proving server is still responsive @@ -91,10 +95,17 @@ async def test_notification_validation_error(tmp_path: Path): server_writer, server_reader = anyio.create_memory_object_stream(1) client_writer, client_reader = anyio.create_memory_object_stream(1) + server_ready = anyio.Event() + + async def wrapped_server_handler(read_stream, write_stream): + server_ready.set() + await server_handler(read_stream, write_stream) + async with anyio.create_task_group() as tg: - tg.start_soon(server_handler, server_reader, client_writer) + tg.start_soon(wrapped_server_handler, server_reader, client_writer) # Wait for server to start and initialize - await anyio.sleep(0.1) + with anyio.fail_after(1): # Timeout after 1 second + await server_ready.wait() # Run client in a separate task to avoid cancellation async with anyio.create_task_group() as client_tg: client_tg.start_soon(client, client_reader, server_writer) From 733db0c9cfd2bef6b125c1962b0137a510233759 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 4 Feb 2025 19:29:12 +0000 Subject: [PATCH 15/37] fix: enforce context manager usage for RequestResponder --- src/mcp/server/session.py | 23 ++++++++++++----------- src/mcp/shared/session.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index b71b372b..d918b988 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -126,19 +126,20 @@ class ServerSession( case types.InitializeRequest(params=params): self._initialization_state = InitializationState.Initializing self._client_params = params - await responder.respond( - types.ServerResult( - types.InitializeResult( - protocolVersion=types.LATEST_PROTOCOL_VERSION, - capabilities=self._init_options.capabilities, - serverInfo=types.Implementation( - name=self._init_options.server_name, - version=self._init_options.server_version, - ), - instructions=self._init_options.instructions, + with responder: + await responder.respond( + types.ServerResult( + types.InitializeResult( + protocolVersion=types.LATEST_PROTOCOL_VERSION, + capabilities=self._init_options.capabilities, + serverInfo=types.Implementation( + name=self._init_options.server_name, + version=self._init_options.server_version, + ), + instructions=self._init_options.instructions, + ) ) ) - ) case _: if self._initialization_state != InitializationState.Initialized: raise RuntimeError( diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index e21bcbc1..3d3988ce 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -40,6 +40,21 @@ RequestId = str | int class RequestResponder(Generic[ReceiveRequestT, SendResultT]): + """Handles responding to MCP requests and manages request lifecycle. + + This class MUST be used as a context manager to ensure proper cleanup and + cancellation handling: + + Example: + with request_responder as resp: + await resp.respond(result) + + The context manager ensures: + 1. Proper cancellation scope setup and cleanup + 2. Request completion tracking + 3. Cleanup of in-flight requests + """ + def __init__( self, request_id: RequestId, @@ -55,19 +70,36 @@ class RequestResponder(Generic[ReceiveRequestT, SendResultT]): self._completed = False self._cancel_scope = anyio.CancelScope() self._on_complete = on_complete + self._entered = False # Track if we're in a context manager def __enter__(self) -> "RequestResponder[ReceiveRequestT, SendResultT]": + """Enter the context manager, enabling request cancellation tracking.""" + self._entered = True + self._cancel_scope = anyio.CancelScope() self._cancel_scope.__enter__() return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Exit the context manager, performing cleanup and notifying completion.""" try: if self._completed: self._on_complete(self) finally: + self._entered = False + if not self._cancel_scope: + raise RuntimeError("No active cancel scope") self._cancel_scope.__exit__(exc_type, exc_val, exc_tb) async def respond(self, response: SendResultT | ErrorData) -> None: + """Send a response for this request. + + Must be called within a context manager block. + Raises: + RuntimeError: If not used within a context manager + AssertionError: If request was already responded to + """ + if not self._entered: + raise RuntimeError("RequestResponder must be used as a context manager") assert not self._completed, "Request already responded to" if not self.cancelled: @@ -79,6 +111,11 @@ class RequestResponder(Generic[ReceiveRequestT, SendResultT]): async def cancel(self) -> None: """Cancel this request and mark it as completed.""" + if not self._entered: + raise RuntimeError("RequestResponder must be used as a context manager") + if not self._cancel_scope: + raise RuntimeError("No active cancel scope") + self._cancel_scope.cancel() self._completed = True # Mark as completed so it's removed from in_flight # Send an error response to indicate cancellation From 27bfde95a4c11874f3993cb3954e2a6077df2169 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 4 Feb 2025 20:16:17 +0000 Subject: [PATCH 16/37] fix: fix tests --- tests/server/fastmcp/test_parameter_descriptions.py | 2 +- tests/shared/test_sse.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/server/fastmcp/test_parameter_descriptions.py b/tests/server/fastmcp/test_parameter_descriptions.py index 0a45e4fe..29470ed1 100644 --- a/tests/server/fastmcp/test_parameter_descriptions.py +++ b/tests/server/fastmcp/test_parameter_descriptions.py @@ -6,7 +6,7 @@ from pydantic import Field from mcp.server.fastmcp import FastMCP -@pytest.mark.asyncio +@pytest.mark.anyio async def test_parameter_descriptions(): mcp = FastMCP("Test Server") diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index a229cb1a..87129ba9 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -43,7 +43,7 @@ def server_url(server_port: int) -> str: # Test server implementation -class TestServer(Server): +class ServerTest(Server): def __init__(self): super().__init__(SERVER_NAME) @@ -81,7 +81,7 @@ class TestServer(Server): def make_server_app() -> Starlette: """Create test Starlette app with SSE transport""" sse = SseServerTransport("/messages/") - server = TestServer() + server = ServerTest() async def handle_sse(request: Request) -> None: async with sse.connect_sse( From f10665db4c2f676da1131617ad67715952258712 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Wed, 5 Feb 2025 11:02:51 +0000 Subject: [PATCH 17/37] tests: cleanup --- tests/conftest.py | 28 ---------------------------- tests/shared/test_memory.py | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9c4f8b48..af7e4799 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,32 +1,4 @@ import pytest -from pydantic import AnyUrl - -from mcp.server.lowlevel import Server -from mcp.server.models import InitializationOptions -from mcp.types import Resource, ServerCapabilities - -TEST_INITIALIZATION_OPTIONS = InitializationOptions( - server_name="my_mcp_server", - server_version="0.1.0", - capabilities=ServerCapabilities(), -) - - -@pytest.fixture -def mcp_server() -> Server: - server = Server(name="test_server") - - @server.list_resources() - async def handle_list_resources(): - return [ - Resource( - uri=AnyUrl("memory://test"), - name="Test Resource", - description="A test resource", - ) - ] - - return server @pytest.fixture diff --git a/tests/shared/test_memory.py b/tests/shared/test_memory.py index 0a0515ca..a0c32f55 100644 --- a/tests/shared/test_memory.py +++ b/tests/shared/test_memory.py @@ -1,4 +1,5 @@ import pytest +from pydantic import AnyUrl from typing_extensions import AsyncGenerator from mcp.client.session import ClientSession @@ -8,9 +9,27 @@ from mcp.shared.memory import ( ) from mcp.types import ( EmptyResult, + Resource, ) +@pytest.fixture +def mcp_server() -> Server: + server = Server(name="test_server") + + @server.list_resources() + async def handle_list_resources(): + return [ + Resource( + uri=AnyUrl("memory://test"), + name="Test Resource", + description="A test resource", + ) + ] + + return server + + @pytest.fixture async def client_connected_to_server( mcp_server: Server, From 2c7bd8343eb92bf6d3d3dbf3e66687b8d47cbd5f Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 11 Feb 2025 12:14:58 +0000 Subject: [PATCH 18/37] feat: add lifespan support to low-level MCP server Adds a context manager based lifespan API in mcp.server.lowlevel.server to manage server lifecycles in a type-safe way. This enables servers to: - Initialize resources on startup and clean them up on shutdown - Pass context data from startup to request handlers - Support async startup/shutdown operations --- src/mcp/server/lowlevel/server.py | 81 ++++++++++++++++++++++--------- src/mcp/shared/context.py | 3 +- 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 3d917226..28942cf8 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -68,7 +68,8 @@ import contextvars import logging import warnings from collections.abc import Awaitable, Callable -from typing import Any, Sequence +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import Any, AsyncIterator, Generic, Sequence, TypeVar from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl @@ -101,13 +102,36 @@ class NotificationOptions: self.tools_changed = tools_changed -class Server: +LifespanResultT = TypeVar("LifespanResultT") + + +@asynccontextmanager +async def lifespan(server: "Server") -> AsyncIterator[object]: + """Default lifespan context manager that does nothing. + + Args: + server: The server instance this lifespan is managing + + Returns: + An empty context object + """ + yield {} + + +class Server(Generic[LifespanResultT]): def __init__( - self, name: str, version: str | None = None, instructions: str | None = None + self, + name: str, + version: str | None = None, + instructions: str | None = None, + lifespan: Callable[ + ["Server"], AbstractAsyncContextManager[LifespanResultT] + ] = lifespan, ): self.name = name self.version = version self.instructions = instructions + self.lifespan = lifespan self.request_handlers: dict[ type, Callable[..., Awaitable[types.ServerResult]] ] = { @@ -446,35 +470,43 @@ class Server: raise_exceptions: bool = False, ): with warnings.catch_warnings(record=True) as w: - async with ServerSession( - read_stream, write_stream, initialization_options - ) as session: - async for message in session.incoming_messages: - logger.debug(f"Received message: {message}") + async with self.lifespan(self) as lifespan_context: + async with ServerSession( + read_stream, write_stream, initialization_options + ) as session: + async for message in session.incoming_messages: + logger.debug(f"Received message: {message}") - match message: - case ( - RequestResponder( - request=types.ClientRequest(root=req) - ) as responder - ): - with responder: - await self._handle_request( - message, req, session, raise_exceptions - ) - case types.ClientNotification(root=notify): - await self._handle_notification(notify) + match message: + case ( + RequestResponder( + request=types.ClientRequest(root=req) + ) as responder + ): + with responder: + await self._handle_request( + message, + req, + session, + lifespan_context, + raise_exceptions, + ) + case types.ClientNotification(root=notify): + await self._handle_notification(notify) - for warning in w: - logger.info( - f"Warning: {warning.category.__name__}: {warning.message}" - ) + for warning in w: + logger.info( + "Warning: %s: %s", + warning.category.__name__, + warning.message, + ) async def _handle_request( self, message: RequestResponder, req: Any, session: ServerSession, + lifespan_context: object, raise_exceptions: bool, ): logger.info(f"Processing request of type {type(req).__name__}") @@ -491,6 +523,7 @@ class Server: message.request_id, message.request_meta, session, + lifespan_context, ) ) response = await handler(req) diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py index 760d5587..50e5d519 100644 --- a/src/mcp/shared/context.py +++ b/src/mcp/shared/context.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar from mcp.shared.session import BaseSession from mcp.types import RequestId, RequestParams @@ -12,3 +12,4 @@ class RequestContext(Generic[SessionT]): request_id: RequestId meta: RequestParams.Meta | None session: SessionT + lifespan_context: Any From d3ea9009b0414c713084dc8688459120af6a67cc Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 11 Feb 2025 12:15:08 +0000 Subject: [PATCH 19/37] feat: add lifespan support to FastMCP server Adds support for the lifespan API to FastMCP server, enabling: - Simple setup with FastMCP constructor - Type-safe context passing to tools and handlers - Configuration via Settings class --- src/mcp/server/fastmcp/server.py | 44 +++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index aa7c79bc..bc341b40 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -3,8 +3,13 @@ import inspect import json import re +from collections.abc import AsyncIterator +from contextlib import ( + AbstractAsyncContextManager, + asynccontextmanager, +) from itertools import chain -from typing import Any, Callable, Literal, Sequence +from typing import Any, Callable, Generic, Literal, Sequence import anyio import pydantic_core @@ -19,8 +24,16 @@ from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceMan from mcp.server.fastmcp.tools import ToolManager from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger from mcp.server.fastmcp.utilities.types import Image -from mcp.server.lowlevel import Server as MCPServer from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.server.lowlevel.server import ( + LifespanResultT, +) +from mcp.server.lowlevel.server import ( + Server as MCPServer, +) +from mcp.server.lowlevel.server import ( + lifespan as default_lifespan, +) from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.shared.context import RequestContext @@ -50,7 +63,7 @@ from mcp.types import ( logger = get_logger(__name__) -class Settings(BaseSettings): +class Settings(BaseSettings, Generic[LifespanResultT]): """FastMCP server settings. All settings can be configured via environment variables with the prefix FASTMCP_. @@ -85,13 +98,36 @@ class Settings(BaseSettings): description="List of dependencies to install in the server environment", ) + lifespan: ( + Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]] | None + ) = Field(None, description="Lifespan contexte manager") + + +def lifespan_wrapper( + app: "FastMCP", + lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]], +) -> Callable[[MCPServer], AbstractAsyncContextManager[object]]: + @asynccontextmanager + async def wrap(s: MCPServer) -> AsyncIterator[object]: + async with lifespan(app) as context: + yield context + + return wrap + class FastMCP: def __init__( self, name: str | None = None, instructions: str | None = None, **settings: Any ): self.settings = Settings(**settings) - self._mcp_server = MCPServer(name=name or "FastMCP", instructions=instructions) + + self._mcp_server = MCPServer( + name=name or "FastMCP", + instructions=instructions, + lifespan=lifespan_wrapper(self, self.settings.lifespan) + if self.settings.lifespan + else default_lifespan, + ) self._tool_manager = ToolManager( warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools ) From e598750cbaec0dcc48be1f6561c0062553ac9b2e Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 11 Feb 2025 12:15:18 +0000 Subject: [PATCH 20/37] test: add tests for server lifespan support Adds comprehensive tests for lifespan functionality: - Tests for both low-level Server and FastMCP classes - Coverage for startup, shutdown, and context access - Verifies context passing to request handlers --- tests/issues/test_176_progress_token.py | 5 +- tests/server/test_lifespan.py | 207 ++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/server/test_lifespan.py diff --git a/tests/issues/test_176_progress_token.py b/tests/issues/test_176_progress_token.py index ed8ab128..7f9131a1 100644 --- a/tests/issues/test_176_progress_token.py +++ b/tests/issues/test_176_progress_token.py @@ -20,7 +20,10 @@ async def test_progress_token_zero_first_call(): mock_meta.progressToken = 0 # This is the key test case - token is 0 request_context = RequestContext( - request_id="test-request", session=mock_session, meta=mock_meta + request_id="test-request", + session=mock_session, + meta=mock_meta, + lifespan_context=None, ) # Create context with our mocks diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py new file mode 100644 index 00000000..14afb6b0 --- /dev/null +++ b/tests/server/test_lifespan.py @@ -0,0 +1,207 @@ +"""Tests for lifespan functionality in both low-level and FastMCP servers.""" + +from contextlib import asynccontextmanager +from typing import AsyncIterator + +import anyio +import pytest +from pydantic import TypeAdapter + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.lowlevel.server import NotificationOptions, Server +from mcp.server.models import InitializationOptions +from mcp.types import ( + ClientCapabilities, + Implementation, + InitializeRequestParams, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, +) + + +@pytest.mark.anyio +async def test_lowlevel_server_lifespan(): + """Test that lifespan works in low-level server.""" + + @asynccontextmanager + async def test_lifespan(server: Server) -> AsyncIterator[dict]: + """Test lifespan context that tracks startup/shutdown.""" + context = {"started": False, "shutdown": False} + try: + context["started"] = True + yield context + finally: + context["shutdown"] = True + + server = Server("test", lifespan=test_lifespan) + + # Create memory streams for testing + send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) + send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) + + # Create a tool that accesses lifespan context + @server.call_tool() + async def check_lifespan(name: str, arguments: dict) -> list: + ctx = server.request_context + assert isinstance(ctx.lifespan_context, dict) + assert ctx.lifespan_context["started"] + assert not ctx.lifespan_context["shutdown"] + return [{"type": "text", "text": "true"}] + + # Run server in background task + async with anyio.create_task_group() as tg: + + async def run_server(): + await server.run( + receive_stream1, + send_stream2, + InitializationOptions( + server_name="test", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + raise_exceptions=True, + ) + + tg.start_soon(run_server) + + # Initialize the server + params = InitializeRequestParams( + protocolVersion="2024-11-05", + capabilities=ClientCapabilities(), + clientInfo=Implementation(name="test-client", version="0.1.0"), + ) + await send_stream1.send( + JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=TypeAdapter(InitializeRequestParams).dump_python(params), + ) + ) + ) + response = await receive_stream2.receive() + + # Send initialized notification + await send_stream1.send( + JSONRPCMessage( + root=JSONRPCNotification( + jsonrpc="2.0", + method="notifications/initialized", + ) + ) + ) + + # Call the tool to verify lifespan context + await send_stream1.send( + JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=2, + method="tools/call", + params={"name": "check_lifespan", "arguments": {}}, + ) + ) + ) + + # Get response and verify + response = await receive_stream2.receive() + assert response.root.result["content"][0]["text"] == "true" + + # Cancel server task + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_fastmcp_server_lifespan(): + """Test that lifespan works in FastMCP server.""" + + @asynccontextmanager + async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]: + """Test lifespan context that tracks startup/shutdown.""" + context = {"started": False, "shutdown": False} + try: + context["started"] = True + yield context + finally: + context["shutdown"] = True + + server = FastMCP("test", lifespan=test_lifespan) + + # Create memory streams for testing + send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) + send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) + + # Add a tool that checks lifespan context + @server.tool() + def check_lifespan(ctx: Context) -> bool: + """Tool that checks lifespan context.""" + assert isinstance(ctx.request_context.lifespan_context, dict) + assert ctx.request_context.lifespan_context["started"] + assert not ctx.request_context.lifespan_context["shutdown"] + return True + + # Run server in background task + async with anyio.create_task_group() as tg: + + async def run_server(): + await server._mcp_server.run( + receive_stream1, + send_stream2, + server._mcp_server.create_initialization_options(), + raise_exceptions=True, + ) + + tg.start_soon(run_server) + + # Initialize the server + params = InitializeRequestParams( + protocolVersion="2024-11-05", + capabilities=ClientCapabilities(), + clientInfo=Implementation(name="test-client", version="0.1.0"), + ) + await send_stream1.send( + JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=TypeAdapter(InitializeRequestParams).dump_python(params), + ) + ) + ) + response = await receive_stream2.receive() + + # Send initialized notification + await send_stream1.send( + JSONRPCMessage( + root=JSONRPCNotification( + jsonrpc="2.0", + method="notifications/initialized", + ) + ) + ) + + # Call the tool to verify lifespan context + await send_stream1.send( + JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=2, + method="tools/call", + params={"name": "check_lifespan", "arguments": {}}, + ) + ) + ) + + # Get response and verify + response = await receive_stream2.receive() + assert response.root.result["content"][0]["text"] == "true" + + # Cancel server task + tg.cancel_scope.cancel() From e5815bd162c490741a3c4769bcbadc77f8961c21 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 11 Feb 2025 12:15:29 +0000 Subject: [PATCH 21/37] docs: update README with lifespan examples and usage Add comprehensive documentation for lifespan support: - Add usage examples for both Server and FastMPC classes - Document startup/shutdown patterns - Show context access in tools and handlers - Clean up spacing in test files --- README.md | 56 +++++++++++++++++++++- tests/server/fastmcp/test_func_metadata.py | 22 ++++----- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 310bb35b..8de0d798 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,28 @@ mcp = FastMCP("My App") # Specify dependencies for deployment and development mcp = FastMCP("My App", dependencies=["pandas", "numpy"]) + +# Add lifespan support for startup/shutdown +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]: + """Manage application lifecycle""" + try: + # Initialize on startup + await db.connect() + yield {"db": db} + finally: + # Cleanup on shutdown + await db.disconnect() + +# Pass lifespan to server +mcp = FastMCP("My App", lifespan=app_lifespan) + +# Access lifespan context in tools +@mcp.tool() +def query_db(ctx: Context) -> str: + """Tool that uses initialized resources""" + db = ctx.request_context.lifespan_context["db"] + return db.query() ``` ### Resources @@ -334,7 +356,39 @@ def query_data(sql: str) -> str: ### Low-Level Server -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server: +For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: + +```python +from contextlib import asynccontextmanager +from typing import AsyncIterator + +@asynccontextmanager +async def server_lifespan(server: Server) -> AsyncIterator[dict]: + """Manage server startup and shutdown lifecycle.""" + try: + # Initialize resources on startup + await db.connect() + yield {"db": db} + finally: + # Clean up on shutdown + await db.disconnect() + +# Pass lifespan to server +server = Server("example-server", lifespan=server_lifespan) + +# Access lifespan context in handlers +@server.call_tool() +async def query_db(name: str, arguments: dict) -> list: + ctx = server.request_context + db = ctx.lifespan_context["db"] + return await db.query(arguments["query"]) +``` + +The lifespan API provides: +- A way to initialize resources when the server starts and clean them up when it stops +- Access to initialized resources through the request context in handlers +- Support for both low-level Server and FastMCP classes +- Type-safe context passing between lifespan and request handlers ```python from mcp.server.lowlevel import Server, NotificationOptions diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index b68fb902..6461648e 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -236,7 +236,7 @@ async def test_lambda_function(): def test_complex_function_json_schema(): """Test JSON schema generation for complex function arguments. - + Note: Different versions of pydantic output slightly different JSON Schema formats for model fields with defaults. The format changed in 2.9.0: @@ -245,16 +245,16 @@ def test_complex_function_json_schema(): "allOf": [{"$ref": "#/$defs/Model"}], "default": {} } - + 2. Since 2.9.0: { "$ref": "#/$defs/Model", "default": {} } - + Both formats are valid and functionally equivalent. This test accepts either format to ensure compatibility across our supported pydantic versions. - + This change in format does not affect runtime behavior since: 1. Both schemas validate the same way 2. The actual model classes and validation logic are unchanged @@ -262,17 +262,17 @@ def test_complex_function_json_schema(): """ meta = func_metadata(complex_arguments_fn) actual_schema = meta.arg_model.model_json_schema() - + # Create a copy of the actual schema to normalize normalized_schema = actual_schema.copy() - + # Normalize the my_model_a_with_default field to handle both pydantic formats - if 'allOf' in actual_schema['properties']['my_model_a_with_default']: - normalized_schema['properties']['my_model_a_with_default'] = { - '$ref': '#/$defs/SomeInputModelA', - 'default': {} + if "allOf" in actual_schema["properties"]["my_model_a_with_default"]: + normalized_schema["properties"]["my_model_a_with_default"] = { + "$ref": "#/$defs/SomeInputModelA", + "default": {}, } - + assert normalized_schema == { "$defs": { "InnerModel": { From fddba007230bf9fb94a6e2595f0c67ef2f857815 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 11 Feb 2025 12:26:32 +0000 Subject: [PATCH 22/37] refactor: improve server context management with AsyncExitStack Replace nested context managers with AsyncExitStack to ensure proper cleanup order during server shutdown and make the code more maintainable. --- src/mcp/server/lowlevel/server.py | 60 ++++++++++++++++--------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 28942cf8..a4a8510c 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -470,36 +470,40 @@ class Server(Generic[LifespanResultT]): raise_exceptions: bool = False, ): with warnings.catch_warnings(record=True) as w: - async with self.lifespan(self) as lifespan_context: - async with ServerSession( - read_stream, write_stream, initialization_options - ) as session: - async for message in session.incoming_messages: - logger.debug(f"Received message: {message}") + from contextlib import AsyncExitStack - match message: - case ( - RequestResponder( - request=types.ClientRequest(root=req) - ) as responder - ): - with responder: - await self._handle_request( - message, - req, - session, - lifespan_context, - raise_exceptions, - ) - case types.ClientNotification(root=notify): - await self._handle_notification(notify) + async with AsyncExitStack() as stack: + lifespan_context = await stack.enter_async_context(self.lifespan(self)) + session = await stack.enter_async_context( + ServerSession(read_stream, write_stream, initialization_options) + ) - for warning in w: - logger.info( - "Warning: %s: %s", - warning.category.__name__, - warning.message, - ) + async for message in session.incoming_messages: + logger.debug(f"Received message: {message}") + + match message: + case ( + RequestResponder( + request=types.ClientRequest(root=req) + ) as responder + ): + with responder: + await self._handle_request( + message, + req, + session, + lifespan_context, + raise_exceptions, + ) + case types.ClientNotification(root=notify): + await self._handle_notification(notify) + + for warning in w: + logger.info( + "Warning: %s: %s", + warning.category.__name__, + warning.message, + ) async def _handle_request( self, From 41af509bcbfc4bce6165aa9e3c69bc85a7bd1627 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Wed, 12 Feb 2025 16:32:21 +0000 Subject: [PATCH 23/37] Add test to verify request ID preservation This test ensures that the server properly preserves and returns the same request ID in responses, which is a fundamental part of the JSON-RPC protocol. The test initializes the server, sends requests with custom IDs, and verifies that these IDs are correctly returned. Github-Issue:#192 --- tests/issues/test_192_request_id.py | 88 +++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/issues/test_192_request_id.py diff --git a/tests/issues/test_192_request_id.py b/tests/issues/test_192_request_id.py new file mode 100644 index 00000000..628f00f9 --- /dev/null +++ b/tests/issues/test_192_request_id.py @@ -0,0 +1,88 @@ +import anyio +import pytest + +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions +from mcp.types import ( + LATEST_PROTOCOL_VERSION, + ClientCapabilities, + Implementation, + InitializeRequestParams, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + NotificationParams, +) + + +@pytest.mark.anyio +async def test_request_id_match() -> None: + """Test that the server preserves request IDs in responses.""" + server = Server("test") + custom_request_id = "test-123" + + # Create memory streams for communication + client_writer, client_reader = anyio.create_memory_object_stream(1) + server_writer, server_reader = anyio.create_memory_object_stream(1) + + # Server task to process the request + async def run_server(): + async with client_reader, server_writer: + await server.run( + client_reader, + server_writer, + InitializationOptions( + server_name="test", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + raise_exceptions=True, + ) + + # Start server task + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + # Send initialize request + init_req = JSONRPCRequest( + id="init-1", + method="initialize", + params=InitializeRequestParams( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ClientCapabilities(), + clientInfo=Implementation(name="test-client", version="1.0.0"), + ).model_dump(by_alias=True, exclude_none=True), + jsonrpc="2.0", + ) + + await client_writer.send(JSONRPCMessage(root=init_req)) + await server_reader.receive() # Get init response but don't need to check it + + # Send initialized notification + initialized_notification = JSONRPCNotification( + method="notifications/initialized", + params=NotificationParams().model_dump(by_alias=True, exclude_none=True), + jsonrpc="2.0", + ) + await client_writer.send(JSONRPCMessage(root=initialized_notification)) + + # Send ping request with custom ID + ping_request = JSONRPCRequest( + id=custom_request_id, method="ping", params={}, jsonrpc="2.0" + ) + + await client_writer.send(JSONRPCMessage(root=ping_request)) + + # Read response + response = await server_reader.receive() + + # Verify response ID matches request ID + assert ( + response.root.id == custom_request_id + ), "Response ID should match request ID" + + # Cancel server task + tg.cancel_scope.cancel() From 4d3e05f6f6104e7e189f29ea02068a3f2025324f Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Wed, 12 Feb 2025 22:12:09 +0000 Subject: [PATCH 24/37] refactor: improve lifespan context typing and documentation - Add proper generic parameter for lifespan context type - Update README with TypedDict example for strong typing - Fix context variable initialization in server - Improve property return type safety - Remove redundant documentation - Ensure compatibility with existing tests --- README.md | 17 +++++++++++------ src/mcp/server/fastmcp/server.py | 2 +- src/mcp/server/lowlevel/server.py | 12 ++++++------ src/mcp/shared/context.py | 7 ++++--- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8de0d798..370b4f33 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,9 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you bui The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: ```python +# Add lifespan support for startup/shutdown with strong typing +from dataclasses import dataclass +from typing import AsyncIterator from mcp.server.fastmcp import FastMCP # Create a named server @@ -136,14 +139,17 @@ mcp = FastMCP("My App") # Specify dependencies for deployment and development mcp = FastMCP("My App", dependencies=["pandas", "numpy"]) -# Add lifespan support for startup/shutdown +@dataclass +class AppContext: + db: Database # Replace with your actual DB type + @asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]: - """Manage application lifecycle""" +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context""" try: # Initialize on startup await db.connect() - yield {"db": db} + yield AppContext(db=db) finally: # Cleanup on shutdown await db.disconnect() @@ -151,7 +157,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]: # Pass lifespan to server mcp = FastMCP("My App", lifespan=app_lifespan) -# Access lifespan context in tools +# Access type-safe lifespan context in tools @mcp.tool() def query_db(ctx: Context) -> str: """Tool that uses initialized resources""" @@ -387,7 +393,6 @@ async def query_db(name: str, arguments: dict) -> list: The lifespan API provides: - A way to initialize resources when the server starts and clean them up when it stops - Access to initialized resources through the request context in handlers -- Support for both low-level Server and FastMCP classes - Type-safe context passing between lifespan and request handlers ```python diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index bc341b40..5ae30a5c 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -100,7 +100,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]): lifespan: ( Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]] | None - ) = Field(None, description="Lifespan contexte manager") + ) = Field(None, description="Lifespan context manager") def lifespan_wrapper( diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index a4a8510c..643e1a27 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -85,7 +85,10 @@ from mcp.shared.session import RequestResponder logger = logging.getLogger(__name__) -request_ctx: contextvars.ContextVar[RequestContext[ServerSession]] = ( +LifespanResultT = TypeVar("LifespanResultT") + +# This will be properly typed in each Server instance's context +request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any]] = ( contextvars.ContextVar("request_ctx") ) @@ -102,9 +105,6 @@ class NotificationOptions: self.tools_changed = tools_changed -LifespanResultT = TypeVar("LifespanResultT") - - @asynccontextmanager async def lifespan(server: "Server") -> AsyncIterator[object]: """Default lifespan context manager that does nothing. @@ -212,7 +212,7 @@ class Server(Generic[LifespanResultT]): ) @property - def request_context(self) -> RequestContext[ServerSession]: + def request_context(self) -> RequestContext[ServerSession, LifespanResultT]: """If called outside of a request context, this will raise a LookupError.""" return request_ctx.get() @@ -510,7 +510,7 @@ class Server(Generic[LifespanResultT]): message: RequestResponder, req: Any, session: ServerSession, - lifespan_context: object, + lifespan_context: LifespanResultT, raise_exceptions: bool, ): logger.info(f"Processing request of type {type(req).__name__}") diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py index 50e5d519..a45fdacd 100644 --- a/src/mcp/shared/context.py +++ b/src/mcp/shared/context.py @@ -1,15 +1,16 @@ from dataclasses import dataclass -from typing import Any, Generic, TypeVar +from typing import Generic, TypeVar from mcp.shared.session import BaseSession from mcp.types import RequestId, RequestParams SessionT = TypeVar("SessionT", bound=BaseSession) +LifespanContextT = TypeVar("LifespanContextT") @dataclass -class RequestContext(Generic[SessionT]): +class RequestContext(Generic[SessionT, LifespanContextT]): request_id: RequestId meta: RequestParams.Meta | None session: SessionT - lifespan_context: Any + lifespan_context: LifespanContextT From e4503a72c872e11650d0c1fd00b5a4809c0ee854 Mon Sep 17 00:00:00 2001 From: Jerome Date: Thu, 13 Feb 2025 13:51:01 +1300 Subject: [PATCH 25/37] Added scratch/ to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bb25f9e4..54006f93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +scratch/ # Byte-compiled / optimized / DLL files __pycache__/ From 0b5644723be222ece868d5f892f8376b98e17ce8 Mon Sep 17 00:00:00 2001 From: Jerome Date: Thu, 13 Feb 2025 14:15:23 +1300 Subject: [PATCH 26/37] Added ignore revs (for gitlense) --- .git-blame-ignore-revs | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..e69de29b From 9abfe775cc7788e32cb446a0eb9cd0c4d214999c Mon Sep 17 00:00:00 2001 From: Jerome Date: Thu, 13 Feb 2025 15:22:23 +1300 Subject: [PATCH 27/37] Added failing test --- tests/server/fastmcp/test_concurrency.py | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/server/fastmcp/test_concurrency.py diff --git a/tests/server/fastmcp/test_concurrency.py b/tests/server/fastmcp/test_concurrency.py new file mode 100644 index 00000000..a56c0d30 --- /dev/null +++ b/tests/server/fastmcp/test_concurrency.py @@ -0,0 +1,49 @@ +import anyio +from pydantic import AnyUrl + +from mcp.server.fastmcp import FastMCP +from mcp.shared.memory import ( + create_connected_server_and_client_session as create_session, +) + +_sleep_time_seconds = 0.01 +_resource_name = "slow://slow_resource" + + +async def test_messages_are_executed_concurrently(): + server = FastMCP("test") + + @server.tool("sleep") + async def sleep_tool(): + await anyio.sleep(_sleep_time_seconds) + return "done" + + @server.resource(_resource_name) + async def slow_resource(): + await anyio.sleep(_sleep_time_seconds) + return "slow" + + async with create_session(server._mcp_server) as client_session: + start_time = anyio.current_time() + async with anyio.create_task_group() as tg: + for _ in range(10): + tg.start_soon(client_session.call_tool, "sleep") + tg.start_soon(client_session.read_resource, AnyUrl(_resource_name)) + + end_time = anyio.current_time() + + duration = end_time - start_time + assert duration < 3 * _sleep_time_seconds + print(duration) + + +def main(): + anyio.run(test_messages_are_executed_concurrently) + + +if __name__ == "__main__": + import logging + + logging.basicConfig(level=logging.DEBUG) + + main() From da53a97ed9bf22fd4642e72dff617869fc496178 Mon Sep 17 00:00:00 2001 From: Jerome Date: Thu, 13 Feb 2025 15:22:58 +1300 Subject: [PATCH 28/37] Made message handling concurrent --- src/mcp/server/lowlevel/server.py | 71 +++++++++++++++++-------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 643e1a27..27ca276b 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -68,9 +68,10 @@ import contextvars import logging import warnings from collections.abc import Awaitable, Callable -from contextlib import AbstractAsyncContextManager, asynccontextmanager +from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from typing import Any, AsyncIterator, Generic, Sequence, TypeVar +import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl @@ -458,6 +459,30 @@ class Server(Generic[LifespanResultT]): return decorator + async def _handle_message( + self, + message: RequestResponder[types.ClientRequest, types.ServerResult] + | types.ClientNotification + | Exception, + session: ServerSession, + lifespan_context: LifespanResultT, + raise_exceptions: bool = False, + ): + with warnings.catch_warnings(record=True) as w: + match message: + case ( + RequestResponder(request=types.ClientRequest(root=req)) as responder + ): + with responder: + await self._handle_request( + message, req, session, lifespan_context, raise_exceptions + ) + case types.ClientNotification(root=notify): + await self._handle_notification(notify) + + for warning in w: + logger.info(f"Warning: {warning.category.__name__}: {warning.message}") + async def run( self, read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], @@ -469,41 +494,23 @@ class Server(Generic[LifespanResultT]): # in-process servers. raise_exceptions: bool = False, ): - with warnings.catch_warnings(record=True) as w: - from contextlib import AsyncExitStack - - async with AsyncExitStack() as stack: - lifespan_context = await stack.enter_async_context(self.lifespan(self)) - session = await stack.enter_async_context( - ServerSession(read_stream, write_stream, initialization_options) - ) + async with AsyncExitStack() as stack: + lifespan_context = await stack.enter_async_context(self.lifespan(self)) + session = await stack.enter_async_context( + ServerSession(read_stream, write_stream, initialization_options) + ) + async with anyio.create_task_group() as tg: async for message in session.incoming_messages: logger.debug(f"Received message: {message}") - match message: - case ( - RequestResponder( - request=types.ClientRequest(root=req) - ) as responder - ): - with responder: - await self._handle_request( - message, - req, - session, - lifespan_context, - raise_exceptions, - ) - case types.ClientNotification(root=notify): - await self._handle_notification(notify) - - for warning in w: - logger.info( - "Warning: %s: %s", - warning.category.__name__, - warning.message, - ) + tg.start_soon( + self._handle_message, + message, + session, + lifespan_context, + raise_exceptions, + ) async def _handle_request( self, From fbf4acc6793d1432dd2c7978d0c97e52ccf1819e Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 13 Feb 2025 10:21:31 +0000 Subject: [PATCH 29/37] fix: method ordering --- src/mcp/server/lowlevel/server.py | 48 +++++++++---------- .../test_188_concurrency.py} | 0 2 files changed, 24 insertions(+), 24 deletions(-) rename tests/{server/fastmcp/test_concurrency.py => issues/test_188_concurrency.py} (100%) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 27ca276b..c0008b32 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -459,30 +459,6 @@ class Server(Generic[LifespanResultT]): return decorator - async def _handle_message( - self, - message: RequestResponder[types.ClientRequest, types.ServerResult] - | types.ClientNotification - | Exception, - session: ServerSession, - lifespan_context: LifespanResultT, - raise_exceptions: bool = False, - ): - with warnings.catch_warnings(record=True) as w: - match message: - case ( - RequestResponder(request=types.ClientRequest(root=req)) as responder - ): - with responder: - await self._handle_request( - message, req, session, lifespan_context, raise_exceptions - ) - case types.ClientNotification(root=notify): - await self._handle_notification(notify) - - for warning in w: - logger.info(f"Warning: {warning.category.__name__}: {warning.message}") - async def run( self, read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], @@ -512,6 +488,30 @@ class Server(Generic[LifespanResultT]): raise_exceptions, ) + async def _handle_message( + self, + message: RequestResponder[types.ClientRequest, types.ServerResult] + | types.ClientNotification + | Exception, + session: ServerSession, + lifespan_context: LifespanResultT, + raise_exceptions: bool = False, + ): + with warnings.catch_warnings(record=True) as w: + match message: + case ( + RequestResponder(request=types.ClientRequest(root=req)) as responder + ): + with responder: + await self._handle_request( + message, req, session, lifespan_context, raise_exceptions + ) + case types.ClientNotification(root=notify): + await self._handle_notification(notify) + + for warning in w: + logger.info(f"Warning: {warning.category.__name__}: {warning.message}") + async def _handle_request( self, message: RequestResponder, diff --git a/tests/server/fastmcp/test_concurrency.py b/tests/issues/test_188_concurrency.py similarity index 100% rename from tests/server/fastmcp/test_concurrency.py rename to tests/issues/test_188_concurrency.py From b53e0902991b43d1e9f32774849b5c87b4d4c178 Mon Sep 17 00:00:00 2001 From: Henry Wildermuth Date: Thu, 13 Feb 2025 14:21:50 -0800 Subject: [PATCH 30/37] Update URL validation to allow file and other nonstandard schemas --- .../mcp_simple_resource/server.py | 7 +++--- src/mcp/types.py | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 11ba5692..0ec1d926 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -2,7 +2,7 @@ import anyio import click import mcp.types as types from mcp.server.lowlevel import Server -from pydantic import AnyUrl +from pydantic import FileUrl SAMPLE_RESOURCES = { "greeting": "Hello! This is a sample text resource.", @@ -26,7 +26,7 @@ def main(port: int, transport: str) -> int: async def list_resources() -> list[types.Resource]: return [ types.Resource( - uri=AnyUrl(f"file:///{name}.txt"), + uri=FileUrl(f"file:///{name}.txt"), name=name, description=f"A sample text resource named {name}", mimeType="text/plain", @@ -35,8 +35,7 @@ def main(port: int, transport: str) -> int: ] @app.read_resource() - async def read_resource(uri: AnyUrl) -> str | bytes: - assert uri.path is not None + async def read_resource(uri: FileUrl) -> str | bytes: name = uri.path.replace(".txt", "").lstrip("/") if name not in SAMPLE_RESOURCES: diff --git a/src/mcp/types.py b/src/mcp/types.py index d1157aa6..7d867bd3 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -1,7 +1,15 @@ -from typing import Annotated, Any, Callable, Generic, Literal, TypeAlias, TypeVar +from typing import ( + Annotated, + Any, + Callable, + Generic, + Literal, + TypeAlias, + TypeVar, +) from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel -from pydantic.networks import AnyUrl +from pydantic.networks import AnyUrl, UrlConstraints """ Model Context Protocol bindings for Python @@ -353,7 +361,7 @@ class Annotations(BaseModel): class Resource(BaseModel): """A known resource that the server is capable of reading.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """The URI of this resource.""" name: str """A human-readable name for this resource.""" @@ -415,7 +423,7 @@ class ListResourceTemplatesResult(PaginatedResult): class ReadResourceRequestParams(RequestParams): """Parameters for reading a resource.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """ The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. @@ -433,7 +441,7 @@ class ReadResourceRequest(Request): class ResourceContents(BaseModel): """The contents of a specific resource or sub-resource.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """The URI of this resource.""" mimeType: str | None = None """The MIME type of this resource, if known.""" @@ -476,7 +484,7 @@ class ResourceListChangedNotification(Notification): class SubscribeRequestParams(RequestParams): """Parameters for subscribing to a resource.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """ The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. @@ -497,7 +505,7 @@ class SubscribeRequest(Request): class UnsubscribeRequestParams(RequestParams): """Parameters for unsubscribing from a resource.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """The URI of the resource to unsubscribe from.""" model_config = ConfigDict(extra="allow") @@ -515,7 +523,7 @@ class UnsubscribeRequest(Request): class ResourceUpdatedNotificationParams(NotificationParams): """Parameters for resource update notifications.""" - uri: AnyUrl + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """ The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. From 106619967b530b85fdcc35134e46575719c4b234 Mon Sep 17 00:00:00 2001 From: Randall Nortman Date: Mon, 10 Feb 2025 08:10:24 -0500 Subject: [PATCH 31/37] Force stdin/stdout encoding to UTF-8 The character encoding of the stdin/stdout streams in Python is platform- dependent. On Windows it will be something weird, like CP437 or CP1252, depending on the locale. This change ensures that no matter the platform, UTF-8 is used. --- src/mcp/server/stdio.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index d74d6bc4..0e0e4912 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -20,6 +20,7 @@ Example usage: import sys from contextlib import asynccontextmanager +from io import TextIOWrapper import anyio import anyio.lowlevel @@ -38,11 +39,13 @@ async def stdio_server( from the current process' stdin and writing to stdout. """ # Purposely not using context managers for these, as we don't want to close - # standard process handles. + # standard process handles. Encoding of stdin/stdout as text streams on + # python is platform-dependent (Windows is particularly problematic), so we + # re-wrap the underlying binary stream to ensure UTF-8. if not stdin: - stdin = anyio.wrap_file(sys.stdin) + stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8")) if not stdout: - stdout = anyio.wrap_file(sys.stdout) + stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] From ff22f483656fd083cc3bcc5503ced2ac57779638 Mon Sep 17 00:00:00 2001 From: Jerome Date: Thu, 20 Feb 2025 10:49:43 +0000 Subject: [PATCH 32/37] Add client handling for sampling, list roots, ping (#218) Adds sampling and list roots callbacks to the ClientSession, allowing the client to handle requests from the server. Co-authored-by: TerminalMan <84923604+SecretiveShell@users.noreply.github.com> Co-authored-by: David Soria Parra --- README.md | 14 +++- src/mcp/client/session.py | 98 +++++++++++++++++++++--- src/mcp/shared/memory.py | 6 +- tests/client/test_list_roots_callback.py | 70 +++++++++++++++++ tests/client/test_sampling_callback.py | 73 ++++++++++++++++++ tests/client/test_stdio.py | 7 +- 6 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 tests/client/test_list_roots_callback.py create mode 100644 tests/client/test_sampling_callback.py diff --git a/README.md b/README.md index 370b4f33..bdbc9bca 100644 --- a/README.md +++ b/README.md @@ -476,9 +476,21 @@ server_params = StdioServerParameters( env=None # Optional environment variables ) +# Optional: create a sampling callback +async def handle_sampling_message(message: types.CreateMessageRequestParams) -> types.CreateMessageResult: + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text="Hello, world! from model", + ), + model="gpt-3.5-turbo", + stopReason="endTurn", + ) + async def run(): async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: + async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: # Initialize the connection await session.initialize() diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 4858ede5..37036e2b 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,13 +1,51 @@ from datetime import timedelta +from typing import Any, Protocol from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl +from pydantic import AnyUrl, TypeAdapter import mcp.types as types -from mcp.shared.session import BaseSession +from mcp.shared.context import RequestContext +from mcp.shared.session import BaseSession, RequestResponder from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +class SamplingFnT(Protocol): + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult | types.ErrorData: ... + + +class ListRootsFnT(Protocol): + async def __call__( + self, context: RequestContext["ClientSession", Any] + ) -> types.ListRootsResult | types.ErrorData: ... + + +async def _default_sampling_callback( + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, +) -> types.CreateMessageResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Sampling not supported", + ) + + +async def _default_list_roots_callback( + context: RequestContext["ClientSession", Any], +) -> types.ListRootsResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="List roots not supported", + ) + + +ClientResponse = TypeAdapter(types.ClientResult | types.ErrorData) + + class ClientSession( BaseSession[ types.ClientRequest, @@ -22,6 +60,8 @@ class ClientSession( read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], write_stream: MemoryObjectSendStream[types.JSONRPCMessage], read_timeout_seconds: timedelta | None = None, + sampling_callback: SamplingFnT | None = None, + list_roots_callback: ListRootsFnT | None = None, ) -> None: super().__init__( read_stream, @@ -30,8 +70,24 @@ class ClientSession( types.ServerNotification, read_timeout_seconds=read_timeout_seconds, ) + self._sampling_callback = sampling_callback or _default_sampling_callback + self._list_roots_callback = list_roots_callback or _default_list_roots_callback async def initialize(self) -> types.InitializeResult: + sampling = ( + types.SamplingCapability() if self._sampling_callback is not None else None + ) + roots = ( + types.RootsCapability( + # TODO: Should this be based on whether we + # _will_ send notifications, or only whether + # they're supported? + listChanged=True, + ) + if self._list_roots_callback is not None + else None + ) + result = await self.send_request( types.ClientRequest( types.InitializeRequest( @@ -39,14 +95,9 @@ class ClientSession( params=types.InitializeRequestParams( protocolVersion=types.LATEST_PROTOCOL_VERSION, capabilities=types.ClientCapabilities( - sampling=None, + sampling=sampling, experimental=None, - roots=types.RootsCapability( - # TODO: Should this be based on whether we - # _will_ send notifications, or only whether - # they're supported? - listChanged=True - ), + roots=roots, ), clientInfo=types.Implementation(name="mcp", version="0.1.0"), ), @@ -243,3 +294,32 @@ class ClientSession( ) ) ) + + async def _received_request( + self, responder: RequestResponder[types.ServerRequest, types.ClientResult] + ) -> None: + ctx = RequestContext[ClientSession, Any]( + request_id=responder.request_id, + meta=responder.request_meta, + session=self, + lifespan_context=None, + ) + + match responder.request.root: + case types.CreateMessageRequest(params=params): + with responder: + response = await self._sampling_callback(ctx, params) + client_response = ClientResponse.validate_python(response) + await responder.respond(client_response) + + case types.ListRootsRequest(): + with responder: + response = await self._list_roots_callback(ctx) + client_response = ClientResponse.validate_python(response) + await responder.respond(client_response) + + case types.PingRequest(): + with responder: + return await responder.respond( + types.ClientResult(root=types.EmptyResult()) + ) diff --git a/src/mcp/shared/memory.py b/src/mcp/shared/memory.py index 72549925..ae6b0be5 100644 --- a/src/mcp/shared/memory.py +++ b/src/mcp/shared/memory.py @@ -9,7 +9,7 @@ from typing import AsyncGenerator import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp.client.session import ClientSession +from mcp.client.session import ClientSession, ListRootsFnT, SamplingFnT from mcp.server import Server from mcp.types import JSONRPCMessage @@ -54,6 +54,8 @@ async def create_client_server_memory_streams() -> ( async def create_connected_server_and_client_session( server: Server, read_timeout_seconds: timedelta | None = None, + sampling_callback: SamplingFnT | None = None, + list_roots_callback: ListRootsFnT | None = None, raise_exceptions: bool = False, ) -> AsyncGenerator[ClientSession, None]: """Creates a ClientSession that is connected to a running MCP server.""" @@ -80,6 +82,8 @@ async def create_connected_server_and_client_session( read_stream=client_read, write_stream=client_write, read_timeout_seconds=read_timeout_seconds, + sampling_callback=sampling_callback, + list_roots_callback=list_roots_callback, ) as client_session: await client_session.initialize() yield client_session diff --git a/tests/client/test_list_roots_callback.py b/tests/client/test_list_roots_callback.py new file mode 100644 index 00000000..384e7676 --- /dev/null +++ b/tests/client/test_list_roots_callback.py @@ -0,0 +1,70 @@ +import pytest +from pydantic import FileUrl + +from mcp.client.session import ClientSession +from mcp.server.fastmcp.server import Context +from mcp.shared.context import RequestContext +from mcp.shared.memory import ( + create_connected_server_and_client_session as create_session, +) +from mcp.types import ( + ListRootsResult, + Root, + TextContent, +) + + +@pytest.mark.anyio +async def test_list_roots_callback(): + from mcp.server.fastmcp import FastMCP + + server = FastMCP("test") + + callback_return = ListRootsResult( + roots=[ + Root( + uri=FileUrl("file://users/fake/test"), + name="Test Root 1", + ), + Root( + uri=FileUrl("file://users/fake/test/2"), + name="Test Root 2", + ), + ] + ) + + async def list_roots_callback( + context: RequestContext[ClientSession, None], + ) -> ListRootsResult: + return callback_return + + @server.tool("test_list_roots") + async def test_list_roots(context: Context, message: str): + roots = await context.session.list_roots() + assert roots == callback_return + return True + + # Test with list_roots callback + async with create_session( + server._mcp_server, list_roots_callback=list_roots_callback + ) as client_session: + # Make a request to trigger sampling callback + result = await client_session.call_tool( + "test_list_roots", {"message": "test message"} + ) + assert result.isError is False + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "true" + + # Test without list_roots callback + async with create_session(server._mcp_server) as client_session: + # Make a request to trigger sampling callback + result = await client_session.call_tool( + "test_list_roots", {"message": "test message"} + ) + assert result.isError is True + assert isinstance(result.content[0], TextContent) + assert ( + result.content[0].text + == "Error executing tool test_list_roots: List roots not supported" + ) diff --git a/tests/client/test_sampling_callback.py b/tests/client/test_sampling_callback.py new file mode 100644 index 00000000..ba586d4a --- /dev/null +++ b/tests/client/test_sampling_callback.py @@ -0,0 +1,73 @@ +import pytest + +from mcp.client.session import ClientSession +from mcp.shared.context import RequestContext +from mcp.shared.memory import ( + create_connected_server_and_client_session as create_session, +) +from mcp.types import ( + CreateMessageRequestParams, + CreateMessageResult, + SamplingMessage, + TextContent, +) + + +@pytest.mark.anyio +async def test_sampling_callback(): + from mcp.server.fastmcp import FastMCP + + server = FastMCP("test") + + callback_return = CreateMessageResult( + role="assistant", + content=TextContent( + type="text", text="This is a response from the sampling callback" + ), + model="test-model", + stopReason="endTurn", + ) + + async def sampling_callback( + context: RequestContext[ClientSession, None], + params: CreateMessageRequestParams, + ) -> CreateMessageResult: + return callback_return + + @server.tool("test_sampling") + async def test_sampling_tool(message: str): + value = await server.get_context().session.create_message( + messages=[ + SamplingMessage( + role="user", content=TextContent(type="text", text=message) + ) + ], + max_tokens=100, + ) + assert value == callback_return + return True + + # Test with sampling callback + async with create_session( + server._mcp_server, sampling_callback=sampling_callback + ) as client_session: + # Make a request to trigger sampling callback + result = await client_session.call_tool( + "test_sampling", {"message": "Test message for sampling"} + ) + assert result.isError is False + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "true" + + # Test without sampling callback + async with create_session(server._mcp_server) as client_session: + # Make a request to trigger sampling callback + result = await client_session.call_tool( + "test_sampling", {"message": "Test message for sampling"} + ) + assert result.isError is True + assert isinstance(result.content[0], TextContent) + assert ( + result.content[0].text + == "Error executing tool test_sampling: Sampling not supported" + ) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 0bdec72d..95747ffd 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -1,12 +1,17 @@ +import shutil + import pytest from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse +tee: str = shutil.which("tee") # type: ignore + @pytest.mark.anyio +@pytest.mark.skipif(tee is None, reason="could not find tee command") async def test_stdio_client(): - server_parameters = StdioServerParameters(command="/usr/bin/tee") + server_parameters = StdioServerParameters(command=tee) async with stdio_client(server_parameters) as (read_stream, write_stream): # Test sending and receiving messages From 10a85e452d90febd1e13de1bbedc3badca233dfa Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 20 Feb 2025 11:04:29 +0000 Subject: [PATCH 33/37] fix: mark test as pytest.mark.anyio --- tests/issues/test_188_concurrency.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/issues/test_188_concurrency.py b/tests/issues/test_188_concurrency.py index a56c0d30..2aa6c49c 100644 --- a/tests/issues/test_188_concurrency.py +++ b/tests/issues/test_188_concurrency.py @@ -1,4 +1,5 @@ import anyio +import pytest from pydantic import AnyUrl from mcp.server.fastmcp import FastMCP @@ -10,6 +11,7 @@ _sleep_time_seconds = 0.01 _resource_name = "slow://slow_resource" +@pytest.mark.anyio async def test_messages_are_executed_concurrently(): server = FastMCP("test") From a50cf92d3a2aa9b6835826255d01d4e9aeb3cde1 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 20 Feb 2025 11:04:46 +0000 Subject: [PATCH 34/37] fix: ruff format --- src/mcp/client/session.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 37036e2b..c1cc5b5f 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -21,7 +21,7 @@ class SamplingFnT(Protocol): class ListRootsFnT(Protocol): async def __call__( self, context: RequestContext["ClientSession", Any] - ) -> types.ListRootsResult | types.ErrorData: ... + ) -> types.ListRootsResult | types.ErrorData: ... async def _default_sampling_callback( @@ -36,7 +36,7 @@ async def _default_sampling_callback( async def _default_list_roots_callback( context: RequestContext["ClientSession", Any], -) -> types.ListRootsResult | types.ErrorData: +) -> types.ListRootsResult | types.ErrorData: return types.ErrorData( code=types.INVALID_REQUEST, message="List roots not supported", @@ -317,7 +317,7 @@ class ClientSession( response = await self._list_roots_callback(ctx) client_response = ClientResponse.validate_python(response) await responder.respond(client_response) - + case types.PingRequest(): with responder: return await responder.respond( From 2628e01f4b892b9c59f3bdd2abbad718c121c87a Mon Sep 17 00:00:00 2001 From: Jerome Date: Thu, 20 Feb 2025 11:07:54 +0000 Subject: [PATCH 35/37] Merge pull request #217 from modelcontextprotocol/jerome/fix/request-context-typing Updated typing on request context for the server to use server session --- src/mcp/server/fastmcp/server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 5ae30a5c..e08a161c 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -34,6 +34,7 @@ from mcp.server.lowlevel.server import ( from mcp.server.lowlevel.server import ( lifespan as default_lifespan, ) +from mcp.server.session import ServerSession from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.shared.context import RequestContext @@ -597,7 +598,7 @@ class Context(BaseModel): The context is optional - tools that don't need it can omit the parameter. """ - _request_context: RequestContext | None + _request_context: RequestContext[ServerSession, Any] | None _fastmcp: FastMCP | None def __init__( From b1942b31c49102b929e2d19569feaf65b4e3e89c Mon Sep 17 00:00:00 2001 From: David Soria Parra <167242713+dsp-ant@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:31:26 +0000 Subject: [PATCH 36/37] Fix #177: Returning multiple tool results (#222) * feat: allow lowlevel servers to return a list of resources The resource/read message in MCP allows of multiple resources to be returned. However, in the SDK we do not allow this. This change is such that we allow returning multiple resource in the lowlevel API if needed. However in FastMCP we stick to one, since a FastMCP resource defines the mime_type in the decorator and hence a resource cannot dynamically return different mime_typed resources. It also is just the better default to only return one resource. However in the lowlevel API we will allow this. Strictly speaking this is not a BC break since the new return value is additive, but if people subclassed server, it will break them. * feat: lower the type requriements for call_tool to Iterable --- src/mcp/server/fastmcp/server.py | 8 +++--- src/mcp/server/lowlevel/server.py | 25 ++++++++++++----- tests/issues/test_141_resource_templates.py | 6 +++-- tests/issues/test_152_resource_mime_type.py | 8 +++--- .../fastmcp/servers/test_file_server.py | 15 ++++++++--- tests/server/fastmcp/test_server.py | 5 +++- tests/server/test_read_resource.py | 27 +++++++++++-------- 7 files changed, 62 insertions(+), 32 deletions(-) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index e08a161c..122acebb 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -3,7 +3,7 @@ import inspect import json import re -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Iterable from contextlib import ( AbstractAsyncContextManager, asynccontextmanager, @@ -236,7 +236,7 @@ class FastMCP: for template in templates ] - async def read_resource(self, uri: AnyUrl | str) -> ReadResourceContents: + async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]: """Read a resource by URI.""" resource = await self._resource_manager.get_resource(uri) @@ -245,7 +245,7 @@ class FastMCP: try: content = await resource.read() - return ReadResourceContents(content=content, mime_type=resource.mime_type) + return [ReadResourceContents(content=content, mime_type=resource.mime_type)] except Exception as e: logger.error(f"Error reading resource {uri}: {e}") raise ResourceError(str(e)) @@ -649,7 +649,7 @@ class Context(BaseModel): progress_token=progress_token, progress=progress, total=total ) - async def read_resource(self, uri: str | AnyUrl) -> ReadResourceContents: + async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: """Read a resource by URI. Args: diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index c0008b32..25e94365 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -67,9 +67,9 @@ messages from the client. import contextvars import logging import warnings -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, AsyncIterator, Generic, Sequence, TypeVar +from typing import Any, AsyncIterator, Generic, TypeVar import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream @@ -279,7 +279,9 @@ class Server(Generic[LifespanResultT]): def read_resource(self): def decorator( - func: Callable[[AnyUrl], Awaitable[str | bytes | ReadResourceContents]], + func: Callable[ + [AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]] + ], ): logger.debug("Registering handler for ReadResourceRequest") @@ -307,13 +309,22 @@ class Server(Generic[LifespanResultT]): case str() | bytes() as data: warnings.warn( "Returning str or bytes from read_resource is deprecated. " - "Use ReadResourceContents instead.", + "Use Iterable[ReadResourceContents] instead.", DeprecationWarning, stacklevel=2, ) content = create_content(data, None) - case ReadResourceContents() as contents: - content = create_content(contents.content, contents.mime_type) + case Iterable() as contents: + contents_list = [ + create_content(content_item.content, content_item.mime_type) + for content_item in contents + if isinstance(content_item, ReadResourceContents) + ] + return types.ServerResult( + types.ReadResourceResult( + contents=contents_list, + ) + ) case _: raise ValueError( f"Unexpected return type from read_resource: {type(result)}" @@ -387,7 +398,7 @@ class Server(Generic[LifespanResultT]): func: Callable[ ..., Awaitable[ - Sequence[ + Iterable[ types.TextContent | types.ImageContent | types.EmbeddedResource ] ], diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index d6526e9f..3c17cd55 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -51,8 +51,10 @@ async def test_resource_template_edge_cases(): # Verify valid template works result = await mcp.read_resource("resource://users/123/posts/456") - assert result.content == "Post 456 by user 123" - assert result.mime_type == "text/plain" + result_list = list(result) + assert len(result_list) == 1 + assert result_list[0].content == "Post 456 by user 123" + assert result_list[0].mime_type == "text/plain" # Verify invalid parameters raise error with pytest.raises(ValueError, match="Unknown resource"): diff --git a/tests/issues/test_152_resource_mime_type.py b/tests/issues/test_152_resource_mime_type.py index 7a1b6606..1143195e 100644 --- a/tests/issues/test_152_resource_mime_type.py +++ b/tests/issues/test_152_resource_mime_type.py @@ -99,11 +99,11 @@ async def test_lowlevel_resource_mime_type(): @server.read_resource() async def handle_read_resource(uri: AnyUrl): if str(uri) == "test://image": - return ReadResourceContents(content=base64_string, mime_type="image/png") + return [ReadResourceContents(content=base64_string, mime_type="image/png")] elif str(uri) == "test://image_bytes": - return ReadResourceContents( - content=bytes(image_bytes), mime_type="image/png" - ) + return [ + ReadResourceContents(content=bytes(image_bytes), mime_type="image/png") + ] raise Exception(f"Resource not found: {uri}") # Test that resources are listed with correct mime type diff --git a/tests/server/fastmcp/servers/test_file_server.py b/tests/server/fastmcp/servers/test_file_server.py index edaaa159..c51ecb25 100644 --- a/tests/server/fastmcp/servers/test_file_server.py +++ b/tests/server/fastmcp/servers/test_file_server.py @@ -88,7 +88,10 @@ async def test_list_resources(mcp: FastMCP): @pytest.mark.anyio async def test_read_resource_dir(mcp: FastMCP): - res = await mcp.read_resource("dir://test_dir") + res_iter = await mcp.read_resource("dir://test_dir") + res_list = list(res_iter) + assert len(res_list) == 1 + res = res_list[0] assert res.mime_type == "text/plain" files = json.loads(res.content) @@ -102,7 +105,10 @@ async def test_read_resource_dir(mcp: FastMCP): @pytest.mark.anyio async def test_read_resource_file(mcp: FastMCP): - res = await mcp.read_resource("file://test_dir/example.py") + res_iter = await mcp.read_resource("file://test_dir/example.py") + res_list = list(res_iter) + assert len(res_list) == 1 + res = res_list[0] assert res.content == "print('hello world')" @@ -119,5 +125,8 @@ async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): await mcp.call_tool( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) - res = await mcp.read_resource("file://test_dir/example.py") + res_iter = await mcp.read_resource("file://test_dir/example.py") + res_list = list(res_iter) + assert len(res_list) == 1 + res = res_list[0] assert res.content == "File not found" diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index d90e9939..5d375ccc 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -581,7 +581,10 @@ class TestContextInjection: @mcp.tool() async def tool_with_resource(ctx: Context) -> str: - r = await ctx.read_resource("test://data") + r_iter = await ctx.read_resource("test://data") + r_list = list(r_iter) + assert len(r_list) == 1 + r = r_list[0] return f"Read resource: {r.content} with mime type {r.mime_type}" async with client_session(mcp._mcp_server) as client: diff --git a/tests/server/test_read_resource.py b/tests/server/test_read_resource.py index de00bc3d..469eef85 100644 --- a/tests/server/test_read_resource.py +++ b/tests/server/test_read_resource.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from pathlib import Path from tempfile import NamedTemporaryFile @@ -26,8 +27,8 @@ async def test_read_resource_text(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> ReadResourceContents: - return ReadResourceContents(content="Hello World", mime_type="text/plain") + async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ReadResourceContents(content="Hello World", mime_type="text/plain")] # Get the handler directly from the server handler = server.request_handlers[types.ReadResourceRequest] @@ -54,10 +55,12 @@ async def test_read_resource_binary(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> ReadResourceContents: - return ReadResourceContents( - content=b"Hello World", mime_type="application/octet-stream" - ) + async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ + ReadResourceContents( + content=b"Hello World", mime_type="application/octet-stream" + ) + ] # Get the handler directly from the server handler = server.request_handlers[types.ReadResourceRequest] @@ -83,11 +86,13 @@ async def test_read_resource_default_mime(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> ReadResourceContents: - return ReadResourceContents( - content="Hello World", - # No mime_type specified, should default to text/plain - ) + async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ + ReadResourceContents( + content="Hello World", + # No mime_type specified, should default to text/plain + ) + ] # Get the handler directly from the server handler = server.request_handlers[types.ReadResourceRequest] From 775f87981300660ee957b63c2a14b448ab9c3675 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 20 Feb 2025 21:40:35 +0000 Subject: [PATCH 37/37] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 05494d85..48fa0bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp" -version = "1.3.0.dev0" +version = "1.4.0.dev0" description = "Model Context Protocol SDK" readme = "README.md" requires-python = ">=3.10"