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/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 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