From 34cf688b87b6296cddb28f3fc01749922499a9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=B1zgar=20Ozan?= Date: Tue, 6 Oct 2026 00:44:08 +0300 Subject: [PATCH 1/2] fix(custom-tools): keep handler annotations through wrappers on Python 3.14 Assigning __annotations__ sets __annotate__ to None on 3.14, and functools.wraps now copies __annotate__, so the wrapped handler had no type hints and pydantic dropped every custom tool with parameters. Fixes #1430 --- Server/src/services/custom_tool_service.py | 7 ++- ...custom_tool_service_global_registration.py | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 Server/tests/test_custom_tool_service_global_registration.py diff --git a/Server/src/services/custom_tool_service.py b/Server/src/services/custom_tool_service.py index f81b7c70e..a31d9de2c 100644 --- a/Server/src/services/custom_tool_service.py +++ b/Server/src/services/custom_tool_service.py @@ -409,7 +409,12 @@ async def _handler(ctx: Context, **kwargs) -> MCPResponse: _handler.__name__ = f"custom_tool_{definition.name}" _handler.__doc__ = definition.description or "" _handler.__signature__ = self._build_signature(definition) - _handler.__annotations__ = self._build_annotations(definition) + annotations = self._build_annotations(definition) + _handler.__annotations__ = annotations + # On Python 3.14, functools.wraps copies __annotate__ instead of __annotations__, + # and assigning __annotations__ sets __annotate__ to None. Without a real + # __annotate__ the logging/telemetry wrappers end up with no annotations. + _handler.__annotate__ = lambda format: dict(annotations) return _handler def _build_signature(self, definition: ToolDefinitionModel) -> inspect.Signature: diff --git a/Server/tests/test_custom_tool_service_global_registration.py b/Server/tests/test_custom_tool_service_global_registration.py new file mode 100644 index 000000000..4b9cb76c7 --- /dev/null +++ b/Server/tests/test_custom_tool_service_global_registration.py @@ -0,0 +1,44 @@ +import typing + +from models.models import ToolDefinitionModel, ToolParameterModel +from services.custom_tool_service import CustomToolService + + +class _RecordingMcp: + def __init__(self): + self.tools = {} + + def custom_route(self, _path, methods=None): # noqa: ARG002 + def _decorator(fn): + return fn + + return _decorator + + def tool(self, name, description=None): # noqa: ARG002 + def _decorator(fn): + self.tools[name] = fn + return fn + + return _decorator + + +def test_global_tool_keeps_parameter_annotations_through_wrappers(): + """FastMCP builds the tool schema from the registered function's type hints. If the + logging/telemetry wrappers lose them (Python 3.14), pydantic raises KeyError and + the tool is dropped.""" + mcp = _RecordingMcp() + service = CustomToolService(mcp) + definition = ToolDefinitionModel( + name="my_tool", + description="My tool", + parameters=[ + ToolParameterModel(name="action", type="string"), + ToolParameterModel(name="count", type="integer", required=False, default_value="3"), + ], + ) + + service.register_global_tools([definition]) + + hints = typing.get_type_hints(mcp.tools["my_tool"]) + assert hints["action"] is str + assert hints["count"] is int From 678a97d8d25261627d5f9f519a8857ccb7da9f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=B1zgar=20Ozan?= Date: Wed, 7 Oct 2026 08:16:05 +0300 Subject: [PATCH 2/2] ci: run the Python tests on 3.14 too Adds a 3.10/3.14 matrix to python-tests.yml so the custom tool fix in this PR is checked on the version where it broke. Coverage still uploads from the 3.10 job only, and the artifact name carries the version so the two jobs don't collide. --- .github/workflows/python-tests.yml | 16 +++++++++++----- website/docs/contributing/dev-setup.md | 2 +- website/docs/contributing/testing.md | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 8d3619cc2..f5d08fbb1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -28,8 +28,14 @@ on: jobs: test: - name: Run Python Tests + name: Run Python Tests (${{ matrix.python-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 3.10 is the oldest supported version; 3.14 catches changes like the lazy + # __annotations__ evaluation from PEP 649. + python-version: ["3.10", "3.14"] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -42,7 +48,7 @@ jobs: version: "latest" - name: Set up Python - run: uv python install 3.10 + run: uv python install ${{ matrix.python-version }} - name: Install dependencies run: | @@ -51,7 +57,7 @@ jobs: # re-resolving, so a dependency or version bump that forgot `uv lock` shows up here. # The dev extra is resolved in the lock too, so pytest runs against pinned versions # instead of whatever `uv pip install` would fetch today. - uv sync --locked --extra dev + uv sync --locked --extra dev --python ${{ matrix.python-version }} - name: Run tests with coverage run: | @@ -65,7 +71,7 @@ jobs: - name: Upload coverage reports uses: codecov/codecov-action@v4 - if: always() + if: always() && matrix.python-version == '3.10' # fail_ci_if_error: false does not cover a failed download: v4 crashes on the https # error before it reads that input. This job gates both release pipelines, and a # Codecov outage must not stop a release. @@ -80,7 +86,7 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: pytest-results + name: pytest-results-${{ matrix.python-version }} path: | Server/.pytest_cache/ Server/tests/ diff --git a/website/docs/contributing/dev-setup.md b/website/docs/contributing/dev-setup.md index 6cd8fb3fa..875bd7169 100644 --- a/website/docs/contributing/dev-setup.md +++ b/website/docs/contributing/dev-setup.md @@ -256,7 +256,7 @@ GitHub withholds repository secrets from workflow runs triggered by a fork's pul | Check | On a fork PR | |---|---| | `Compile MCPForUnity (win/osx/linux)` | **Runs.** License-free compile across win/osx/linux — real signal. | -| `Run Python Tests` | **Runs.** Real signal. | +| `Run Python Tests (3.10/3.14)` | **Runs.** Real signal. | | `Check docs reference is fresh` | **Runs.** Real signal. | | `Test in editmode on Unity ` | **Skipped** — no Editor booted, no C# compiled by this job. | | `e2e-bridge` | **Skipped** — no Editor booted, no tool call exercised. | diff --git a/website/docs/contributing/testing.md b/website/docs/contributing/testing.md index 2cdcd3e34..cab547864 100644 --- a/website/docs/contributing/testing.md +++ b/website/docs/contributing/testing.md @@ -25,7 +25,7 @@ cd Server && uv run pytest tests/test_manage_material.py -v cd Server && uv run pytest tests/ -k "test_create_material" -v ``` -CI workflow: `.github/workflows/python-tests.yml`. Coverage is uploaded to Codecov on every run. +CI workflow: `.github/workflows/python-tests.yml`. It runs the tests on Python 3.10 and 3.14; coverage is uploaded to Codecov from the 3.10 job. ### Adding a Python test