diff --git a/.github/workflows/build.yml b/.github/workflows/build.yaml similarity index 92% rename from .github/workflows/build.yml rename to .github/workflows/build.yaml index ab23c58..7addd1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yaml @@ -13,7 +13,7 @@ jobs: strategy: matrix: python-version: ['3.11', '3.12'] - uses: ./.github/workflows/reusable-python.yml + uses: ./.github/workflows/reusable-python.yaml with: python-version: ${{ matrix.python-version }} install-extras: test,typing diff --git a/.github/workflows/py-code-style.yml b/.github/workflows/py-code-style.yaml similarity index 76% rename from .github/workflows/py-code-style.yml rename to .github/workflows/py-code-style.yaml index 48897ef..48c0a9a 100644 --- a/.github/workflows/py-code-style.yml +++ b/.github/workflows/py-code-style.yaml @@ -13,8 +13,8 @@ on: - '.pre-commit-config.yaml' - 'ruff.toml' - '.ruff.toml' - - '.github/workflows/py-code-style.yml' - - '.github/workflows/reusable-python.yml' + - '.github/workflows/py-code-style.yaml' + - '.github/workflows/reusable-python.yaml' pull_request: branches: - main @@ -27,8 +27,8 @@ on: - '.pre-commit-config.yaml' - 'ruff.toml' - '.ruff.toml' - - '.github/workflows/py-code-style.yml' - - '.github/workflows/reusable-python.yml' + - '.github/workflows/py-code-style.yaml' + - '.github/workflows/reusable-python.yaml' workflow_dispatch: concurrency: @@ -37,7 +37,7 @@ concurrency: jobs: check-code-style: - uses: ./.github/workflows/reusable-python.yml + uses: ./.github/workflows/reusable-python.yaml with: python-version: '3.11' extra-packages: pre-commit diff --git a/.github/workflows/py-tests.yml b/.github/workflows/py-tests.yaml similarity index 77% rename from .github/workflows/py-tests.yml rename to .github/workflows/py-tests.yaml index 0506cf6..a41a3d0 100644 --- a/.github/workflows/py-tests.yml +++ b/.github/workflows/py-tests.yaml @@ -11,8 +11,8 @@ on: - 'setup.py' - 'requirements*.txt' - '**/*.py' - - '.github/workflows/py-tests.yml' - - '.github/workflows/reusable-python.yml' + - '.github/workflows/py-tests.yaml' + - '.github/workflows/reusable-python.yaml' pull_request: branches: - main @@ -23,8 +23,8 @@ on: - 'setup.py' - 'requirements*.txt' - '**/*.py' - - '.github/workflows/py-tests.yml' - - '.github/workflows/reusable-python.yml' + - '.github/workflows/py-tests.yaml' + - '.github/workflows/reusable-python.yaml' workflow_dispatch: @@ -34,7 +34,7 @@ jobs: max-parallel: 1 matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] - uses: ./.github/workflows/reusable-python.yml + uses: ./.github/workflows/reusable-python.yaml with: python-version: ${{ matrix.python-version }} install-extras: test diff --git a/.github/workflows/py-typing.yml b/.github/workflows/py-typing.yaml similarity index 76% rename from .github/workflows/py-typing.yml rename to .github/workflows/py-typing.yaml index 4f9f623..61cbda3 100644 --- a/.github/workflows/py-typing.yml +++ b/.github/workflows/py-typing.yaml @@ -12,8 +12,8 @@ on: - 'setup.py' - 'mypy.ini' - '.mypy.ini' - - '.github/workflows/py-typing.yml' - - '.github/workflows/reusable-python.yml' + - '.github/workflows/py-typing.yaml' + - '.github/workflows/reusable-python.yaml' pull_request: branches: - main @@ -25,8 +25,8 @@ on: - 'setup.py' - 'mypy.ini' - '.mypy.ini' - - '.github/workflows/py-typing.yml' - - '.github/workflows/reusable-python.yml' + - '.github/workflows/py-typing.yaml' + - '.github/workflows/reusable-python.yaml' workflow_dispatch: concurrency: @@ -35,7 +35,7 @@ concurrency: jobs: type-check: - uses: ./.github/workflows/reusable-python.yml + uses: ./.github/workflows/reusable-python.yaml with: python-version: '3.12' install-extras: typing diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b28edf2 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,105 @@ +# Copyright (c) 2023-2026 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +# Publishes the release a tag names to PyPI, with no stored token: PyPI +# trusts this workflow file (OIDC trusted publishing, through the `pypi` +# environment). The tag must name the version in agent_codemode/__version__.py; a +# version already published is skipped. See RELEASE.md. + +name: Release + +on: + push: + tags: + - 'v*' + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + outputs: + python: ${{ steps.plan.outputs.python }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check the tag names the version the package carries + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME#v}" + version="$(python -c "import re;print(re.search(r'__version__\s*=\s*[\"\']([^\"\']+)', open('agent_codemode/__version__.py').read())[1])")" + echo "tag=$tag version=$version" + test "$tag" = "$version" + + - name: Find whether it is published yet + id: plan + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + status="$(curl -sL -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/agent-codemode/$version/json")" + if [ "$status" = "200" ]; then + echo "PyPI: agent-codemode $version is already published, skipping" + echo "python=" >> "$GITHUB_OUTPUT" + else + echo "PyPI: agent-codemode $version will be published" + echo "python=yes" >> "$GITHUB_OUTPUT" + fi + + - name: Build + if: steps.plan.outputs.python != '' + run: | + set -euo pipefail + python -m pip install --upgrade pip build + python -m build --sdist --wheel --outdir dist . + ls -la dist + + - uses: actions/upload-artifact@v4 + if: steps.plan.outputs.python != '' + with: + name: python + path: dist/* + + pypi: + needs: build + if: needs.build.outputs.python != '' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + + release: + name: GitHub release + needs: [build, pypi] + if: always() && needs.build.result == 'success' && needs.pypi.result != 'failure' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Create the release with generated notes + run: | + version="${GITHUB_REF_NAME#v}" + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 && exit 0 + gh release create "$GITHUB_REF_NAME" --title "Release $version" --generate-notes \ + --notes "- [PyPI agent-codemode](https://pypi.org/project/agent-codemode/$version/)" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reusable-python.yml b/.github/workflows/reusable-python.yaml similarity index 100% rename from .github/workflows/reusable-python.yml rename to .github/workflows/reusable-python.yaml diff --git a/README.md b/README.md index 4e586d6..a0b2c19 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ agent = Agent( Expose the programmatic tools as an MCP server for any MCP-compatible client: ```python -from agent_codemode import codemode_server, configure_server +from agent_codemode import configure_server, run_server from agent_codemode import ToolRegistry, MCPServerConfig, CodeModeConfig # Create and configure registry with MCP servers to compose @@ -331,7 +331,7 @@ config = CodeModeConfig( ) configure_server(config=config, registry=registry) -codemode_server.run() +run_server() # stdio; run_server(transport="streamable-http", host=..., port=...) for HTTP ``` Or start with command line: @@ -469,14 +469,14 @@ When running in a sandbox, state can persist between `execute_code` calls within ## CI Workflows -This repository uses a reusable GitHub Actions workflow at `.github/workflows/reusable-python.yml`. +This repository uses a reusable GitHub Actions workflow at `.github/workflows/reusable-python.yaml`. The following workflows call it: -- `.github/workflows/build.yml` -- `.github/workflows/py-tests.yml` -- `.github/workflows/py-code-style.yml` -- `.github/workflows/py-typing.yml` +- `.github/workflows/build.yaml` +- `.github/workflows/py-tests.yaml` +- `.github/workflows/py-code-style.yaml` +- `.github/workflows/py-typing.yaml` Reusable workflow inputs: diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..47f6abb --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,35 @@ + + +# Making a release + +A tag releases `agent-codemode` to PyPI, with no stored token: PyPI trusts +`.github/workflows/release.yaml` through OIDC (trusted publishing, GitHub +environment `pypi`). The version lives in `agent_codemode/__version__.py`, which +hatch reads too. + +## Steps + +1. Bump `__version__` on a branch and open a pull request. + +1. Merge, then tag the merge commit and push the tag: + + ```bash + git checkout main && git pull + git tag vX.Y.Z + git push origin vX.Y.Z + ``` + +1. The `Release` workflow checks that the tag names the version, builds the + wheel and the sdist, publishes them unless PyPI already has that version, + and creates a GitHub release with generated notes. + +## Trusted publishing + +PyPI project `agent-codemode`: owner `datalayer`, repository `agent-codemode`, workflow +`release.yaml`, environment `pypi`. The registry matches the repository, +the workflow filename and the environment exactly; renaming any of them +means re-registering. diff --git a/agent_codemode/__init__.py b/agent_codemode/__init__.py index 79965cd..c6f8178 100644 --- a/agent_codemode/__init__.py +++ b/agent_codemode/__init__.py @@ -49,6 +49,7 @@ from .proxy.meta_tools import MetaToolProvider from .server import configure as configure_server from .server import mcp as codemode_server +from .server import run as run_server from .toolset import PYDANTIC_AI_AVAILABLE, CodemodeToolset from .types import ( CodeModeConfig, @@ -90,6 +91,7 @@ "configure_server", "parallel", "retry", + "run_server", "run_with_timeout", "setup_skills_directory", # Helpers (from agent_skills) diff --git a/agent_codemode/__version__.py b/agent_codemode/__version__.py index 801f68a..3785ba0 100644 --- a/agent_codemode/__version__.py +++ b/agent_codemode/__version__.py @@ -4,4 +4,4 @@ """Agent Codemode.""" -__version__ = "1.0.1" +__version__ = "1.1.1" diff --git a/agent_codemode/composition/executor.py b/agent_codemode/composition/executor.py index 959474e..3cee979 100644 --- a/agent_codemode/composition/executor.py +++ b/agent_codemode/composition/executor.py @@ -702,7 +702,10 @@ async def call_tool(tool_name: str, arguments: dict[str, Any]) -> Any: is_error = False if isinstance(result, dict): - is_error = result.get("isError", False) + is_error = result.get("isError", result.get("is_error", False)) + elif hasattr(result, "is_error"): + # A ``CallToolResult`` of mcp 2, whose fields are snake_case. + is_error = result.is_error elif hasattr(result, "isError"): is_error = result.isError diff --git a/agent_codemode/discovery/codegen.py b/agent_codemode/discovery/codegen.py index 7e43819..8d1051a 100644 --- a/agent_codemode/discovery/codegen.py +++ b/agent_codemode/discovery/codegen.py @@ -133,7 +133,10 @@ async def call_tool(tool_name: str, arguments: dict[str, Any]) -> Any: # Check for error response is_error = False if isinstance(result, dict): - is_error = result.get("isError", False) + is_error = result.get("isError", result.get("is_error", False)) + elif hasattr(result, "is_error"): + # A ``CallToolResult`` of mcp 2, whose fields are snake_case. + is_error = result.is_error elif hasattr(result, "isError"): is_error = result.isError diff --git a/agent_codemode/proxy/mcp_client.py b/agent_codemode/proxy/mcp_client.py index 036aa4c..c0741d2 100644 --- a/agent_codemode/proxy/mcp_client.py +++ b/agent_codemode/proxy/mcp_client.py @@ -124,11 +124,14 @@ async def _get_http_session(self): return self._http_session from mcp.client.session import ClientSession - from mcp.client.streamable_http import streamablehttp_client + from mcp.client.streamable_http import streamable_http_client - http_ctx = streamablehttp_client(self.url) + # mcp 2: the transport yields the two streams only; the session id + # callback of mcp 1 is gone, and headers or timeouts would be set on + # an ``httpx2.AsyncClient`` passed as ``http_client``. + http_ctx = streamable_http_client(self.url) self._http_ctx = http_ctx - read_stream, write_stream, _get_session_id = await http_ctx.__aenter__() + read_stream, write_stream = await http_ctx.__aenter__() http_session = ClientSession(read_stream, write_stream) self._http_session = http_session await http_session.__aenter__() diff --git a/agent_codemode/server.py b/agent_codemode/server.py index f265fd5..36402df 100644 --- a/agent_codemode/server.py +++ b/agent_codemode/server.py @@ -25,7 +25,7 @@ import anyio import mcp.types as types -from mcp.server.lowlevel import Server +from mcp.server import Server, ServerRequestContext from .composition.executor import CodeModeExecutor from .discovery.registry import ToolRegistry @@ -33,8 +33,8 @@ logger = logging.getLogger(__name__) -# Create the MCP server -mcp = Server("codemode") +# The MCP server is built at the end of this module, once its handlers +# exist: mcp 2's lowlevel ``Server`` takes them as constructor arguments. # Global instances (configured at startup) _registry: Optional[ToolRegistry] = None @@ -119,7 +119,7 @@ def _build_tools() -> list[types.Tool]: types.Tool( name=name, description=description, - inputSchema=parameters, + input_schema=parameters, ) ) @@ -129,7 +129,7 @@ def _build_tools() -> list[types.Tool]: types.Tool( name="save_skill", description="Save a reusable skill (code-based tool composition).", - inputSchema={ + input_schema={ "type": "object", "required": ["name", "code", "description"], "properties": { @@ -144,7 +144,7 @@ def _build_tools() -> list[types.Tool]: types.Tool( name="run_skill", description="Execute a saved skill.", - inputSchema={ + input_schema={ "type": "object", "required": ["name"], "properties": { @@ -156,7 +156,7 @@ def _build_tools() -> list[types.Tool]: types.Tool( name="list_skills", description="List available skills.", - inputSchema={ + input_schema={ "type": "object", "properties": { "tags": {"type": "array", "items": {"type": "string"}}, @@ -166,7 +166,7 @@ def _build_tools() -> list[types.Tool]: types.Tool( name="delete_skill", description="Delete a saved skill.", - inputSchema={ + input_schema={ "type": "object", "required": ["name"], "properties": { @@ -177,7 +177,7 @@ def _build_tools() -> list[types.Tool]: types.Tool( name="get_execution_history", description="Get recent tool execution history.", - inputSchema={ + input_schema={ "type": "object", "properties": { "limit": {"type": "integer", "default": 10}, @@ -187,7 +187,7 @@ def _build_tools() -> list[types.Tool]: types.Tool( name="add_mcp_server", description="Add a new MCP server to discover tools from.", - inputSchema={ + input_schema={ "type": "object", "required": ["name"], "properties": { @@ -588,25 +588,47 @@ async def handle_add_mcp_server(arguments: dict[str, Any]) -> dict[str, Any]: # ============================================================================= -@mcp.list_tools() -async def list_tools() -> list[types.Tool]: +async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: """Return the list of available tools.""" config = _config or CodeModeConfig() if config.allow_direct_tool_calls: - return TOOLS - return [tool for tool in TOOLS if tool.name != "call_tool"] + return types.ListToolsResult(tools=TOOLS) + return types.ListToolsResult(tools=[tool for tool in TOOLS if tool.name != "call_tool"]) + +async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams +) -> types.CallToolResult: + """Handle tool calls. -@mcp.call_tool() -async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: - """Handle tool calls.""" - handler = TOOL_HANDLERS.get(name) + A failure is answered as a result with ``is_error`` set, the way mcp 1's + decorator did it: mcp 2's lowlevel server turns an exception raised here + into a JSON-RPC error instead, which an agent cannot read as a tool + outcome. + """ + handler = TOOL_HANDLERS.get(params.name) if handler is None: - raise ValueError(f"Unknown tool: {name}") + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")], + is_error=True, + ) - result = await handler(arguments) + try: + result = await handler(params.arguments or {}) + except Exception as e: # every failure is reported to the agent + logger.debug("Tool %s failed", params.name, exc_info=e) + return types.CallToolResult( + content=[types.TextContent(type="text", text=str(e))], + is_error=True, + ) json_str = json.dumps(result, indent=2) - return [types.TextContent(type="text", text=json_str)] + return types.CallToolResult(content=[types.TextContent(type="text", text=json_str)]) + + +# Create the MCP server +mcp = Server("codemode", on_list_tools=list_tools, on_call_tool=call_tool) # ============================================================================= @@ -629,26 +651,15 @@ def run(transport: str = "stdio", host: str = "127.0.0.1", port: int = 8000) -> if transport == "streamable-http": import uvicorn - from mcp.server.streamable_http import StreamableHTTPServerTransport - from starlette.applications import Starlette - from starlette.routing import Route - async def handle_mcp(request): - transport_ctx: Any = StreamableHTTPServerTransport( - "/mcp", request.scope, request.receive, request._send - ) - async with transport_ctx as transport: - await mcp.run( - transport.read_stream, - transport.write_stream, - mcp.create_initialization_options(), - ) - - starlette_app = Starlette( - debug=True, - routes=[ - Route("/mcp", endpoint=handle_mcp, methods=["POST"]), - ], + # Stateless, as before: each request is served on a transport of its + # own. ``host`` is passed on because the SDK enables DNS-rebinding + # protection, with a localhost-only allowlist, when it is a loopback + # address — a server bound elsewhere must not inherit that list. + starlette_app = mcp.streamable_http_app( + streamable_http_path="/mcp", + stateless_http=True, + host=host, ) uvicorn.run(starlette_app, host=host, port=port) diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 47f87e2..149d53c 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -247,11 +247,11 @@ See [Integrations](./integrations/index.mdx) for complete documentation. **Exposed as an MCP Server:** ```python -from agent_codemode import codemode_server, configure_server +from agent_codemode import configure_server, run_server # Configure with your registry configure_server(config=config, registry=registry) -codemode_server.run() +run_server() ``` Or run directly: diff --git a/docs/docs/integrations/index.mdx b/docs/docs/integrations/index.mdx index d03fa1f..78b0f7e 100644 --- a/docs/docs/integrations/index.mdx +++ b/docs/docs/integrations/index.mdx @@ -136,13 +136,13 @@ python -m agent_codemode.server Or programmatically: ```python -from agent_codemode import codemode_server, configure_server, ToolRegistry, MCPServerConfig +from agent_codemode import configure_server, run_server, ToolRegistry, MCPServerConfig registry = ToolRegistry() registry.add_server(MCPServerConfig(name="filesystem", command="npx", args=["@anthropic-ai/mcp-server-filesystem"])) configure_server(registry=registry) -codemode_server.run() +run_server() ``` The server exposes tools for: diff --git a/docs/docs/skills/index.mdx b/docs/docs/skills/index.mdx index a3afa68..336d41b 100644 --- a/docs/docs/skills/index.mdx +++ b/docs/docs/skills/index.mdx @@ -353,8 +353,7 @@ When running Agent Codemode as an MCP server, skills are exposed through dedicat - **`run_skill`**: Execute a saved skill by name ```python -from agent_codemode import codemode_server, configure_server -from agent_codemode import CodeModeConfig +from agent_codemode import CodeModeConfig, configure_server, run_server config = CodeModeConfig( skills_path="./skills", @@ -362,7 +361,7 @@ config = CodeModeConfig( ) configure_server(config=config) -codemode_server.run() +run_server() ``` ## Pydantic AI Integration diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index afa9761..9498c98 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -161,7 +161,7 @@ module.exports = { }, { label: 'Bluesky', - href: 'https://assets.datalayer.tech/logos-social-grey/youtube.svg', + href: 'https://assets.datalayer.tech/logos-social-grey/bluesky.svg', }, { label: 'LinkedIn', diff --git a/docs/package.json b/docs/package.json index aa6f149..67b8e0b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@datalayer/icons-react": "^1.0.0", - "@datalayer/primer-addons": "^1.0.12", + "@datalayer/primer-addons": "^1.0.23", "@docusaurus/core": "^3.5.2", "@docusaurus/preset-classic": "^3.5.2", "@docusaurus/theme-live-codeblock": "^3.5.2", diff --git a/examples/patterns/codemode_example.py b/examples/patterns/codemode_example.py index 6f5e851..8267484 100644 --- a/examples/patterns/codemode_example.py +++ b/examples/patterns/codemode_example.py @@ -158,8 +158,9 @@ async def _server(): # Configure with MCP servers configure_server() - # Run the MCP server (uses FastMCP under the hood) - codemode_server.run() + # Serve it over stdio (the MCP SDK's lowlevel Server under the hood) + from agent_codemode.server import run + run() Or from the command line: diff --git a/examples/simple/example_mcp_server.py b/examples/simple/example_mcp_server.py index 5249a58..0d127bd 100644 --- a/examples/simple/example_mcp_server.py +++ b/examples/simple/example_mcp_server.py @@ -13,10 +13,10 @@ from pathlib import Path from typing import Optional -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from typing_extensions import TypedDict -mcp = FastMCP("example-mcp-server") +mcp = MCPServer("example-mcp-server") # Base directory for file operations - defaults to /tmp if CWD is not writable _BASE_DIR: Path | None = None diff --git a/examples/skills/example_mcp_server.py b/examples/skills/example_mcp_server.py index 5249a58..0d127bd 100644 --- a/examples/skills/example_mcp_server.py +++ b/examples/skills/example_mcp_server.py @@ -13,10 +13,10 @@ from pathlib import Path from typing import Optional -from mcp.server.fastmcp import FastMCP +from mcp.server import MCPServer from typing_extensions import TypedDict -mcp = FastMCP("example-mcp-server") +mcp = MCPServer("example-mcp-server") # Base directory for file operations - defaults to /tmp if CWD is not writable _BASE_DIR: Path | None = None diff --git a/pyproject.toml b/pyproject.toml index 31c6a2d..719258a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,10 @@ classifiers = [ dependencies = [ "agent-skills", "code-sandboxes", - "mcp[cli]>=1.10.1,<2", + # The MCP Python SDK 2: FastMCP became MCPServer, the lowlevel Server takes + # its handlers as constructor arguments, and clients come with httpx2. See + # https://py.sdk.modelcontextprotocol.io/v2/migration/ + "mcp[cli]>=2,<3", "pydantic>=2.0", "httpx>=0.24", ] diff --git a/tests/conftest.py b/tests/conftest.py index 68d264e..a6ce79e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,28 +9,6 @@ import pytest -def _rebuild_fastmcp_settings() -> None: - """Resolve the forward reference in FastMCP's ``Settings`` model. - - ``mcp.server.fastmcp.server.Settings.lifespan`` is annotated with - ``FastMCP``, which is defined further down the same module, and upstream - never calls ``model_rebuild()``. Recent pydantic-settings releases warn - (``IncompleteFieldDefinitionWarning``) when such a model is instantiated, - and this suite turns warnings into errors, so collection fails as soon as - anything constructs a FastMCP server. Rebuilding the model once resolves - the reference for real instead of muting the warning. - """ - try: - from mcp.server.fastmcp.server import Settings - except ImportError: # pragma: no cover - mcp layout changed - return - if not getattr(Settings, "__pydantic_complete__", True): - Settings.model_rebuild() - - -_rebuild_fastmcp_settings() - - @pytest.fixture def skills_dir(tmp_path: Path) -> Path: """Create a temporary skills directory."""