From 5382c8cc330fd0d0ad7b1e0e6c2e8cb3d834acc6 Mon Sep 17 00:00:00 2001 From: Malay <2974051+malaykurwa@users.noreply.github.com> Date: Fri, 3 Oct 2025 08:59:18 -0700 Subject: [PATCH] [SEP-1575] changes to introduce Tool Versioning --- examples/test_versioning.py | 206 ++++++++++++++++ examples/tool_versioning_example.py | 166 +++++++++++++ src/mcp/server/fastmcp/server.py | 10 +- src/mcp/server/fastmcp/tools/base.py | 3 + src/mcp/server/fastmcp/tools/tool_manager.py | 115 +++++++-- src/mcp/server/fastmcp/utilities/__init__.py | 20 ++ .../server/fastmcp/utilities/versioning.py | 224 ++++++++++++++++++ src/mcp/server/lowlevel/server.py | 5 +- src/mcp/types.py | 10 + 9 files changed, 736 insertions(+), 23 deletions(-) create mode 100644 examples/test_versioning.py create mode 100644 examples/tool_versioning_example.py create mode 100644 src/mcp/server/fastmcp/utilities/versioning.py diff --git a/examples/test_versioning.py b/examples/test_versioning.py new file mode 100644 index 000000000..808055ddb --- /dev/null +++ b/examples/test_versioning.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Test script for tool versioning functionality. + +This script demonstrates the new tool versioning features implemented according to SEP-1575. +""" + +import asyncio +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.utilities.versioning import ( + parse_version, + compare_versions, + satisfies_constraint, + find_best_version, + validate_tool_requirements, + VersionConstraintError, +) + + +# Test version parsing and comparison +def test_version_parsing(): + """Test version parsing functionality.""" + print("Testing version parsing...") + + # Test valid versions + assert parse_version("1.2.3") == (1, 2, 3, None) + assert parse_version("2.0.0-alpha.1") == (2, 0, 0, "alpha.1") + assert parse_version("0.1.0-beta") == (0, 1, 0, "beta") + + # Test version comparison + assert compare_versions("1.2.3", "1.2.4") == -1 + assert compare_versions("2.0.0", "1.9.9") == 1 + assert compare_versions("1.2.3", "1.2.3") == 0 + assert compare_versions("1.2.3", "1.2.3-alpha") == 1 # Stable > prerelease + + print("✓ Version parsing tests passed") + + +def test_constraint_satisfaction(): + """Test constraint satisfaction functionality.""" + print("Testing constraint satisfaction...") + + # Test exact version + assert satisfies_constraint("1.2.3", "1.2.3") == True + assert satisfies_constraint("1.2.4", "1.2.3") == False + + # Test caret (^) - allows non-breaking updates + assert satisfies_constraint("1.2.3", "^1.2.3") == True + assert satisfies_constraint("1.3.0", "^1.2.3") == True + assert satisfies_constraint("2.0.0", "^1.2.3") == False + + # Test tilde (~) - allows patch-level updates + assert satisfies_constraint("1.2.3", "~1.2.3") == True + assert satisfies_constraint("1.2.4", "~1.2.3") == True + assert satisfies_constraint("1.3.0", "~1.2.3") == False + + # Test comparison operators + assert satisfies_constraint("1.2.3", ">=1.2.0") == True + assert satisfies_constraint("1.1.9", ">=1.2.0") == False + assert satisfies_constraint("1.2.3", "<1.3.0") == True + assert satisfies_constraint("1.3.0", "<1.3.0") == False + + print("✓ Constraint satisfaction tests passed") + + +def test_version_selection(): + """Test best version selection.""" + print("Testing version selection...") + + available_versions = ["1.0.0", "1.1.0", "1.2.0", "2.0.0-alpha.1", "2.0.0"] + + # Test caret constraint + best = find_best_version(available_versions, "^1.0.0") + assert best == "1.2.0" # Latest in 1.x range + + # Test tilde constraint + best = find_best_version(available_versions, "~1.1.0") + assert best == "1.1.0" # Exact match for patch level + + # Test exact version + best = find_best_version(available_versions, "2.0.0") + assert best == "2.0.0" + + # Test no match + best = find_best_version(available_versions, "^3.0.0") + assert best is None + + print("✓ Version selection tests passed") + + +def test_tool_requirements_validation(): + """Test tool requirements validation.""" + print("Testing tool requirements validation...") + + available_tools = { + "weather": ["1.0.0", "1.1.0", "2.0.0"], + "calculator": ["1.0.0", "1.0.1", "1.1.0"], + } + + # Test valid requirements + requirements = { + "weather": "^1.0.0", + "calculator": "~1.0.0" + } + + selected = validate_tool_requirements(requirements, available_tools) + assert selected["weather"] == "1.1.0" # Latest in 1.x range + assert selected["calculator"] == "1.0.1" # Latest patch in 1.0.x range + + # Test unsatisfied requirement + requirements = { + "weather": "^3.0.0" + } + + try: + validate_tool_requirements(requirements, available_tools) + assert False, "Should have raised VersionConstraintError" + except VersionConstraintError: + pass # Expected + + print("✓ Tool requirements validation tests passed") + + +# Create a simple FastMCP server with versioned tools +def create_test_server(): + """Create a test server with versioned tools.""" + server = FastMCP("test-server") + + def get_weather_v1(location: str) -> str: + """Get weather for a location (v1).""" + return f"Weather in {location}: Sunny, 72°F (v1.0.0)" + + def get_weather_v1_1(location: str) -> str: + """Get weather for a location (v1.1).""" + return f"Weather in {location}: Partly cloudy, 75°F (v1.1.0)" + + def get_weather_v2(location: str) -> str: + """Get weather for a location (v2).""" + return f"Weather in {location}: Clear skies, 78°F (v2.0.0)" + + def calculate_v1(expression: str) -> float: + """Calculate a simple expression (v1).""" + return eval(expression) # Simple implementation for demo + + server.add_tool(get_weather_v1, version="1.0.0") + server.add_tool(get_weather_v1_1, version="1.1.0") + server.add_tool(get_weather_v2, version="2.0.0") + server.add_tool(calculate_v1, version="1.0.0") + + return server + + +async def test_server_versioning(): + """Test server versioning functionality.""" + print("Testing server versioning...") + + server = create_test_server() + + # Test listing tools (should show latest versions) + tools = server._tool_manager.list_tools() + tool_names = [t.name for t in tools] + print(f"Available tools: {tool_names}") + assert "get_weather_v1" in tool_names + assert "calculate_v1" in tool_names + + # Test getting specific version + weather_v1 = server._tool_manager.get_tool("get_weather_v1", "1.0.0") + assert weather_v1 is not None + assert weather_v1.version == "1.0.0" + + # Test getting latest version + weather_latest = server._tool_manager.get_tool("get_weather_v1") + assert weather_latest is not None + assert weather_latest.version == "1.0.0" # Only one version for this tool + + # Test available versions + versions = server._tool_manager.get_available_versions("get_weather_v1") + assert "1.0.0" in versions + + print("✓ Server versioning tests passed") + + +async def main(): + """Run all tests.""" + print("Running tool versioning tests...\n") + + test_version_parsing() + print() + + test_constraint_satisfaction() + print() + + test_version_selection() + print() + + test_tool_requirements_validation() + print() + + await test_server_versioning() + print() + + print("🎉 All tests passed! Tool versioning implementation is working correctly.") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/tool_versioning_example.py b/examples/tool_versioning_example.py new file mode 100644 index 000000000..58b7415f2 --- /dev/null +++ b/examples/tool_versioning_example.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Example demonstrating tool versioning functionality in MCP. + +This example shows how to: +1. Create tools with different versions +2. Use version constraints in tool calls +3. Handle version conflicts and errors +""" + +import asyncio +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.exceptions import ToolError +from mcp.types import UNSATISFIED_TOOL_VERSION + + +def create_versioned_server(): + """Create a server with multiple versions of tools.""" + server = FastMCP("versioned-tools-server") + + # Weather tool versions + @server.tool(version="1.0.0") + def get_weather_v1(location: str) -> str: + """Get basic weather information (v1.0.0).""" + return f"Weather in {location}: Sunny, 72°F (Basic API v1.0.0)" + + @server.tool(version="1.1.0") + def get_weather_v1_1(location: str) -> str: + """Get weather with humidity (v1.1.0).""" + return f"Weather in {location}: Partly cloudy, 75°F, Humidity: 65% (Enhanced API v1.1.0)" + + @server.tool(version="2.0.0") + def get_weather_v2(location: str) -> str: + """Get detailed weather with forecast (v2.0.0).""" + return f"Weather in {location}: Clear skies, 78°F, Humidity: 60%, Forecast: Sunny tomorrow (Advanced API v2.0.0)" + + # Calculator tool versions + @server.tool(version="1.0.0") + def calculate_v1(expression: str) -> float: + """Basic calculator (v1.0.0).""" + try: + return eval(expression) + except Exception as e: + raise ValueError(f"Invalid expression: {e}") + + @server.tool(version="1.1.0") + def calculate_v1_1(expression: str) -> dict: + """Calculator with detailed output (v1.1.0).""" + try: + result = eval(expression) + return { + "result": result, + "expression": expression, + "type": type(result).__name__ + } + except Exception as e: + raise ValueError(f"Invalid expression: {e}") + + return server + + +async def demonstrate_versioning(): + """Demonstrate various versioning scenarios.""" + print("🚀 Tool Versioning Demonstration\n") + + server = create_versioned_server() + + # 1. List available tools and their versions + print("1. Available Tools:") + tools = server._tool_manager.list_tools() + for tool in tools: + print(f" - {tool.name} (version: {tool.version})") + print() + + # 2. Show available versions for each tool + print("2. Available Versions:") + for tool_name in ["get_weather_v1", "calculate_v1"]: + versions = server._tool_manager.get_available_versions(tool_name) + print(f" - {tool_name}: {versions}") + print() + + # 3. Demonstrate tool calls without version requirements (uses latest) + print("3. Tool Calls Without Version Requirements (Latest Version):") + try: + result = await server.call_tool("get_weather_v1", {"location": "New York"}) + print(f" Weather result: {result}") + + result = await server.call_tool("calculate_v1", {"expression": "2 + 3 * 4"}) + print(f" Calculator result: {result}") + except Exception as e: + print(f" Error: {e}") + print() + + # 4. Demonstrate tool calls with version requirements + print("4. Tool Calls With Version Requirements:") + try: + # Use caret constraint (^1.0.0) - allows non-breaking updates + result = await server.call_tool( + "get_weather_v1", + {"location": "San Francisco"}, + tool_requirements={"get_weather_v1": "^1.0.0"} + ) + print(f" Weather with ^1.0.0: {result}") + + # Use tilde constraint (~1.0.0) - allows only patch updates + result = await server.call_tool( + "calculate_v1", + {"expression": "10 / 2"}, + tool_requirements={"calculate_v1": "~1.0.0"} + ) + print(f" Calculator with ~1.0.0: {result}") + + except Exception as e: + print(f" Error: {e}") + print() + + # 5. Demonstrate version conflict handling + print("5. Version Conflict Handling:") + try: + # Try to use a version that doesn't exist + result = await server.call_tool( + "get_weather_v1", + {"location": "Chicago"}, + tool_requirements={"get_weather_v1": "^3.0.0"} # No v3.x exists + ) + print(f" Unexpected success: {result}") + except ToolError as e: + if hasattr(e, 'code') and e.code == UNSATISFIED_TOOL_VERSION: + print(f" ✓ Correctly caught version conflict: {e}") + else: + print(f" Unexpected error: {e}") + except Exception as e: + print(f" Unexpected error: {e}") + print() + + # 6. Demonstrate exact version specification + print("6. Exact Version Specification:") + try: + result = await server.call_tool( + "get_weather_v1", + {"location": "Boston"}, + tool_requirements={"get_weather_v1": "1.0.0"} # Exact version + ) + print(f" Weather with exact 1.0.0: {result}") + + result = await server.call_tool( + "calculate_v1", + {"expression": "5 ** 2"}, + tool_requirements={"calculate_v1": "1.1.0"} # Exact version + ) + print(f" Calculator with exact 1.1.0: {result}") + + except Exception as e: + print(f" Error: {e}") + print() + + print("✅ Versioning demonstration completed!") + + +async def main(): + """Run the demonstration.""" + await demonstrate_versioning() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index c2dbc4436..8b403dcde 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -305,10 +305,10 @@ def get_context(self) -> Context[ServerSession, LifespanResultT, Request]: request_context = None return Context(request_context=request_context, fastmcp=self) - async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock] | dict[str, Any]: - """Call a tool by name with arguments.""" + async def call_tool(self, name: str, arguments: dict[str, Any], tool_requirements: dict[str, str] | None = None) -> Sequence[ContentBlock] | dict[str, Any]: + """Call a tool by name with arguments and optional version requirements.""" context = self.get_context() - return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True) + return await self._tool_manager.call_tool(name, arguments, tool_requirements=tool_requirements, context=context, convert_result=True) async def list_resources(self) -> list[MCPResource]: """List all available resources.""" @@ -361,6 +361,7 @@ def add_tool( name: str | None = None, title: str | None = None, description: str | None = None, + version: str | None = None, annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, structured_output: bool | None = None, @@ -386,6 +387,7 @@ def add_tool( name=name, title=title, description=description, + version=version, annotations=annotations, icons=icons, structured_output=structured_output, @@ -396,6 +398,7 @@ def tool( name: str | None = None, title: str | None = None, description: str | None = None, + version: str | None = None, annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, structured_output: bool | None = None, @@ -443,6 +446,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: name=name, title=title, description=description, + version=version, annotations=annotations, icons=icons, structured_output=structured_output, diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index 3f26ddcea..792b2a8cb 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -26,6 +26,7 @@ class Tool(BaseModel): name: str = Field(description="Name of the tool") title: str | None = Field(None, description="Human-readable title of the tool") description: str = Field(description="Description of what the tool does") + version: str | None = Field(None, description="Semantic version of the tool") parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") fn_metadata: FuncMetadata = Field( description="Metadata about the function including a pydantic model for tool arguments" @@ -46,6 +47,7 @@ def from_function( name: str | None = None, title: str | None = None, description: str | None = None, + version: str | None = None, context_kwarg: str | None = None, annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, @@ -75,6 +77,7 @@ def from_function( name=func_name, title=title, description=func_doc, + version=version, parameters=parameters, fn_metadata=func_arg_metadata, is_async=is_async, diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 443196d0d..cc0f87487 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -6,8 +6,9 @@ from mcp.server.fastmcp.exceptions import ToolError from mcp.server.fastmcp.tools.base import Tool from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.server.fastmcp.utilities.versioning import VersionConstraintError, validate_tool_requirements from mcp.shared.context import LifespanContextT, RequestT -from mcp.types import Icon, ToolAnnotations +from mcp.types import Icon, ToolAnnotations, UNSATISFIED_TOOL_VERSION if TYPE_CHECKING: from mcp.server.fastmcp.server import Context @@ -25,22 +26,82 @@ def __init__( *, tools: list[Tool] | None = None, ): - self._tools: dict[str, Tool] = {} + # Store tools by name -> list of versions + self._tools: dict[str, list[Tool]] = {} if tools is not None: for tool in tools: - if warn_on_duplicate_tools and tool.name in self._tools: - logger.warning(f"Tool already exists: {tool.name}") - self._tools[tool.name] = tool + self._add_tool_internal(tool, warn_on_duplicate_tools) self.warn_on_duplicate_tools = warn_on_duplicate_tools - def get_tool(self, name: str) -> Tool | None: - """Get tool by name.""" - return self._tools.get(name) + def _add_tool_internal(self, tool: Tool, warn_on_duplicate: bool) -> None: + """Internal method to add a tool.""" + if tool.name not in self._tools: + self._tools[tool.name] = [] + + # Check for duplicate versions + existing_versions = [t.version for t in self._tools[tool.name] if t.version is not None] + if tool.version in existing_versions: + if warn_on_duplicate: + logger.warning(f"Tool version already exists: {tool.name} {tool.version}") + return + + self._tools[tool.name].append(tool) + + def get_tool(self, name: str, version: str | None = None) -> Tool | None: + """Get tool by name and optionally version.""" + if name not in self._tools: + return None + + tool_versions = self._tools[name] + + if version is None: + # Return the latest stable version, or latest prerelease if no stable versions + stable_versions = [t for t in tool_versions if t.version is None or not self._is_prerelease(t.version)] + if stable_versions: + return max(stable_versions, key=lambda t: self._parse_version_for_sorting(t.version)) + else: + return max(tool_versions, key=lambda t: self._parse_version_for_sorting(t.version)) + + # Find exact version match + for tool in tool_versions: + if tool.version == version: + return tool + + return None + + def _is_prerelease(self, version: str | None) -> bool: + """Check if a version is a prerelease.""" + if version is None: + return False + return '-' in version + + def _parse_version_for_sorting(self, version: str | None) -> tuple[int, int, int, str]: + """Parse version for sorting purposes.""" + if version is None: + return (0, 0, 0, "") + + try: + from mcp.server.fastmcp.utilities.versioning import parse_version + major, minor, patch, prerelease = parse_version(version) + return (major, minor, patch, prerelease or "") + except Exception: + return (0, 0, 0, version) def list_tools(self) -> list[Tool]: - """List all registered tools.""" - return list(self._tools.values()) + """List all registered tools (latest version of each).""" + result = [] + for tool_name in self._tools: + tool = self.get_tool(tool_name) + if tool: + result.append(tool) + return result + + def get_available_versions(self, name: str) -> list[str]: + """Get all available versions for a tool.""" + if name not in self._tools: + return [] + return [tool.version for tool in self._tools[name] if tool.version is not None] def add_tool( self, @@ -48,6 +109,7 @@ def add_tool( name: str | None = None, title: str | None = None, description: str | None = None, + version: str | None = None, annotations: ToolAnnotations | None = None, icons: list[Icon] | None = None, structured_output: bool | None = None, @@ -62,23 +124,40 @@ def add_tool( icons=icons, structured_output=structured_output, ) - existing = self._tools.get(tool.name) - if existing: - if self.warn_on_duplicate_tools: - logger.warning(f"Tool already exists: {tool.name}") - return existing - self._tools[tool.name] = tool + # Set the version if provided + if version is not None: + tool.version = version + + self._add_tool_internal(tool, self.warn_on_duplicate_tools) return tool async def call_tool( self, name: str, arguments: dict[str, Any], + tool_requirements: dict[str, str] | None = None, context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, convert_result: bool = False, ) -> Any: - """Call a tool by name with arguments.""" - tool = self.get_tool(name) + """Call a tool by name with arguments and optional version requirements.""" + # Validate tool requirements if provided + if tool_requirements: + available_tools = {name: self.get_available_versions(name) for name in tool_requirements} + try: + selected_versions = validate_tool_requirements(tool_requirements, available_tools) + # Use the selected version for the requested tool + if name in selected_versions: + tool = self.get_tool(name, selected_versions[name]) + else: + tool = self.get_tool(name) + except VersionConstraintError as e: + # Convert to ToolError with specific error code + error = ToolError(str(e)) + error.code = UNSATISFIED_TOOL_VERSION + raise error + else: + tool = self.get_tool(name) + if not tool: raise ToolError(f"Unknown tool: {name}") diff --git a/src/mcp/server/fastmcp/utilities/__init__.py b/src/mcp/server/fastmcp/utilities/__init__.py index be448f97a..42eaa090d 100644 --- a/src/mcp/server/fastmcp/utilities/__init__.py +++ b/src/mcp/server/fastmcp/utilities/__init__.py @@ -1 +1,21 @@ """FastMCP utility modules.""" + +from .versioning import ( + VersionConstraintError, + InvalidVersionError, + parse_version, + compare_versions, + satisfies_constraint, + find_best_version, + validate_tool_requirements, +) + +__all__ = [ + "VersionConstraintError", + "InvalidVersionError", + "parse_version", + "compare_versions", + "satisfies_constraint", + "find_best_version", + "validate_tool_requirements", +] diff --git a/src/mcp/server/fastmcp/utilities/versioning.py b/src/mcp/server/fastmcp/utilities/versioning.py new file mode 100644 index 000000000..c0c243b13 --- /dev/null +++ b/src/mcp/server/fastmcp/utilities/versioning.py @@ -0,0 +1,224 @@ +""" +Utility functions for handling tool versioning and semantic version constraints. + +This module provides functionality to parse and validate semantic version constraints +as specified in SEP-1575: Tool Semantic Versioning. +""" + +import re +from typing import Any, Dict, List, Optional, Tuple + + +class VersionConstraintError(Exception): + """Raised when a version constraint cannot be satisfied.""" + pass + + +class InvalidVersionError(Exception): + """Raised when a version string is invalid.""" + pass + + +def parse_version(version_str: str) -> Tuple[int, int, int, Optional[str]]: + """ + Parse a semantic version string into its components. + + Args: + version_str: Version string in SemVer format (e.g., "1.2.3", "2.0.0-alpha.1") + + Returns: + Tuple of (major, minor, patch, prerelease) + + Raises: + InvalidVersionError: If the version string is invalid + """ + # SemVer regex pattern + pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+([a-zA-Z0-9.-]+))?$' + match = re.match(pattern, version_str) + + if not match: + raise InvalidVersionError(f"Invalid version string: {version_str}") + + major, minor, patch, prerelease, build = match.groups() + return int(major), int(minor), int(patch), prerelease + + +def compare_versions(version1: str, version2: str) -> int: + """ + Compare two semantic versions. + + Args: + version1: First version string + version2: Second version string + + Returns: + -1 if version1 < version2, 0 if equal, 1 if version1 > version2 + """ + try: + major1, minor1, patch1, prerelease1 = parse_version(version1) + major2, minor2, patch2, prerelease2 = parse_version(version2) + except InvalidVersionError: + raise InvalidVersionError(f"Invalid version strings: {version1}, {version2}") + + # Compare major, minor, patch + if major1 != major2: + return -1 if major1 < major2 else 1 + if minor1 != minor2: + return -1 if minor1 < minor2 else 1 + if patch1 != patch2: + return -1 if patch1 < patch2 else 1 + + # Compare prerelease versions + if prerelease1 is None and prerelease2 is None: + return 0 + if prerelease1 is None: + return 1 # Stable version is greater than prerelease + if prerelease2 is None: + return -1 # Prerelease is less than stable version + + # Compare prerelease strings lexicographically + if prerelease1 < prerelease2: + return -1 + elif prerelease1 > prerelease2: + return 1 + else: + return 0 + + +def satisfies_constraint(version: str, constraint: str) -> bool: + """ + Check if a version satisfies a given constraint. + + Args: + version: Version string to check + constraint: Version constraint (e.g., "^1.2.3", "~1.4.1", ">=2.0.0", "1.2.3") + + Returns: + True if the version satisfies the constraint, False otherwise + """ + try: + major, minor, patch, prerelease = parse_version(version) + except InvalidVersionError: + return False + + # Handle exact version + if not any(op in constraint for op in ['^', '~', '>', '<', '=', '!']): + return compare_versions(version, constraint) == 0 + + # Handle caret (^) - allows non-breaking updates + if constraint.startswith('^'): + target_version = constraint[1:] + try: + target_major, target_minor, target_patch, target_prerelease = parse_version(target_version) + except InvalidVersionError: + return False + + # ^1.2.3 is equivalent to >=1.2.3 <2.0.0 + if major != target_major: + return False + if major == target_major: + if minor < target_minor: + return False + if minor == target_minor and patch < target_patch: + return False + return True + + # Handle tilde (~) - allows patch-level updates + if constraint.startswith('~'): + target_version = constraint[1:] + try: + target_major, target_minor, target_patch, target_prerelease = parse_version(target_version) + except InvalidVersionError: + return False + + # ~1.2.3 is equivalent to >=1.2.3 <1.3.0 + if major != target_major or minor != target_minor: + return False + return patch >= target_patch + + # Handle comparison operators + if constraint.startswith('>='): + target_version = constraint[2:] + return compare_versions(version, target_version) >= 0 + elif constraint.startswith('<='): + target_version = constraint[2:] + return compare_versions(version, target_version) <= 0 + elif constraint.startswith('>'): + target_version = constraint[1:] + return compare_versions(version, target_version) > 0 + elif constraint.startswith('<'): + target_version = constraint[1:] + return compare_versions(version, target_version) < 0 + elif constraint.startswith('='): + target_version = constraint[1:] + return compare_versions(version, target_version) == 0 + + return False + + +def find_best_version(available_versions: List[str], constraint: str) -> Optional[str]: + """ + Find the best version that satisfies a constraint from a list of available versions. + + Args: + available_versions: List of available version strings + constraint: Version constraint + + Returns: + The best version that satisfies the constraint, or None if none satisfy it + """ + satisfying_versions = [v for v in available_versions if satisfies_constraint(v, constraint)] + + if not satisfying_versions: + return None + + # Sort versions and return the latest stable version + # Prefer stable versions over prerelease versions + stable_versions = [v for v in satisfying_versions if not parse_version(v)[3]] + prerelease_versions = [v for v in satisfying_versions if parse_version(v)[3]] + + if stable_versions: + # Return the latest stable version + return max(stable_versions, key=lambda v: parse_version(v)[:3]) + else: + # Return the latest prerelease version if no stable versions satisfy + return max(prerelease_versions, key=lambda v: parse_version(v)[:3]) + + +def validate_tool_requirements( + tool_requirements: Dict[str, str], + available_tools: Dict[str, List[str]] +) -> Dict[str, str]: + """ + Validate tool requirements against available tool versions. + + Args: + tool_requirements: Dictionary mapping tool names to version constraints + available_tools: Dictionary mapping tool names to lists of available versions + + Returns: + Dictionary mapping tool names to selected versions + + Raises: + VersionConstraintError: If any tool requirement cannot be satisfied + """ + selected_versions = {} + + for tool_name, constraint in tool_requirements.items(): + if tool_name not in available_tools: + raise VersionConstraintError(f"Tool '{tool_name}' not found") + + available_versions = available_tools[tool_name] + if not available_versions: + raise VersionConstraintError(f"No versions available for tool '{tool_name}'") + + best_version = find_best_version(available_versions, constraint) + if best_version is None: + raise VersionConstraintError( + f"Tool requirement for '{tool_name}' ({constraint}) could not be satisfied. " + f"Available versions: {available_versions}" + ) + + selected_versions[tool_name] = best_version + + return selected_versions \ No newline at end of file diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 2fec3381b..f3fc71df6 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -479,7 +479,7 @@ def call_tool(self, *, validate_input: bool = True): def decorator( func: Callable[ - ..., + [str, dict[str, Any], dict[str, str] | None], Awaitable[UnstructuredContent | StructuredContent | CombinationContent], ], ): @@ -489,6 +489,7 @@ async def handler(req: types.CallToolRequest): try: tool_name = req.params.name arguments = req.params.arguments or {} + tool_requirements = getattr(req.params, 'tool_requirements', None) tool = await self._get_cached_tool_definition(tool_name) # input validation @@ -499,7 +500,7 @@ async def handler(req: types.CallToolRequest): return self._make_error_result(f"Input validation error: {e.message}") # tool call - results = await func(tool_name, arguments) + results = await func(tool_name, arguments, tool_requirements) # output normalization unstructured_content: UnstructuredContent diff --git a/src/mcp/types.py b/src/mcp/types.py index 871322740..219d14f43 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -157,6 +157,9 @@ class JSONRPCResponse(BaseModel): INVALID_PARAMS = -32602 INTERNAL_ERROR = -32603 +# Tool versioning error codes +UNSATISFIED_TOOL_VERSION = -32602 # Reusing INVALID_PARAMS code for tool version conflicts + class ErrorData(BaseModel): """Error information for JSON-RPC error responses.""" @@ -873,6 +876,8 @@ class Tool(BaseMetadata): description: str | None = None """A human-readable description of the tool.""" + version: str | None = None + """The semantic version of this tool. Must follow Semantic Versioning 2.0.0 standard (e.g., MAJOR.MINOR.PATCH). Examples: "1.2.3", "2.0.0", "0.1.0-alpha.1".""" inputSchema: dict[str, Any] """A JSON Schema object defining the expected parameters for the tool.""" outputSchema: dict[str, Any] | None = None @@ -903,6 +908,11 @@ class CallToolRequestParams(RequestParams): name: str arguments: dict[str, Any] | None = None + tool_requirements: dict[str, str] | None = None + """ + A key-value map where the key is the tool name (string) and the value is a version specifier (string). + Version specifiers follow conventions from modern package managers and support operators like ^, ~, >, >=, <, <=, and exact versions. + """ model_config = ConfigDict(extra="allow")