Update mirror backend with latest code

main
Marcus Sanatan 2025-11-05 16:08:59 -04:00
parent 2649d9c379
commit 2619644a3b
47 changed files with 3108 additions and 253 deletions

View File

@ -16,7 +16,7 @@ import json
import logging import logging
import os import os
import struct import struct
from datetime import datetime, timezone from datetime import datetime
from pathlib import Path from pathlib import Path
import socket import socket
from typing import Optional, List, Dict from typing import Optional, List, Dict
@ -238,7 +238,7 @@ class PortDiscovery:
for status_file_path in status_files: for status_file_path in status_files:
try: try:
status_path = Path(status_file_path) status_path = Path(status_file_path)
file_mtime = datetime.fromtimestamp(status_path.stat().st_mtime, tz=timezone.utc) file_mtime = datetime.fromtimestamp(status_path.stat().st_mtime)
with status_path.open('r') as f: with status_path.open('r') as f:
data = json.load(f) data = json.load(f)
@ -258,12 +258,7 @@ class PortDiscovery:
heartbeat_str = data.get('last_heartbeat') heartbeat_str = data.get('last_heartbeat')
if heartbeat_str: if heartbeat_str:
try: try:
parsed = datetime.fromisoformat(heartbeat_str.replace('Z', '+00:00')) last_heartbeat = datetime.fromisoformat(heartbeat_str.replace('Z', '+00:00'))
# Normalize to UTC for consistent comparison
if parsed.tzinfo is None:
last_heartbeat = parsed.replace(tzinfo=timezone.utc)
else:
last_heartbeat = parsed.astimezone(timezone.utc)
except Exception: except Exception:
pass pass

View File

@ -35,6 +35,7 @@ py-modules = [
"server", "server",
"telemetry", "telemetry",
"telemetry_decorator", "telemetry_decorator",
"unity_connection" "unity_connection",
"unity_instance_middleware"
] ]
packages = ["tools", "resources", "registry"] packages = ["tools", "resources", "registry"]

View File

@ -49,7 +49,6 @@ def register_all_resources(mcp: FastMCP):
has_query_params = '{?' in uri has_query_params = '{?' in uri
if has_query_params: if has_query_params:
# Register template with query parameter support
wrapped_template = telemetry_resource(resource_name)(func) wrapped_template = telemetry_resource(resource_name)(func)
wrapped_template = mcp.resource( wrapped_template = mcp.resource(
uri=uri, uri=uri,
@ -61,7 +60,6 @@ def register_all_resources(mcp: FastMCP):
registered_count += 1 registered_count += 1
resource_info['func'] = wrapped_template resource_info['func'] = wrapped_template
else: else:
# No query parameters, register as-is
wrapped = telemetry_resource(resource_name)(func) wrapped = telemetry_resource(resource_name)(func)
wrapped = mcp.resource( wrapped = mcp.resource(
uri=uri, uri=uri,

View File

@ -0,0 +1,46 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
class Vector3(BaseModel):
"""3D vector."""
x: float = 0.0
y: float = 0.0
z: float = 0.0
class ActiveToolData(BaseModel):
"""Active tool data fields."""
activeTool: str = ""
isCustom: bool = False
pivotMode: str = ""
pivotRotation: str = ""
handleRotation: Vector3 = Vector3()
handlePosition: Vector3 = Vector3()
class ActiveToolResponse(MCPResponse):
"""Information about the currently active editor tool."""
data: ActiveToolData = ActiveToolData()
@mcp_for_unity_resource(
uri="unity://editor/active-tool",
name="editor_active_tool",
description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings."
)
async def get_active_tool(ctx: Context) -> ActiveToolResponse | MCPResponse:
"""Get active editor tool information."""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_active_tool",
{}
)
return ActiveToolResponse(**response) if isinstance(response, dict) else response

View File

@ -0,0 +1,41 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
class EditorStateData(BaseModel):
"""Editor state data fields."""
isPlaying: bool = False
isPaused: bool = False
isCompiling: bool = False
isUpdating: bool = False
timeSinceStartup: float = 0.0
activeSceneName: str = ""
selectionCount: int = 0
activeObjectName: str | None = None
class EditorStateResponse(MCPResponse):
"""Dynamic editor state information that changes frequently."""
data: EditorStateData = EditorStateData()
@mcp_for_unity_resource(
uri="unity://editor/state",
name="editor_state",
description="Current editor runtime state including play mode, compilation status, active scene, and selection summary. Refresh frequently for up-to-date information."
)
async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse:
"""Get current editor runtime state."""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_editor_state",
{}
)
return EditorStateResponse(**response) if isinstance(response, dict) else response

View File

@ -0,0 +1,28 @@
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
class LayersResponse(MCPResponse):
"""Dictionary of layer indices to layer names."""
data: dict[int, str] = {}
@mcp_for_unity_resource(
uri="unity://project/layers",
name="project_layers",
description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools."
)
async def get_layers(ctx: Context) -> LayersResponse | MCPResponse:
"""Get all project layers with their indices."""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_layers",
{}
)
return LayersResponse(**response) if isinstance(response, dict) else response

View File

@ -12,10 +12,10 @@ class GetMenuItemsResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="mcpforunity://menu-items", uri="mcpforunity://menu-items",
name="get_menu_items", name="menu_items",
description="Provides a list of all menu items." description="Provides a list of all menu items."
) )
async def get_menu_items(ctx: Context) -> GetMenuItemsResponse: async def get_menu_items(ctx: Context) -> GetMenuItemsResponse | MCPResponse:
"""Provides a list of all menu items. """Provides a list of all menu items.
""" """
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)

View File

@ -0,0 +1,38 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
class PrefabStageData(BaseModel):
"""Prefab stage data fields."""
isOpen: bool = False
assetPath: str | None = None
prefabRootName: str | None = None
mode: str | None = None
isDirty: bool = False
class PrefabStageResponse(MCPResponse):
"""Information about the current prefab editing context."""
data: PrefabStageData = PrefabStageData()
@mcp_for_unity_resource(
uri="unity://editor/prefab-stage",
name="editor_prefab_stage",
description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited."
)
async def get_prefab_stage(ctx: Context) -> PrefabStageResponse | MCPResponse:
"""Get current prefab stage information."""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_prefab_stage",
{}
)
return PrefabStageResponse(**response) if isinstance(response, dict) else response

View File

@ -0,0 +1,38 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
class ProjectInfoData(BaseModel):
"""Project info data fields."""
projectRoot: str = ""
projectName: str = ""
unityVersion: str = ""
platform: str = ""
assetsPath: str = ""
class ProjectInfoResponse(MCPResponse):
"""Static project configuration information."""
data: ProjectInfoData = ProjectInfoData()
@mcp_for_unity_resource(
uri="unity://project/info",
name="project_info",
description="Static project information including root path, Unity version, and platform. This data rarely changes."
)
async def get_project_info(ctx: Context) -> ProjectInfoResponse | MCPResponse:
"""Get static project configuration information."""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_project_info",
{}
)
return ProjectInfoResponse(**response) if isinstance(response, dict) else response

View File

@ -0,0 +1,54 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
class SelectionObjectInfo(BaseModel):
"""Information about a selected object."""
name: str | None = None
type: str | None = None
instanceID: int | None = None
class SelectionGameObjectInfo(BaseModel):
"""Information about a selected GameObject."""
name: str | None = None
instanceID: int | None = None
class SelectionData(BaseModel):
"""Selection data fields."""
activeObject: str | None = None
activeGameObject: str | None = None
activeTransform: str | None = None
activeInstanceID: int = 0
count: int = 0
objects: list[SelectionObjectInfo] = []
gameObjects: list[SelectionGameObjectInfo] = []
assetGUIDs: list[str] = []
class SelectionResponse(MCPResponse):
"""Detailed information about the current editor selection."""
data: SelectionData = SelectionData()
@mcp_for_unity_resource(
uri="unity://editor/selection",
name="editor_selection",
description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties."
)
async def get_selection(ctx: Context) -> SelectionResponse | MCPResponse:
"""Get detailed editor selection information."""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_selection",
{}
)
return SelectionResponse(**response) if isinstance(response, dict) else response

29
Server/resources/tags.py Normal file
View File

@ -0,0 +1,29 @@
from pydantic import Field
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
class TagsResponse(MCPResponse):
"""List of all tags in the project."""
data: list[str] = Field(default_factory=list)
@mcp_for_unity_resource(
uri="unity://project/tags",
name="project_tags",
description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools."
)
async def get_tags(ctx: Context) -> TagsResponse | MCPResponse:
"""Get all project tags."""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_tags",
{}
)
return TagsResponse(**response) if isinstance(response, dict) else response

View File

@ -21,7 +21,7 @@ class GetTestsResponse(MCPResponse):
@mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.") @mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.")
async def get_tests(ctx: Context) -> GetTestsResponse: async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse:
"""Provides a list of all tests. """Provides a list of all tests.
""" """
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
@ -38,7 +38,7 @@ async def get_tests(ctx: Context) -> GetTestsResponse:
async def get_tests_for_mode( async def get_tests_for_mode(
ctx: Context, ctx: Context,
mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")], mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")],
) -> GetTestsResponse: ) -> GetTestsResponse | MCPResponse:
"""Provides a list of tests for a specific mode. """Provides a list of tests for a specific mode.
Args: Args:

View File

@ -0,0 +1,46 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from registry import mcp_for_unity_resource
from tools import get_unity_instance_from_context, async_send_with_unity_instance
from unity_connection import async_send_command_with_retry
class WindowPosition(BaseModel):
"""Window position and size."""
x: float = 0.0
y: float = 0.0
width: float = 0.0
height: float = 0.0
class WindowInfo(BaseModel):
"""Information about an editor window."""
title: str = ""
typeName: str = ""
isFocused: bool = False
position: WindowPosition = WindowPosition()
instanceID: int = 0
class WindowsResponse(MCPResponse):
"""List of all open editor windows."""
data: list[WindowInfo] = []
@mcp_for_unity_resource(
uri="unity://editor/windows",
name="editor_windows",
description="All currently open editor windows with their titles, types, positions, and focus state."
)
async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse:
"""Get all open editor windows."""
unity_instance = get_unity_instance_from_context(ctx)
response = await async_send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_windows",
{}
)
return WindowsResponse(**response) if isinstance(response, dict) else response

View File

@ -108,14 +108,12 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
instances = _unity_connection_pool.discover_all_instances() instances = _unity_connection_pool.discover_all_instances()
if instances: if instances:
logger.info( logger.info(f"Discovered {len(instances)} Unity instance(s): {[i.id for i in instances]}")
f"Discovered {len(instances)} Unity instance(s): {[i.id for i in instances]}")
# Try to connect to default instance # Try to connect to default instance
try: try:
_unity_connection_pool.get_connection() _unity_connection_pool.get_connection()
logger.info( logger.info("Connected to default Unity instance on startup")
"Connected to default Unity instance on startup")
# Record successful Unity connection (deferred) # Record successful Unity connection (deferred)
import threading as _t import threading as _t
@ -128,8 +126,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
} }
)).start() )).start()
except Exception as e: except Exception as e:
logger.warning( logger.warning("Could not connect to default Unity instance: %s", e)
"Could not connect to default Unity instance: %s", e)
else: else:
logger.warning("No Unity instances found on startup") logger.warning("No Unity instances found on startup")
@ -179,10 +176,15 @@ This server provides tools to interact with the Unity Game Engine Editor.
Important Workflows: Important Workflows:
Resources vs Tools:
- Use RESOURCES to read editor state (editor_state, project_info, project_tags, tests, etc)
- Use TOOLS to perform actions and mutations (manage_editor for play mode control, tag/layer management, etc)
- Always check related resources before modifying the engine state with tools
Script Management: Script Management:
1. After creating or modifying scripts with `manage_script` - After creating or modifying scripts (by your own tools or the `manage_script` tool) use `read_console` to check for compilation errors before proceeding
2. Use `read_console` to check for compilation errors before proceeding - Only after successful compilation can new components/types be used
3. Only after successful compilation can new components/types be used - You can poll the `editor_state` resource's `isCompiling` field to check if the domain reload is complete
Scene Setup: Scene Setup:
- Always include a Camera and main Light (Directional Light) in new scenes - Always include a Camera and main Light (Directional Light) in new scenes
@ -248,8 +250,7 @@ Examples:
# Set environment variable if --default-instance is provided # Set environment variable if --default-instance is provided
if args.default_instance: if args.default_instance:
os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = args.default_instance os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = args.default_instance
logger.info( logger.info(f"Using default Unity instance from command-line: {args.default_instance}")
f"Using default Unity instance from command-line: {args.default_instance}")
mcp.run(transport='stdio') mcp.run(transport='stdio')

0
Server/tests/__init__.py Normal file
View File

View File

@ -0,0 +1 @@
# This file makes tests a package so test modules can import from each other

View File

@ -0,0 +1,66 @@
import os
import sys
import types
# Ensure telemetry is disabled during test collection and execution to avoid
# any background network or thread startup that could slow or block pytest.
os.environ.setdefault("DISABLE_TELEMETRY", "true")
os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true")
os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true")
# NOTE: These tests are integration tests for the MCP server Python code.
# They test tools, resources, and utilities without requiring Unity to be running.
# Tests can now import directly from the parent package since they're inside src/
# To run: cd MCPForUnity/UnityMcpServer~/src && uv run pytest tests/integration/ -v
# Stub telemetry modules to avoid file I/O during import of tools package
telemetry = types.ModuleType("telemetry")
def _noop(*args, **kwargs):
pass
class MilestoneType:
pass
telemetry.record_resource_usage = _noop
telemetry.record_tool_usage = _noop
telemetry.record_milestone = _noop
telemetry.MilestoneType = MilestoneType
telemetry.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("telemetry", telemetry)
telemetry_decorator = types.ModuleType("telemetry_decorator")
def telemetry_tool(*dargs, **dkwargs):
def _wrap(fn):
return fn
return _wrap
telemetry_decorator.telemetry_tool = telemetry_tool
sys.modules.setdefault("telemetry_decorator", telemetry_decorator)
# Stub fastmcp module (not mcp.server.fastmcp)
fastmcp = types.ModuleType("fastmcp")
class _DummyFastMCP:
pass
class _DummyContext:
pass
class _DummyMiddleware:
"""Base middleware class stub."""
pass
class _DummyMiddlewareContext:
"""Middleware context stub."""
pass
fastmcp.FastMCP = _DummyFastMCP
fastmcp.Context = _DummyContext
sys.modules.setdefault("fastmcp", fastmcp)
# Stub fastmcp.server.middleware submodule
fastmcp_server = types.ModuleType("fastmcp.server")
fastmcp_server_middleware = types.ModuleType("fastmcp.server.middleware")
fastmcp_server_middleware.Middleware = _DummyMiddleware
fastmcp_server_middleware.MiddlewareContext = _DummyMiddlewareContext
fastmcp.server = fastmcp_server
fastmcp_server.middleware = fastmcp_server_middleware
sys.modules.setdefault("fastmcp.server", fastmcp_server)
sys.modules.setdefault("fastmcp.server.middleware", fastmcp_server_middleware)

View File

@ -0,0 +1,128 @@
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self): self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn): self.tools[fn.__name__] = fn; return fn
return deco
def setup_tools():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.manage_script
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_normalizes_lsp_and_index_ranges(monkeypatch):
tools = setup_tools()
apply = tools["apply_text_edits"]
calls = []
def fake_send(cmd, params):
calls.append(params)
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry
# LSP-style
edits = [{
"range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}},
"newText": "// lsp\n"
}]
apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="x")
p = calls[-1]
e = p["edits"][0]
assert e["startLine"] == 11 and e["startCol"] == 3
# Index pair
calls.clear()
edits = [{"range": [0, 0], "text": "// idx\n"}]
# fake read to provide contents length
def fake_read(cmd, params):
if params.get("action") == "read":
return {"success": True, "data": {"contents": "hello\n"}}
return {"success": True}
# Override unity_connection for this read normalization case
monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_read)
apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="x")
# last call is apply_text_edits
def test_noop_evidence_shape(monkeypatch):
tools = setup_tools()
apply = tools["apply_text_edits"]
# Route response from Unity indicating no-op
def fake_send(cmd, params):
return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry
resp = apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs", edits=[
{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}], precondition_sha256="x")
assert resp["success"] is True
assert resp.get("data", {}).get("no_op") is True
def test_atomic_multi_span_and_relaxed(monkeypatch):
tools_text = setup_tools()
apply_text = tools_text["apply_text_edits"]
tools_struct = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.script_apply_edits
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script_apply', 'apply_edits']):
tools_struct.tools[tool_name] = tool_info['func']
# Fake send for read and write; verify atomic applyMode and validate=relaxed passes through
sent = {}
def fake_send(cmd, params):
if params.get("action") == "read":
return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}}
sent.setdefault("calls", []).append(params)
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
edits = [
{"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"},
{"startLine": 3, "startCol": 2, "endLine": 3,
"endCol": 2, "newText": "// tail\n"}
]
resp = apply_text(DummyContext(), uri="unity://path/Assets/Scripts/C.cs", edits=edits,
precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"})
assert resp["success"] is True
# Last manage_script call should include options with applyMode atomic and validate relaxed
last = sent["calls"][-1]
assert last.get("options", {}).get("applyMode") == "atomic"
assert last.get("options", {}).get("validate") == "relaxed"

View File

@ -0,0 +1,65 @@
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self): self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn): self.tools[fn.__name__] = fn; return fn
return deco
def setup_tools():
mcp = DummyMCP()
# Import tools to trigger decorator-based registration
import tools.manage_script
from registry import get_registered_tools
for tool_info in get_registered_tools():
name = tool_info['name']
if any(k in name for k in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[name] = tool_info['func']
return mcp.tools
def test_explicit_zero_based_normalized_warning(monkeypatch):
tools = setup_tools()
apply_edits = tools["apply_text_edits"]
def fake_send(cmd, params):
# Simulate Unity path returning minimal success
return {"success": True}
import unity_connection
monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send)
# Explicit fields given as 0-based (invalid); SDK should normalize and warn
edits = [{"startLine": 0, "startCol": 0,
"endLine": 0, "endCol": 0, "newText": "//x"}]
resp = apply_edits(DummyContext(), uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="sha")
assert resp["success"] is True
data = resp.get("data", {})
assert "normalizedEdits" in data
assert any(
w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", []))
ne = data["normalizedEdits"][0]
assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1
def test_strict_zero_based_error(monkeypatch):
tools = setup_tools()
apply_edits = tools["apply_text_edits"]
def fake_send(cmd, params):
return {"success": True}
import unity_connection
monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send)
edits = [{"startLine": 0, "startCol": 0,
"endLine": 0, "endCol": 0, "newText": "//x"}]
resp = apply_edits(DummyContext(), uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="sha", strict=True)
assert resp["success"] is False
assert resp.get("code") == "zero_based_explicit_fields"

View File

@ -0,0 +1,51 @@
import asyncio
import pytest
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn):
self.tools[fn.__name__] = fn
return fn
return deco
@pytest.fixture()
def resource_tools():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.resource_tools
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all resource-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['find_in_file', 'list_resources', 'read_resource']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_find_in_file_returns_positions(resource_tools, tmp_path):
proj = tmp_path
assets = proj / "Assets"
assets.mkdir()
f = assets / "A.txt"
f.write_text("hello world", encoding="utf-8")
find_in_file = resource_tools["find_in_file"]
loop = asyncio.new_event_loop()
try:
resp = loop.run_until_complete(
find_in_file(uri="unity://path/Assets/A.txt",
pattern="world", ctx=DummyContext(), project_root=str(proj))
)
finally:
loop.close()
assert resp["success"] is True
assert resp["data"]["matches"] == [
{"startLine": 1, "startCol": 7, "endLine": 1, "endCol": 12}]

View File

@ -0,0 +1,53 @@
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn):
self.tools[fn.__name__] = fn
return fn
return deco
def setup_tools():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.manage_script
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_get_sha_param_shape_and_routing(monkeypatch):
tools = setup_tools()
get_sha = tools["get_sha"]
captured = {}
def fake_send(cmd, params):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "unity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
resp = get_sha(DummyContext(), uri="unity://path/Assets/Scripts/A.cs")
assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "get_sha"
assert captured["params"]["name"] == "A"
assert captured["params"]["path"].endswith("Assets/Scripts")
assert resp["success"] is True
assert resp["data"] == {"sha256": "abc", "lengthBytes": 1}

View File

@ -0,0 +1,55 @@
class _DummyMeta(dict):
def __getattr__(self, item):
try:
return self[item]
except KeyError as exc:
raise AttributeError(item) from exc
model_extra = property(lambda self: self)
def model_dump(self, exclude_none=True):
if not exclude_none:
return dict(self)
return {k: v for k, v in self.items() if v is not None}
class DummyContext:
"""Mock context object for testing"""
def __init__(self, **meta):
import uuid
self.log_info = []
self.log_warning = []
self.log_error = []
self._meta = _DummyMeta(meta)
# Give each context a unique session_id to avoid state leakage between tests
self.session_id = str(uuid.uuid4())
# Add state storage to mimic FastMCP context state
self._state = {}
class _RequestContext:
def __init__(self, meta):
self.meta = meta
self.request_context = _RequestContext(self._meta)
def info(self, message):
self.log_info.append(message)
def warning(self, message):
self.log_warning.append(message)
# Some code paths call warn(); treat it as an alias of warning()
def warn(self, message):
self.warning(message)
def error(self, message):
self.log_error.append(message)
def set_state(self, key, value):
"""Set state value (mimics FastMCP context.set_state)"""
self._state[key] = value
def get_state(self, key, default=None):
"""Get state value (mimics FastMCP context.get_state)"""
return self._state.get(key, default)

View File

@ -0,0 +1,148 @@
"""
Test the improved anchor matching logic.
"""
import re
import tools.script_apply_edits as script_apply_edits_module
def test_improved_anchor_matching():
"""Test that our improved anchor matching finds the right closing brace."""
test_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
void Start()
{
Debug.Log("test");
}
void Update()
{
// Update logic
}
}'''
# Test the problematic anchor pattern
anchor_pattern = r"\s*}\s*$"
flags = re.MULTILINE
# Test our improved function
best_match = script_apply_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True
)
assert best_match is not None, "anchor pattern not found"
match_pos = best_match.start()
line_num = test_code[:match_pos].count('\n') + 1
total_lines = test_code.count('\n') + 1
assert line_num >= total_lines - \
2, f"expected match near end (>= {total_lines-2}), got line {line_num}"
def test_old_vs_new_matching():
"""Compare old vs new matching behavior."""
test_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
void Start()
{
Debug.Log("test");
}
void Update()
{
if (condition)
{
DoSomething();
}
}
void LateUpdate()
{
// More logic
}
}'''
import re
anchor_pattern = r"\s*}\s*$"
flags = re.MULTILINE
# Old behavior (first match)
old_match = re.search(anchor_pattern, test_code, flags)
old_line = test_code[:old_match.start()].count(
'\n') + 1 if old_match else None
# New behavior (improved matching)
new_match = script_apply_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True
)
new_line = test_code[:new_match.start()].count(
'\n') + 1 if new_match else None
assert old_line is not None and new_line is not None, "failed to locate anchors"
assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})"
total_lines = test_code.count('\n') + 1
assert new_line >= total_lines - \
2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}"
def test_apply_edits_with_improved_matching():
"""Test that _apply_edits_locally uses improved matching."""
original_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
public string message = "Hello World";
void Start()
{
Debug.Log(message);
}
}'''
# Test anchor_insert with the problematic pattern
edits = [{
"op": "anchor_insert",
"anchor": r"\s*}\s*$", # This should now find the class end
"position": "before",
"text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n"
}]
result = script_apply_edits_module._apply_edits_locally(
original_code, edits)
lines = result.split('\n')
try:
idx = next(i for i, line in enumerate(lines) if "NewMethod" in line)
except StopIteration:
assert False, "NewMethod not found in result"
total_lines = len(lines)
assert idx >= total_lines - \
5, f"method inserted too early (idx={idx}, total_lines={total_lines})"
if __name__ == "__main__":
print("Testing improved anchor matching...")
print("="*60)
success1 = test_improved_anchor_matching()
print("\n" + "="*60)
print("Comparing old vs new behavior...")
success2 = test_old_vs_new_matching()
print("\n" + "="*60)
print("Testing _apply_edits_locally with improved matching...")
success3 = test_apply_edits_with_improved_matching()
print("\n" + "="*60)
if success1 and success2 and success3:
print("🎉 ALL TESTS PASSED! Improved anchor matching is working!")
else:
print("💥 Some tests failed. Need more work on anchor matching.")

View File

@ -0,0 +1,336 @@
"""
Comprehensive test suite for Unity instance routing.
These tests validate that set_active_instance correctly routes subsequent
tool calls to the intended Unity instance across ALL tool categories.
DESIGN: Single source of truth via middleware state:
- set_active_instance tool stores instance per session in UnityInstanceMiddleware
- Middleware injects instance into ctx.set_state() for each tool call
- get_unity_instance_from_context() reads from ctx.get_state()
- All tools (GameObject, Script, Asset, etc.) use get_unity_instance_from_context()
"""
import pytest
from unittest.mock import AsyncMock, Mock, MagicMock, patch
from fastmcp import Context
from unity_instance_middleware import UnityInstanceMiddleware
from tools import get_unity_instance_from_context
class TestInstanceRoutingBasics:
"""Test basic middleware functionality."""
def test_middleware_stores_and_retrieves_instance(self):
"""Middleware should store and retrieve instance per session."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "test-session-1"
# Set active instance
middleware.set_active_instance(ctx, "TestProject@abc123")
# Retrieve should return same instance
assert middleware.get_active_instance(ctx) == "TestProject@abc123"
def test_middleware_isolates_sessions(self):
"""Different sessions should have independent instance selections."""
middleware = UnityInstanceMiddleware()
ctx1 = Mock(spec=Context)
ctx1.session_id = "session-1"
ctx1.client_id = "client-1"
ctx2 = Mock(spec=Context)
ctx2.session_id = "session-2"
ctx2.client_id = "client-2"
# Set different instances for different sessions
middleware.set_active_instance(ctx1, "Project1@aaa")
middleware.set_active_instance(ctx2, "Project2@bbb")
# Each session should retrieve its own instance
assert middleware.get_active_instance(ctx1) == "Project1@aaa"
assert middleware.get_active_instance(ctx2) == "Project2@bbb"
def test_middleware_fallback_to_client_id(self):
"""When session_id unavailable, should use client_id."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = None
ctx.client_id = "client-123"
middleware.set_active_instance(ctx, "Project@xyz")
assert middleware.get_active_instance(ctx) == "Project@xyz"
def test_middleware_fallback_to_global(self):
"""When no session/client id, should use 'global' key."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = None
ctx.client_id = None
middleware.set_active_instance(ctx, "Project@global")
assert middleware.get_active_instance(ctx) == "Project@global"
class TestInstanceRoutingIntegration:
"""Test that instance routing works end-to-end for all tool categories."""
@pytest.mark.asyncio
async def test_middleware_injects_state_into_context(self):
"""Middleware on_call_tool should inject instance into ctx state."""
middleware = UnityInstanceMiddleware()
# Create mock context with state management
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
state_storage = {}
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
# Create middleware context
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
# Set active instance
middleware.set_active_instance(ctx, "TestProject@abc123")
# Mock call_next
async def mock_call_next(ctx):
return {"success": True}
# Execute middleware
await middleware.on_call_tool(middleware_ctx, mock_call_next)
# Verify state was injected
ctx.set_state.assert_called_once_with("unity_instance", "TestProject@abc123")
def test_get_unity_instance_from_context_checks_state(self):
"""get_unity_instance_from_context must read from ctx.get_state()."""
ctx = Mock(spec=Context)
# Set up state storage (only source of truth now)
state_storage = {"unity_instance": "Project@state123"}
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
# Call and verify
result = get_unity_instance_from_context(ctx)
assert result == "Project@state123", \
"get_unity_instance_from_context must read from ctx.get_state()!"
def test_get_unity_instance_returns_none_when_not_set(self):
"""Should return None when no instance is set."""
ctx = Mock(spec=Context)
# Empty state storage
state_storage = {}
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
result = get_unity_instance_from_context(ctx)
assert result is None
class TestInstanceRoutingToolCategories:
"""Test instance routing for each tool category."""
def _create_mock_context_with_instance(self, instance_id: str):
"""Helper to create a mock context with instance set via middleware."""
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
# Set up state storage (only source of truth)
state_storage = {"unity_instance": instance_id}
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
return ctx
@pytest.mark.parametrize("tool_category,tool_names", [
("GameObject", ["manage_gameobject"]),
("Asset", ["manage_asset"]),
("Scene", ["manage_scene"]),
("Editor", ["manage_editor"]),
("Console", ["read_console"]),
("Menu", ["execute_menu_item"]),
("Shader", ["manage_shader"]),
("Prefab", ["manage_prefabs"]),
("Tests", ["run_tests"]),
("Script", ["create_script", "delete_script", "apply_text_edits", "script_apply_edits"]),
("Resources", ["unity_instances", "menu_items", "tests"]),
])
def test_tool_category_respects_active_instance(self, tool_category, tool_names):
"""All tool categories must respect set_active_instance."""
# This is a specification test - individual tools need separate implementation tests
pass # Placeholder for category-level test
class TestInstanceRoutingRaceConditions:
"""Test for race conditions and timing issues."""
@pytest.mark.asyncio
async def test_rapid_instance_switching(self):
"""Rapidly switching instances should not cause routing errors."""
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
state_storage = {}
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
instances = ["Project1@aaa", "Project2@bbb", "Project3@ccc"]
for instance in instances:
middleware.set_active_instance(ctx, instance)
# Create middleware context
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_call_next(ctx):
return {"success": True}
# Execute middleware
await middleware.on_call_tool(middleware_ctx, mock_call_next)
# Verify correct instance is set
assert state_storage.get("unity_instance") == instance
@pytest.mark.asyncio
async def test_set_then_immediate_create_script(self):
"""Setting instance then immediately creating script should route correctly."""
# This reproduces the bug: set_active_instance → create_script went to wrong instance
middleware = UnityInstanceMiddleware()
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
ctx.info = Mock()
state_storage = {}
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
ctx.request_context = None
# Set active instance
middleware.set_active_instance(ctx, "ramble@8e29de57")
# Simulate middleware intercepting create_script call
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_create_script_call(ctx):
# This simulates what create_script does
instance = get_unity_instance_from_context(ctx)
return {"success": True, "routed_to": instance}
# Inject state via middleware
await middleware.on_call_tool(middleware_ctx, mock_create_script_call)
# Verify create_script would route to correct instance
result = await mock_create_script_call(ctx)
assert result["routed_to"] == "ramble@8e29de57", \
"create_script must route to the instance set by set_active_instance"
class TestInstanceRoutingSequentialOperations:
"""Test the exact failure scenario from user report."""
@pytest.mark.asyncio
async def test_four_script_creation_sequence(self):
"""
Reproduce the exact failure:
1. set_active(ramble) create_script1 should go to ramble
2. set_active(UnityMCPTests) create_script2 should go to UnityMCPTests
3. set_active(ramble) create_script3 should go to ramble
4. set_active(UnityMCPTests) create_script4 should go to UnityMCPTests
ACTUAL BEHAVIOR:
- Script1 went to UnityMCPTests (WRONG)
- Script2 went to ramble (WRONG)
- Script3 went to ramble (CORRECT)
- Script4 went to UnityMCPTests (CORRECT)
"""
middleware = UnityInstanceMiddleware()
# Track which instance each script was created in
script_routes = {}
async def simulate_create_script(ctx, script_name, expected_instance):
# Inject state via middleware
middleware_ctx = Mock()
middleware_ctx.fastmcp_context = ctx
async def mock_tool_call(middleware_ctx):
# The middleware passes the middleware_ctx, we need the fastmcp_context
tool_ctx = middleware_ctx.fastmcp_context
instance = get_unity_instance_from_context(tool_ctx)
script_routes[script_name] = instance
return {"success": True}
await middleware.on_call_tool(middleware_ctx, mock_tool_call)
return expected_instance
# Session context
ctx = Mock(spec=Context)
ctx.session_id = "test-session"
ctx.info = Mock()
state_storage = {}
ctx.set_state = Mock(side_effect=lambda k, v: state_storage.__setitem__(k, v))
ctx.get_state = Mock(side_effect=lambda k: state_storage.get(k))
# Execute sequence
middleware.set_active_instance(ctx, "ramble@8e29de57")
expected1 = await simulate_create_script(ctx, "Script1", "ramble@8e29de57")
middleware.set_active_instance(ctx, "UnityMCPTests@cc8756d4")
expected2 = await simulate_create_script(ctx, "Script2", "UnityMCPTests@cc8756d4")
middleware.set_active_instance(ctx, "ramble@8e29de57")
expected3 = await simulate_create_script(ctx, "Script3", "ramble@8e29de57")
middleware.set_active_instance(ctx, "UnityMCPTests@cc8756d4")
expected4 = await simulate_create_script(ctx, "Script4", "UnityMCPTests@cc8756d4")
# Assertions - these will FAIL until the bug is fixed
assert script_routes.get("Script1") == expected1, \
f"Script1 should route to {expected1}, got {script_routes.get('Script1')}"
assert script_routes.get("Script2") == expected2, \
f"Script2 should route to {expected2}, got {script_routes.get('Script2')}"
assert script_routes.get("Script3") == expected3, \
f"Script3 should route to {expected3}, got {script_routes.get('Script3')}"
assert script_routes.get("Script4") == expected4, \
f"Script4 should route to {expected4}, got {script_routes.get('Script4')}"
# Test regimen summary
"""
COMPREHENSIVE TEST REGIMEN FOR INSTANCE ROUTING
Prerequisites:
- Two Unity instances running (e.g., ramble, UnityMCPTests)
- MCP server connected to both instances
Test Categories:
1. Middleware State Management (4 tests)
2. Middleware Integration (2 tests)
3. get_unity_instance_from_context (2 tests)
4. Tool Category Coverage (11 categories)
5. Race Conditions (2 tests)
6. Sequential Operations (1 test - reproduces exact user bug)
Total: 21 tests
DESIGN:
Single source of truth via middleware state:
- set_active_instance stores instance per session in UnityInstanceMiddleware
- Middleware injects instance into ctx.set_state() for each tool call
- get_unity_instance_from_context() reads from ctx.get_state()
- All tools use get_unity_instance_from_context()
This ensures consistent routing across ALL tool categories (Script, GameObject, Asset, etc.)
"""

View File

@ -0,0 +1,80 @@
from .test_helpers import DummyContext
def test_manage_gameobject_uses_session_state(monkeypatch):
"""Test that tools use session-stored active instance via middleware"""
from unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
# Arrange: Initialize middleware and set a session-scoped active instance
middleware = UnityInstanceMiddleware()
set_unity_instance_middleware(middleware)
ctx = DummyContext()
middleware.set_active_instance(ctx, "SessionProj@AAAA1111")
assert middleware.get_active_instance(ctx) == "SessionProj@AAAA1111"
# Simulate middleware injection into request state
ctx.set_state("unity_instance", "SessionProj@AAAA1111")
captured = {}
# Monkeypatch transport to capture the resolved instance_id
def fake_send(command_type, params, **kwargs):
captured["command_type"] = command_type
captured["params"] = params
captured["instance_id"] = kwargs.get("instance_id")
return {"success": True, "data": {}}
import tools.manage_gameobject as mg
monkeypatch.setattr("tools.manage_gameobject.send_command_with_retry", fake_send)
# Act: call tool - should use session state from context
res = mg.manage_gameobject(
ctx,
action="create",
name="SessionSphere",
primitive_type="Sphere",
)
# Assert: uses session-stored instance
assert res.get("success") is True
assert captured.get("command_type") == "manage_gameobject"
assert captured.get("instance_id") == "SessionProj@AAAA1111"
def test_manage_gameobject_without_active_instance(monkeypatch):
"""Test that tools work with no active instance set (uses None/default)"""
from unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
# Arrange: Initialize middleware with no active instance set
middleware = UnityInstanceMiddleware()
set_unity_instance_middleware(middleware)
ctx = DummyContext()
assert middleware.get_active_instance(ctx) is None
# Don't set any state in context
captured = {}
def fake_send(command_type, params, **kwargs):
captured["instance_id"] = kwargs.get("instance_id")
return {"success": True, "data": {}}
import tools.manage_gameobject as mg
monkeypatch.setattr("tools.manage_gameobject.send_command_with_retry", fake_send)
# Act: call without active instance
res = mg.manage_gameobject(
ctx,
action="create",
name="DefaultSphere",
primitive_type="Sphere",
)
# Assert: uses None (connection pool will pick default)
assert res.get("success") is True
assert captured.get("instance_id") is None

View File

@ -0,0 +1,112 @@
"""
Simple tests for JSON string parameter parsing logic.
Tests the core JSON parsing functionality without MCP server dependencies.
"""
import json
import pytest
def parse_properties_json(properties):
"""
Test the JSON parsing logic that would be used in manage_asset.
This simulates the core parsing functionality.
"""
if isinstance(properties, str):
try:
parsed = json.loads(properties)
return parsed, "success"
except json.JSONDecodeError as e:
return properties, f"failed to parse: {e}"
return properties, "no_parsing_needed"
class TestJsonParsingLogic:
"""Test the core JSON parsing logic."""
def test_valid_json_string_parsing(self):
"""Test that valid JSON strings are correctly parsed."""
json_string = '{"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}'
result, status = parse_properties_json(json_string)
assert status == "success"
assert isinstance(result, dict)
assert result["shader"] == "Universal Render Pipeline/Lit"
assert result["color"] == [0, 0, 1, 1]
def test_invalid_json_string_handling(self):
"""Test that invalid JSON strings are handled gracefully."""
invalid_json = '{"invalid": json, "missing": quotes}'
result, status = parse_properties_json(invalid_json)
assert "failed to parse" in status
assert result == invalid_json # Original string returned
def test_dict_input_unchanged(self):
"""Test that dict inputs are passed through unchanged."""
original_dict = {"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}
result, status = parse_properties_json(original_dict)
assert status == "no_parsing_needed"
assert result == original_dict
def test_none_input_handled(self):
"""Test that None input is handled correctly."""
result, status = parse_properties_json(None)
assert status == "no_parsing_needed"
assert result is None
def test_complex_json_parsing(self):
"""Test parsing of complex JSON with nested objects and arrays."""
complex_json = '''
{
"shader": "Universal Render Pipeline/Lit",
"color": [1, 0, 0, 1],
"float": {"name": "_Metallic", "value": 0.5},
"texture": {"name": "_MainTex", "path": "Assets/Textures/Test.png"}
}
'''
result, status = parse_properties_json(complex_json)
assert status == "success"
assert isinstance(result, dict)
assert result["shader"] == "Universal Render Pipeline/Lit"
assert result["color"] == [1, 0, 0, 1]
assert result["float"]["name"] == "_Metallic"
assert result["float"]["value"] == 0.5
assert result["texture"]["name"] == "_MainTex"
assert result["texture"]["path"] == "Assets/Textures/Test.png"
def test_empty_json_string(self):
"""Test handling of empty JSON string."""
empty_json = "{}"
result, status = parse_properties_json(empty_json)
assert status == "success"
assert isinstance(result, dict)
assert len(result) == 0
def test_malformed_json_edge_cases(self):
"""Test various malformed JSON edge cases."""
test_cases = [
'{"incomplete": }',
'{"missing": "quote}',
'{"trailing": "comma",}',
'{"unclosed": [1, 2, 3}',
'not json at all',
'{"nested": {"broken": }'
]
for malformed_json in test_cases:
result, status = parse_properties_json(malformed_json)
assert "failed to parse" in status
assert result == malformed_json
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,70 @@
import ast
from pathlib import Path
import pytest
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[1]
candidates = [
ROOT / "MCPForUnity" / "UnityMcpServer~" / "src",
ROOT / "UnityMcpServer~" / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"MCP for Unity server source not found. Tried:\n" + searched,
allow_module_level=True,
)
@pytest.mark.skip(reason="TODO: ensure server logs only to stderr and rotating file")
def test_no_stdout_output_from_tools():
pass
def test_no_print_statements_in_codebase():
"""Ensure no stray print/sys.stdout writes remain in server source."""
offenders = []
syntax_errors = []
for py_file in SRC.rglob("*.py"):
# Skip virtual envs and third-party packages if they exist under SRC
parts = set(py_file.parts)
if ".venv" in parts or "site-packages" in parts:
continue
try:
text = py_file.read_text(encoding="utf-8", errors="strict")
except UnicodeDecodeError:
# Be tolerant of encoding edge cases in source tree without silently dropping bytes
text = py_file.read_text(encoding="utf-8", errors="replace")
try:
tree = ast.parse(text, filename=str(py_file))
except SyntaxError:
syntax_errors.append(py_file.relative_to(SRC))
continue
class StdoutVisitor(ast.NodeVisitor):
def __init__(self):
self.hit = False
def visit_Call(self, node: ast.Call):
# print(...)
if isinstance(node.func, ast.Name) and node.func.id == "print":
self.hit = True
# sys.stdout.write(...)
if isinstance(node.func, ast.Attribute) and node.func.attr == "write":
val = node.func.value
if isinstance(val, ast.Attribute) and val.attr == "stdout":
if isinstance(val.value, ast.Name) and val.value.id == "sys":
self.hit = True
self.generic_visit(node)
v = StdoutVisitor()
v.visit(tree)
if v.hit:
offenders.append(py_file.relative_to(SRC))
assert not syntax_errors, "syntax errors in: " + \
", ".join(str(e) for e in syntax_errors)
assert not offenders, "stdout writes found in: " + \
", ".join(str(o) for o in offenders)

View File

@ -0,0 +1,136 @@
"""
Tests for JSON string parameter parsing in manage_asset tool.
"""
import pytest
import json
from .test_helpers import DummyContext
from tools.manage_asset import manage_asset
class TestManageAssetJsonParsing:
"""Test JSON string parameter parsing functionality."""
@pytest.mark.asyncio
async def test_properties_json_string_parsing(self, monkeypatch):
"""Test that JSON string properties are correctly parsed to dict."""
# Mock context
ctx = DummyContext()
# Patch Unity transport
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully", "data": {"path": "Assets/Test.mat"}}
monkeypatch.setattr("tools.manage_asset.async_send_command_with_retry", fake_async)
# Test with JSON string properties
result = await manage_asset(
ctx=ctx,
action="create",
path="Assets/Test.mat",
asset_type="Material",
properties='{"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}'
)
# Verify JSON parsing was logged
assert "manage_asset: coerced properties from JSON string to dict" in ctx.log_info
# Verify the result
assert result["success"] is True
assert "Asset created successfully" in result["message"]
@pytest.mark.asyncio
async def test_properties_invalid_json_string(self, monkeypatch):
"""Test handling of invalid JSON string properties."""
ctx = DummyContext()
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr("tools.manage_asset.async_send_command_with_retry", fake_async)
# Test with invalid JSON string
result = await manage_asset(
ctx=ctx,
action="create",
path="Assets/Test.mat",
asset_type="Material",
properties='{"invalid": json, "missing": quotes}'
)
# Verify behavior: no coercion log for invalid JSON; warning may be emitted by some runtimes
assert not any("coerced properties" in msg for msg in ctx.log_info)
assert result.get("success") is True
@pytest.mark.asyncio
async def test_properties_dict_unchanged(self, monkeypatch):
"""Test that dict properties are passed through unchanged."""
ctx = DummyContext()
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr("tools.manage_asset.async_send_command_with_retry", fake_async)
# Test with dict properties
properties_dict = {"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}
result = await manage_asset(
ctx=ctx,
action="create",
path="Assets/Test.mat",
asset_type="Material",
properties=properties_dict
)
# Verify no JSON parsing was attempted (allow initial Processing log)
assert not any("coerced properties" in msg for msg in ctx.log_info)
assert result["success"] is True
@pytest.mark.asyncio
async def test_properties_none_handling(self, monkeypatch):
"""Test that None properties are handled correctly."""
ctx = DummyContext()
async def fake_async(cmd, params, **kwargs):
return {"success": True, "message": "Asset created successfully"}
monkeypatch.setattr("tools.manage_asset.async_send_command_with_retry", fake_async)
# Test with None properties
result = await manage_asset(
ctx=ctx,
action="create",
path="Assets/Test.mat",
asset_type="Material",
properties=None
)
# Verify no JSON parsing was attempted (allow initial Processing log)
assert not any("coerced properties" in msg for msg in ctx.log_info)
assert result["success"] is True
class TestManageGameObjectJsonParsing:
"""Test JSON string parameter parsing for manage_gameobject tool."""
@pytest.mark.asyncio
async def test_component_properties_json_string_parsing(self, monkeypatch):
"""Test that JSON string component_properties are correctly parsed."""
from tools.manage_gameobject import manage_gameobject
ctx = DummyContext()
def fake_send(cmd, params, **kwargs):
return {"success": True, "message": "GameObject created successfully"}
monkeypatch.setattr("tools.manage_gameobject.send_command_with_retry", fake_send)
# Test with JSON string component_properties
result = manage_gameobject(
ctx=ctx,
action="create",
name="TestObject",
component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}'
)
# Verify JSON parsing was logged
assert "manage_gameobject: coerced component_properties from JSON string to dict" in ctx.log_info
# Verify the result
assert result["success"] is True

View File

@ -0,0 +1,34 @@
import asyncio
from .test_helpers import DummyContext
import tools.manage_asset as manage_asset_mod
def test_manage_asset_pagination_coercion(monkeypatch):
captured = {}
async def fake_async_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(manage_asset_mod, "async_send_command_with_retry", fake_async_send)
result = asyncio.run(
manage_asset_mod.manage_asset(
ctx=DummyContext(),
action="search",
path="Assets",
page_size="50",
page_number="2",
)
)
assert result == {"success": True, "data": {}}
assert captured["params"]["pageSize"] == 50
assert captured["params"]["pageNumber"] == 2

View File

@ -0,0 +1,31 @@
from .test_helpers import DummyContext
import tools.manage_gameobject as manage_go_mod
def test_manage_gameobject_boolean_and_tag_mapping(monkeypatch):
captured = {}
def fake_send(cmd, params):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(manage_go_mod, "send_command_with_retry", fake_send)
# find by tag: allow tag to map to searchTerm
resp = manage_go_mod.manage_gameobject(
ctx=DummyContext(),
action="find",
search_method="by_tag",
tag="Player",
find_all="true",
search_inactive="0",
)
# Loosen equality: wrapper may include a diagnostic message
assert resp.get("success") is True
assert "data" in resp
# ensure tag mapped to searchTerm and booleans passed through; C# side coerces true/false already
assert captured["params"]["searchTerm"] == "Player"
assert captured["params"]["findAll"] == "true" or captured["params"]["findAll"] is True
assert captured["params"]["searchInactive"] in ("0", False, 0)

View File

@ -0,0 +1,109 @@
import pytest
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs): # ignore decorator kwargs like description
def _decorator(fn):
self.tools[fn.__name__] = fn
return fn
return _decorator
def _register_tools():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.manage_script # trigger decorator registration
# Get the registered tools from the registry
from registry import get_registered_tools
registered_tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in registered_tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_split_uri_unity_path(monkeypatch):
test_tools = _register_tools()
captured = {}
def fake_send(cmd, params): # capture params and return success
captured['cmd'] = cmd
captured['params'] = params
return {"success": True, "message": "ok"}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = test_tools['apply_text_edits']
uri = "unity://path/Assets/Scripts/MyScript.cs"
fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None)
assert captured['cmd'] == 'manage_script'
assert captured['params']['name'] == 'MyScript'
assert captured['params']['path'] == 'Assets/Scripts'
@pytest.mark.parametrize(
"uri, expected_name, expected_path",
[
("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs",
"Foo Bar", "Assets/Scripts"),
("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"),
("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs",
"Hello", "Assets/Scripts"),
# outside Assets → fall back to normalized dir
("file:///tmp/Other.cs", "Other", "tmp"),
],
)
def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path):
test_tools = _register_tools()
captured = {}
def fake_send(_cmd, params):
captured['cmd'] = _cmd
captured['params'] = params
return {"success": True, "message": "ok"}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = test_tools['apply_text_edits']
fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None)
assert captured['params']['name'] == expected_name
assert captured['params']['path'] == expected_path
def test_split_uri_plain_path(monkeypatch):
test_tools = _register_tools()
captured = {}
def fake_send(_cmd, params):
captured['params'] = params
return {"success": True, "message": "ok"}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = test_tools['apply_text_edits']
fn(DummyContext(), uri="Assets/Scripts/Thing.cs",
edits=[], precondition_sha256=None)
assert captured['params']['name'] == 'Thing'
assert captured['params']['path'] == 'Assets/Scripts'

View File

@ -0,0 +1,78 @@
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn):
self.tools[fn.__name__] = fn
return fn
return deco
def setup_tools():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.read_console
# Get the registered tools from the registry
from registry import get_registered_tools
registered_tools = get_registered_tools()
# Add all console-related tools to our dummy MCP
for tool_info in registered_tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['read_console', 'console']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_read_console_full_default(monkeypatch):
tools = setup_tools()
read_console = tools["read_console"]
captured = {}
def fake_send(cmd, params):
captured["params"] = params
return {
"success": True,
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
}
# Patch the send_command_with_retry function in the tools module
import tools.read_console
monkeypatch.setattr(tools.read_console,
"send_command_with_retry", fake_send)
resp = read_console(ctx=DummyContext(), action="get", count=10)
assert resp == {
"success": True,
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
}
assert captured["params"]["count"] == 10
assert captured["params"]["includeStacktrace"] is True
def test_read_console_truncated(monkeypatch):
tools = setup_tools()
read_console = tools["read_console"]
captured = {}
def fake_send(cmd, params):
captured["params"] = params
return {
"success": True,
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace"}]},
}
# Patch the send_command_with_retry function in the tools module
import tools.read_console
monkeypatch.setattr(tools.read_console,
"send_command_with_retry", fake_send)
resp = read_console(ctx=DummyContext(), action="get", count=10, include_stacktrace=False)
assert resp == {"success": True, "data": {
"lines": [{"level": "error", "message": "oops"}]}}
assert captured["params"]["includeStacktrace"] is False

View File

@ -0,0 +1,57 @@
import asyncio
import pytest
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn):
self.tools[fn.__name__] = fn
return fn
return deco
@pytest.fixture()
def resource_tools():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.resource_tools
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all resource-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['find_in_file', 'list_resources', 'read_resource']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_read_resource_minimal_metadata_only(resource_tools, tmp_path):
proj = tmp_path
assets = proj / "Assets"
assets.mkdir()
f = assets / "A.txt"
content = "hello world"
f.write_text(content, encoding="utf-8")
read_resource = resource_tools["read_resource"]
loop = asyncio.new_event_loop()
try:
resp = loop.run_until_complete(
read_resource(uri="unity://path/Assets/A.txt",
ctx=DummyContext(), project_root=str(proj))
)
finally:
loop.close()
assert resp["success"] is True
data = resp["data"]
assert "text" not in data
meta = data["metadata"]
assert "sha256" in meta and len(meta["sha256"]) == 64
assert meta["lengthBytes"] == len(content.encode("utf-8"))

View File

@ -0,0 +1,75 @@
import pytest
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self):
self._tools = {}
def tool(self, *args, **kwargs): # accept kwargs like description
def deco(fn):
self._tools[fn.__name__] = fn
return fn
return deco
@pytest.fixture()
def resource_tools():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.resource_tools
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all resource-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['find_in_file', 'list_resources', 'read_resource']):
mcp._tools[tool_name] = tool_info['func']
return mcp._tools
def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, monkeypatch):
# Create fake project structure
proj = tmp_path
assets = proj / "Assets" / "Scripts"
assets.mkdir(parents=True)
(assets / "A.cs").write_text("// a", encoding="utf-8")
(assets / "B.txt").write_text("b", encoding="utf-8")
outside = tmp_path / "Outside.cs"
outside.write_text("// outside", encoding="utf-8")
# Symlink attempting to escape
sneaky_link = assets / "link_out"
try:
sneaky_link.symlink_to(outside)
except Exception:
# Some platforms may not allow symlinks in tests; ignore
pass
list_resources = resource_tools["list_resources"]
# Only .cs under Assets should be listed
import asyncio
resp = asyncio.run(
list_resources(ctx=DummyContext(), pattern="*.cs", under="Assets",
limit=50, project_root=str(proj))
)
assert resp["success"] is True
uris = resp["data"]["uris"]
assert any(u.endswith("Assets/Scripts/A.cs") for u in uris)
assert not any(u.endswith("B.txt") for u in uris)
assert not any(u.endswith("Outside.cs") for u in uris)
def test_resource_list_rejects_outside_paths(resource_tools, tmp_path):
proj = tmp_path
# under points outside Assets
list_resources = resource_tools["list_resources"]
import asyncio
resp = asyncio.run(
list_resources(ctx=DummyContext(), pattern="*.cs", under="..",
limit=10, project_root=str(proj))
)
assert resp["success"] is False
assert "Assets" in resp.get(
"error", "") or "under project root" in resp.get("error", "")

View File

@ -0,0 +1,36 @@
import pytest
@pytest.mark.xfail(strict=False, reason="pending: create new script, validate, apply edits, build and compile scene")
def test_script_edit_happy_path():
pass
@pytest.mark.xfail(strict=False, reason="pending: multiple micro-edits debounce to single compilation")
def test_micro_edits_debounce():
pass
@pytest.mark.xfail(strict=False, reason="pending: line ending variations handled correctly")
def test_line_endings_and_columns():
pass
@pytest.mark.xfail(strict=False, reason="pending: regex_replace no-op with allow_noop honored")
def test_regex_replace_noop_allowed():
pass
@pytest.mark.xfail(strict=False, reason="pending: large edit size boundaries and overflow protection")
def test_large_edit_size_and_overflow():
pass
@pytest.mark.xfail(strict=False, reason="pending: symlink and junction protections on edits")
def test_symlink_and_junction_protection():
pass
@pytest.mark.xfail(strict=False, reason="pending: atomic write guarantees")
def test_atomic_write_guarantees():
pass

View File

@ -0,0 +1,179 @@
import pytest
import asyncio
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs): # accept decorator kwargs like description
def decorator(func):
self.tools[func.__name__] = func
return func
return decorator
def setup_manage_script():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.manage_script
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def setup_manage_asset():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.manage_asset
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all asset-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['asset', 'manage_asset']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_apply_text_edits_long_file(monkeypatch):
tools = setup_manage_script()
apply_edits = tools["apply_text_edits"]
captured = {}
def fake_send(cmd, params):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edit = {"startLine": 1005, "startCol": 0,
"endLine": 1005, "endCol": 5, "newText": "Hello"}
ctx = DummyContext()
resp = apply_edits(ctx, "unity://path/Assets/Scripts/LongFile.cs", [edit])
assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "apply_text_edits"
assert captured["params"]["edits"][0]["startLine"] == 1005
assert resp["success"] is True
def test_sequential_edits_use_precondition(monkeypatch):
tools = setup_manage_script()
apply_edits = tools["apply_text_edits"]
calls = []
def fake_send(cmd, params):
calls.append(params)
return {"success": True, "sha256": f"hash{len(calls)}"}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edit1 = {"startLine": 1, "startCol": 0, "endLine": 1,
"endCol": 0, "newText": "//header\n"}
resp1 = apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs", [edit1])
edit2 = {"startLine": 2, "startCol": 0, "endLine": 2,
"endCol": 0, "newText": "//second\n"}
resp2 = apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs",
[edit2], precondition_sha256=resp1["sha256"])
assert calls[1]["precondition_sha256"] == resp1["sha256"]
assert resp2["sha256"] == "hash2"
def test_apply_text_edits_forwards_options(monkeypatch):
tools = setup_manage_script()
apply_edits = tools["apply_text_edits"]
captured = {}
def fake_send(cmd, params):
captured["params"] = params
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"}
apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs",
[{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}], options=opts)
assert captured["params"].get("options") == opts
def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch):
tools = setup_manage_script()
apply_edits = tools["apply_text_edits"]
captured = {}
def fake_send(cmd, params):
captured["params"] = params
return {"success": True}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edits = [
{"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"},
{"startLine": 3, "startCol": 2, "endLine": 3,
"endCol": 2, "newText": "// tail\n"},
]
apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs",
edits, precondition_sha256="x")
opts = captured["params"].get("options", {})
assert opts.get("applyMode") == "atomic"
def test_manage_asset_prefab_modify_request(monkeypatch):
tools = setup_manage_asset()
manage_asset = tools["manage_asset"]
captured = {}
async def fake_async(cmd, params, loop=None):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True}
# Patch the async function in the tools module
import tools.manage_asset as tools_manage_asset
# Patch both at the module and at the function closure location
monkeypatch.setattr(tools_manage_asset,
"async_send_command_with_retry", fake_async)
# Also patch the globals of the function object (handles dynamically loaded module alias)
manage_asset.__globals__["async_send_command_with_retry"] = fake_async
async def run():
resp = await manage_asset(
DummyContext(),
action="modify",
path="Assets/Prefabs/Player.prefab",
properties={"hp": 100},
)
assert captured["cmd"] == "manage_asset"
assert captured["params"]["action"] == "modify"
assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab"
assert captured["params"]["properties"] == {"hp": 100}
assert resp["success"] is True
asyncio.run(run())

View File

@ -0,0 +1,61 @@
import os
import importlib
def test_endpoint_rejects_non_http(tmp_path, monkeypatch):
# Point data dir to temp to avoid touching real files
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd")
# Import the telemetry module
telemetry = importlib.import_module("telemetry")
importlib.reload(telemetry)
tc = telemetry.TelemetryCollector()
# Should have fallen back to default endpoint
assert tc.config.endpoint == tc.config.default_endpoint
def test_config_preferred_then_env_override(tmp_path, monkeypatch):
# Simulate config telemetry endpoint
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.delenv("UNITY_MCP_TELEMETRY_ENDPOINT", raising=False)
# Patch config.telemetry_endpoint via import mocking
cfg_mod = importlib.import_module("config")
old_endpoint = cfg_mod.config.telemetry_endpoint
cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry"
try:
telemetry = importlib.import_module("telemetry")
importlib.reload(telemetry)
tc = telemetry.TelemetryCollector()
# When no env override is set, config endpoint is preferred
assert tc.config.endpoint == "https://example.com/telemetry"
# Env should override config
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT",
"https://override.example/ep")
importlib.reload(telemetry)
tc2 = telemetry.TelemetryCollector()
assert tc2.config.endpoint == "https://override.example/ep"
finally:
cfg_mod.config.telemetry_endpoint = old_endpoint
def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
# Import the telemetry module
telemetry = importlib.import_module("telemetry")
importlib.reload(telemetry)
tc1 = telemetry.TelemetryCollector()
first_uuid = tc1._customer_uuid
# Write malformed milestones
tc1.config.milestones_file.write_text("{not-json}", encoding="utf-8")
# Reload collector; UUID should remain same despite bad milestones
importlib.reload(telemetry)
tc2 = telemetry.TelemetryCollector()
assert tc2._customer_uuid == first_uuid

View File

@ -0,0 +1,52 @@
import types
import threading
import time
import queue as q
import telemetry
def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog):
caplog.set_level("DEBUG")
collector = telemetry.TelemetryCollector()
# Force-enable telemetry regardless of env settings from conftest
collector.config.enabled = True
# Wake existing worker once so it observes the new queue on the next loop
collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": -1})
# Replace queue with tiny one to trigger backpressure quickly
small_q = q.Queue(maxsize=2)
collector._queue = small_q
# Give the worker a moment to switch queues
time.sleep(0.02)
# Make sends slow to build backlog and exercise worker
def slow_send(self, rec):
time.sleep(0.05)
collector._send_telemetry = types.MethodType(slow_send, collector)
# Fire many events quickly; record() should not block even when queue fills
start = time.perf_counter()
for i in range(50):
collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": i})
elapsed_ms = (time.perf_counter() - start) * 1000.0
# Should be fast despite backpressure (non-blocking enqueue or drop)
# Timeout relaxed to 200ms to handle thread scheduling variance in CI/local environments
assert elapsed_ms < 200.0, f"Took {elapsed_ms:.1f}ms (expected <200ms)"
# Allow worker to process some
time.sleep(0.3)
# Verify drops were logged (queue full backpressure)
dropped_logs = [
m for m in caplog.messages if "Telemetry queue full; dropping" in m]
assert len(dropped_logs) >= 1
# Ensure only one worker thread exists and is alive
assert collector._worker.is_alive()
worker_threads = [
t for t in threading.enumerate() if t is collector._worker]
assert len(worker_threads) == 1

View File

@ -0,0 +1,114 @@
import importlib
def _get_decorator_module():
# Import the telemetry_decorator module from the MCP for Unity server src
import sys
import pathlib
import types
# Tests can now import directly from parent package
# Remove any previously stubbed module to force real import
sys.modules.pop("telemetry_decorator", None)
# Preload a minimal telemetry stub to satisfy telemetry_decorator imports
tel = types.ModuleType("telemetry")
class _MilestoneType:
FIRST_TOOL_USAGE = "first_tool_usage"
FIRST_SCRIPT_CREATION = "first_script_creation"
FIRST_SCENE_MODIFICATION = "first_scene_modification"
tel.MilestoneType = _MilestoneType
def _noop(*a, **k):
pass
tel.record_resource_usage = _noop
tel.record_tool_usage = _noop
tel.record_milestone = _noop
tel.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("telemetry", tel)
mod = importlib.import_module("telemetry_decorator")
# Drop stub to avoid bleed-through into other tests
sys.modules.pop("telemetry", None)
# Ensure attributes exist for monkeypatch targets even if not exported
if not hasattr(mod, "record_tool_usage"):
def _noop_record_tool_usage(*a, **k):
pass
mod.record_tool_usage = _noop_record_tool_usage
if not hasattr(mod, "record_milestone"):
def _noop_record_milestone(*a, **k):
pass
mod.record_milestone = _noop_record_milestone
if not hasattr(mod, "_decorator_log_count"):
mod._decorator_log_count = 0
return mod
def test_subaction_extracted_from_keyword(monkeypatch):
td = _get_decorator_module()
captured = {}
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
captured["tool_name"] = tool_name
captured["success"] = success
captured["error"] = error
captured["sub_action"] = sub_action
# Silence milestones/logging in test
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
monkeypatch.setattr(td, "_decorator_log_count", 999)
def dummy_tool(ctx, action: str, name: str = ""):
return {"success": True, "name": name}
wrapped = td.telemetry_tool("manage_scene")(dummy_tool)
resp = wrapped(None, action="get_hierarchy", name="Sample")
assert resp["success"] is True
assert captured["tool_name"] == "manage_scene"
assert captured["success"] is True
assert captured["error"] is None
assert captured["sub_action"] == "get_hierarchy"
def test_subaction_extracted_from_positionals(monkeypatch):
td = _get_decorator_module()
captured = {}
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
captured["tool_name"] = tool_name
captured["sub_action"] = sub_action
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
monkeypatch.setattr(td, "_decorator_log_count", 999)
def dummy_tool(ctx, action: str, name: str = ""):
return True
wrapped = td.telemetry_tool("manage_scene")(dummy_tool)
_ = wrapped(None, "save", "MyScene")
assert captured["tool_name"] == "manage_scene"
assert captured["sub_action"] == "save"
def test_subaction_none_when_not_present(monkeypatch):
td = _get_decorator_module()
captured = {}
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
captured["tool_name"] = tool_name
captured["sub_action"] = sub_action
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
monkeypatch.setattr(td, "_decorator_log_count", 999)
def dummy_tool_without_action(ctx, name: str):
return 123
wrapped = td.telemetry_tool("apply_text_edits")(dummy_tool_without_action)
_ = wrapped(None, name="X")
assert captured["tool_name"] == "apply_text_edits"
assert captured["sub_action"] is None

View File

@ -0,0 +1,222 @@
from unity_connection import UnityConnection
import sys
import json
import struct
import socket
import threading
import time
import select
from pathlib import Path
import pytest
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[1]
candidates = [
ROOT / "MCPForUnity" / "UnityMcpServer~" / "src",
ROOT / "UnityMcpServer~" / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"MCP for Unity server source not found. Tried:\n" + searched,
allow_module_level=True,
)
# Tests can now import directly from parent package
def start_dummy_server(greeting: bytes, respond_ping: bool = False):
"""Start a minimal TCP server for handshake tests."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
conn.settimeout(1.0)
if greeting:
conn.sendall(greeting)
if respond_ping:
try:
# Read exactly n bytes helper
def _read_exact(n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = conn.recv(n - len(buf))
if not chunk:
break
buf += chunk
return buf
header = _read_exact(8)
if len(header) == 8:
length = struct.unpack(">Q", header)[0]
payload = _read_exact(length)
if payload == b'{"type":"ping"}':
resp = b'{"type":"pong"}'
conn.sendall(struct.pack(">Q", len(resp)) + resp)
except Exception:
pass
time.sleep(0.1)
try:
conn.close()
except Exception:
pass
finally:
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
return port
def start_handshake_enforcing_server():
"""Server that drops connection if client sends data before handshake."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
# If client sends any data before greeting, disconnect (poll briefly)
try:
conn.setblocking(False)
deadline = time.time() + 0.15 # short, reduces race with legitimate clients
while time.time() < deadline:
r, _, _ = select.select([conn], [], [], 0.01)
if r:
try:
peek = conn.recv(1, socket.MSG_PEEK)
except BlockingIOError:
peek = b""
except Exception:
peek = b"\x00"
if peek:
conn.close()
sock.close()
return
# No pre-handshake data observed; send greeting
conn.setblocking(True)
conn.sendall(b"MCP/0.1 FRAMING=1\n")
time.sleep(0.1)
finally:
try:
conn.close()
finally:
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
return port
def test_handshake_requires_framing():
port = start_dummy_server(b"MCP/0.1\n")
conn = UnityConnection(host="127.0.0.1", port=port)
assert conn.connect() is False
assert conn.sock is None
def test_small_frame_ping_pong():
port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True)
conn = UnityConnection(host="127.0.0.1", port=port)
try:
assert conn.connect() is True
assert conn.use_framing is True
payload = b'{"type":"ping"}'
conn.sock.sendall(struct.pack(">Q", len(payload)) + payload)
resp = conn.receive_full_response(conn.sock)
assert json.loads(resp.decode("utf-8"))["type"] == "pong"
finally:
conn.disconnect()
def test_unframed_data_disconnect():
port = start_handshake_enforcing_server()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", port))
sock.settimeout(1.0)
sock.sendall(b"BAD")
time.sleep(0.4)
try:
data = sock.recv(1024)
assert data == b""
except (ConnectionResetError, ConnectionAbortedError):
# Some platforms raise instead of returning empty bytes when the
# server closes the connection after detecting pre-handshake data.
pass
finally:
sock.close()
def test_zero_length_payload_heartbeat():
# Server that sends handshake and a zero-length heartbeat frame followed by a pong payload
import socket
import struct
import threading
import time
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
try:
conn.sendall(b"MCP/0.1 FRAMING=1\n")
time.sleep(0.02)
# Heartbeat frame (length=0)
conn.sendall(struct.pack(">Q", 0))
time.sleep(0.02)
# Real payload frame
payload = b'{"type":"pong"}'
conn.sendall(struct.pack(">Q", len(payload)) + payload)
time.sleep(0.02)
finally:
try:
conn.close()
except Exception:
pass
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
conn = UnityConnection(host="127.0.0.1", port=port)
try:
assert conn.connect() is True
# Receive should skip heartbeat and return the pong payload (or empty if only heartbeats seen)
resp = conn.receive_full_response(conn.sock)
assert resp in (b'{"type":"pong"}', b"")
finally:
conn.disconnect()
@pytest.mark.skip(reason="TODO: oversized payload should disconnect")
def test_oversized_payload_rejected():
pass
@pytest.mark.skip(reason="TODO: partial header/payload triggers timeout and disconnect")
def test_partial_frame_timeout():
pass
@pytest.mark.skip(reason="TODO: concurrency test with parallel tool invocations")
def test_parallel_invocations_no_interleaving():
pass
@pytest.mark.skip(reason="TODO: reconnection after drop mid-command")
def test_reconnect_mid_command():
pass

View File

@ -0,0 +1,53 @@
from .test_helpers import DummyContext
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn):
self.tools[fn.__name__] = fn
return fn
return deco
def setup_tools():
mcp = DummyMCP()
# Import the tools module to trigger decorator registration
import tools.manage_script
# Get the registered tools from the registry
from registry import get_registered_tools
registered_tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in registered_tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_validate_script_returns_counts(monkeypatch):
tools = setup_tools()
validate_script = tools["validate_script"]
def fake_send(cmd, params):
return {
"success": True,
"data": {
"diagnostics": [
{"severity": "warning"},
{"severity": "error"},
{"severity": "fatal"},
]
},
}
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
resp = validate_script(DummyContext(), uri="unity://path/Assets/Scripts/A.cs")
assert resp == {"success": True, "data": {"warnings": 1, "errors": 2}}

8
Server/tests/pytest.ini Normal file
View File

@ -0,0 +1,8 @@
[pytest]
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
integration: Integration tests that test multiple components together
unit: Unit tests for individual functions or classes

View File

@ -147,7 +147,6 @@ def with_unity_instance(
await result await result
except Exception: except Exception:
pass pass
# Inject kwarg only if function accepts it or downstream ignores extras
kwargs.setdefault(kwarg_name, inst) kwargs.setdefault(kwarg_name, inst)
return await fn(ctx, *args, **kwargs) return await fn(ctx, *args, **kwargs)
else: else:
@ -162,7 +161,6 @@ def with_unity_instance(
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
loop.create_task(result) loop.create_task(result)
except RuntimeError: except RuntimeError:
# No running event loop; skip awaiting to avoid warnings
pass pass
except Exception: except Exception:
pass pass

View File

@ -0,0 +1,61 @@
from typing import Any
from fastmcp import Context
from registry import mcp_for_unity_tool
from unity_instance_middleware import get_unity_instance_middleware
@mcp_for_unity_tool(
description="Return the current FastMCP request context details (client_id, session_id, and meta dump)."
)
def debug_request_context(ctx: Context) -> dict[str, Any]:
# Check request_context properties
rc = getattr(ctx, "request_context", None)
rc_client_id = getattr(rc, "client_id", None)
rc_session_id = getattr(rc, "session_id", None)
meta = getattr(rc, "meta", None)
# Check direct ctx properties (per latest FastMCP docs)
ctx_session_id = getattr(ctx, "session_id", None)
ctx_client_id = getattr(ctx, "client_id", None)
meta_dump = None
if meta is not None:
try:
dump_fn = getattr(meta, "model_dump", None)
if callable(dump_fn):
meta_dump = dump_fn(exclude_none=False)
elif isinstance(meta, dict):
meta_dump = dict(meta)
except Exception as e:
meta_dump = {"_error": str(e)}
# List all ctx attributes for debugging
ctx_attrs = [attr for attr in dir(ctx) if not attr.startswith("_")]
# Get session state info via middleware
middleware = get_unity_instance_middleware()
derived_key = middleware._get_session_key(ctx)
active_instance = middleware.get_active_instance(ctx)
return {
"success": True,
"data": {
"request_context": {
"client_id": rc_client_id,
"session_id": rc_session_id,
"meta": meta_dump,
},
"direct_properties": {
"session_id": ctx_session_id,
"client_id": ctx_client_id,
},
"session_state": {
"derived_key": derived_key,
"active_instance": active_instance,
},
"available_attributes": ctx_attrs,
},
}

View File

@ -66,7 +66,8 @@ def manage_gameobject(
includeNonPublicSerialized: Annotated[bool | str, includeNonPublicSerialized: Annotated[bool | str,
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None, "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
# Get active instance from session-scoped middleware state # Get active instance from session state
# Removed session_state import
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
# Coercers to tolerate stringified booleans and vectors # Coercers to tolerate stringified booleans and vectors

View File

@ -311,7 +311,12 @@ def apply_text_edits(
"options": opts, "options": opts,
} }
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance) resp = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(resp, dict): if isinstance(resp, dict):
data = resp.setdefault("data", {}) data = resp.setdefault("data", {})
data.setdefault("normalizedEdits", normalized_edits) data.setdefault("normalizedEdits", normalized_edits)
@ -395,7 +400,12 @@ def create_script(
contents.encode("utf-8")).decode("utf-8") contents.encode("utf-8")).decode("utf-8")
params["contentsEncoded"] = True params["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance) resp = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@ -411,7 +421,12 @@ def delete_script(
if not directory or directory.split("/")[0].lower() != "assets": if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
params = {"action": "delete", "name": name, "path": directory} params = {"action": "delete", "name": name, "path": directory}
resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance) resp = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@ -437,7 +452,12 @@ def validate_script(
"path": directory, "path": directory,
"level": level, "level": level,
} }
resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance) resp = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(resp, dict) and resp.get("success"): if isinstance(resp, dict) and resp.get("success"):
diags = resp.get("data", {}).get("diagnostics", []) or [] diags = resp.get("data", {}).get("diagnostics", []) or []
warnings = sum(1 for d in diags if str( warnings = sum(1 for d in diags if str(
@ -559,7 +579,12 @@ def get_sha(
try: try:
name, directory = _split_uri(uri) name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory} params = {"action": "get_sha", "name": name, "path": directory}
resp = unity_connection.send_command_with_retry("manage_script", params, instance_id=unity_instance) resp = send_with_unity_instance(
unity_connection.send_command_with_retry,
unity_instance,
"manage_script",
params,
)
if isinstance(resp, dict) and resp.get("success"): if isinstance(resp, dict) and resp.get("success"):
data = resp.get("data", {}) data = resp.get("data", {})
minimal = {"sha256": data.get( minimal = {"sha256": data.get(

View File

@ -56,6 +56,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
] ]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
]
[[package]]
name = "beartype"
version = "0.22.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/09/9003e5662691056e0e8b2e6f57c799e71875fac0be0e785d8cb11557cd2a/beartype-0.22.5.tar.gz", hash = "sha256:516a9096cc77103c96153474fa35c3ebcd9d36bd2ec8d0e3a43307ced0fa6341", size = 1586256, upload-time = "2025-11-01T05:49:20.771Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/f6/073d19f7b571c08327fbba3f8e011578da67ab62a11f98911274ff80653f/beartype-0.22.5-py3-none-any.whl", hash = "sha256:d9743dd7cd6d193696eaa1e025f8a70fb09761c154675679ff236e61952dfba0", size = 1321700, upload-time = "2025-11-01T05:49:18.436Z" },
]
[[package]]
name = "cachetools"
version = "6.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.1.31" version = "2025.1.31"
@ -339,6 +366,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/0e/0a22e076944600aeb06f40b7e03bbd762a42d56d43a2f5f4ab954aed9005/cyclopts-4.0.0-py3-none-any.whl", hash = "sha256:e64801a2c86b681f08323fd50110444ee961236a0bae402a66d2cc3feda33da7", size = 178837, upload-time = "2025-10-20T18:33:00.191Z" }, { url = "https://files.pythonhosted.org/packages/44/0e/0a22e076944600aeb06f40b7e03bbd762a42d56d43a2f5f4ab954aed9005/cyclopts-4.0.0-py3-none-any.whl", hash = "sha256:e64801a2c86b681f08323fd50110444ee961236a0bae402a66d2cc3feda33da7", size = 178837, upload-time = "2025-10-20T18:33:00.191Z" },
] ]
[[package]]
name = "diskcache"
version = "5.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
]
[[package]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.8.0" version = "2.8.0"
@ -393,24 +429,27 @@ wheels = [
[[package]] [[package]]
name = "fastmcp" name = "fastmcp"
version = "2.12.5" version = "2.13.0.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "authlib" }, { name = "authlib" },
{ name = "cyclopts" }, { name = "cyclopts" },
{ name = "exceptiongroup" }, { name = "exceptiongroup" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jsonschema-path" },
{ name = "mcp" }, { name = "mcp" },
{ name = "openapi-core" },
{ name = "openapi-pydantic" }, { name = "openapi-pydantic" },
{ name = "platformdirs" },
{ name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pyperclip" }, { name = "pyperclip" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "rich" }, { name = "rich" },
{ name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/00/a6/e3b46cd3e228635e0064c2648788b6f66a53bf0d0ddbf5fb44cca951f908/fastmcp-2.12.5.tar.gz", hash = "sha256:2dfd02e255705a4afe43d26caddbc864563036e233dbc6870f389ee523b39a6a", size = 7190263, upload-time = "2025-10-17T13:24:58.896Z" } sdist = { url = "https://files.pythonhosted.org/packages/1b/74/584a152bcd174c99ddf3cfdd7e86ec4a6c696fb190a907c2a2ec9056bda2/fastmcp-2.13.0.2.tar.gz", hash = "sha256:d35386561b6f3cde195ba2b5892dc89b8919a721e6b39b98e7a16f9a7c0b8e8b", size = 7762083, upload-time = "2025-10-28T13:56:21.702Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/c1/9fb98c9649e15ea8cc691b4b09558b61dafb3dc0345f7322f8c4a8991ade/fastmcp-2.12.5-py3-none-any.whl", hash = "sha256:b1e542f9b83dbae7cecfdc9c73b062f77074785abda9f2306799116121344133", size = 329099, upload-time = "2025-10-17T13:24:57.518Z" }, { url = "https://files.pythonhosted.org/packages/bd/c6/95eacd687cfab64fec13bfb64e6c6e7da13d01ecd4cb7d7e991858a08119/fastmcp-2.13.0.2-py3-none-any.whl", hash = "sha256:eb381eb073a101aabbc0ac44b05e23fef0cd1619344b7703115c825c8755fa1c", size = 367511, upload-time = "2025-10-28T13:56:18.83Z" },
] ]
[[package]] [[package]]
@ -468,6 +507,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
] ]
[[package]]
name = "importlib-metadata"
version = "8.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@ -478,12 +529,48 @@ wheels = [
] ]
[[package]] [[package]]
name = "isodate" name = "jaraco-classes"
version = "0.7.2" version = "3.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
] ]
[[package]] [[package]]
@ -529,48 +616,21 @@ wheels = [
] ]
[[package]] [[package]]
name = "lazy-object-proxy" name = "keyring"
version = "1.12.0" version = "25.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/2b/d5e8915038acbd6c6a9fcb8aaf923dc184222405d3710285a1fec6e262bc/lazy_object_proxy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61d5e3310a4aa5792c2b599a7a78ccf8687292c8eb09cf187cca8f09cf6a7519", size = 26658, upload-time = "2025-08-22T13:42:23.373Z" }, { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/da/8f/91fc00eeea46ee88b9df67f7c5388e60993341d2a406243d620b2fdfde57/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ca33565f698ac1aece152a10f432415d1a2aa9a42dfe23e5ba2bc255ab91f6", size = 68412, upload-time = "2025-08-22T13:42:24.727Z" },
{ url = "https://files.pythonhosted.org/packages/07/d2/b7189a0e095caedfea4d42e6b6949d2685c354263bdf18e19b21ca9b3cd6/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d01c7819a410f7c255b20799b65d36b414379a30c6f1684c7bd7eb6777338c1b", size = 67559, upload-time = "2025-08-22T13:42:25.875Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b013840cc43971582ff1ceaf784d35d3a579650eb6cc348e5e6ed7e34d28/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:029d2b355076710505c9545aef5ab3f750d89779310e26ddf2b7b23f6ea03cd8", size = 66651, upload-time = "2025-08-22T13:42:27.427Z" },
{ url = "https://files.pythonhosted.org/packages/7e/6f/b7368d301c15612fcc4cd00412b5d6ba55548bde09bdae71930e1a81f2ab/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc6e3614eca88b1c8a625fc0a47d0d745e7c3255b21dac0e30b3037c5e3deeb8", size = 66901, upload-time = "2025-08-22T13:42:28.585Z" },
{ url = "https://files.pythonhosted.org/packages/61/1b/c6b1865445576b2fc5fa0fbcfce1c05fee77d8979fd1aa653dd0f179aefc/lazy_object_proxy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:be5fe974e39ceb0d6c9db0663c0464669cf866b2851c73971409b9566e880eab", size = 26536, upload-time = "2025-08-22T13:42:29.636Z" },
{ url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656, upload-time = "2025-08-22T13:42:30.605Z" },
{ url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832, upload-time = "2025-08-22T13:42:31.675Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148, upload-time = "2025-08-22T13:42:32.876Z" },
{ url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800, upload-time = "2025-08-22T13:42:34.054Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085, upload-time = "2025-08-22T13:42:35.197Z" },
{ url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535, upload-time = "2025-08-22T13:42:36.521Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" },
{ url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" },
{ url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" },
{ url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" },
{ url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" },
{ url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" },
{ url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" },
{ url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" },
{ url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" },
{ url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" },
{ url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" },
{ url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" },
{ url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" },
{ url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" },
{ url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" },
{ url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" },
{ url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" },
{ url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" },
{ url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" },
] ]
[[package]] [[package]]
@ -585,94 +645,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
] ]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "1.16.0" version = "1.20.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@ -681,15 +656,16 @@ dependencies = [
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" }, { name = "sse-starlette" },
{ name = "starlette" }, { name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/3d/a1/b1f328da3b153683d2ec34f849b4b6eac2790fb240e3aef06ff2fab3df9d/mcp-1.16.0.tar.gz", hash = "sha256:39b8ca25460c578ee2cdad33feeea122694cfdf73eef58bee76c42f6ef0589df", size = 472918, upload-time = "2025-10-02T16:58:20.631Z" } sdist = { url = "https://files.pythonhosted.org/packages/f8/22/fae38092e6c2995c03232635028510d77e7decff31b4ae79dfa0ba99c635/mcp-1.20.0.tar.gz", hash = "sha256:9ccc09eaadbfbcbbdab1c9723cfe2e0d1d9e324d7d3ce7e332ef90b09ed35177", size = 451377, upload-time = "2025-10-30T22:14:53.421Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/0e/7cebc88e17daf94ebe28c95633af595ccb2864dc2ee7abd75542d98495cc/mcp-1.16.0-py3-none-any.whl", hash = "sha256:ec917be9a5d31b09ba331e1768aa576e0af45470d657a0319996a20a57d7d633", size = 167266, upload-time = "2025-10-02T16:58:19.039Z" }, { url = "https://files.pythonhosted.org/packages/df/00/76fc92f4892d47fecb37131d0e95ea69259f077d84c68f6793a0d96cfe80/mcp-1.20.0-py3-none-any.whl", hash = "sha256:d0dc06f93653f7432ff89f694721c87f79876b6f93741bf628ad1e48f7ac5e5d", size = 173136, upload-time = "2025-10-30T22:14:51.078Z" },
] ]
[[package]] [[package]]
@ -712,7 +688,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastmcp", specifier = ">=2.12.5" }, { name = "fastmcp", specifier = ">=2.13.0" },
{ name = "httpx", specifier = ">=0.27.2" }, { name = "httpx", specifier = ">=0.27.2" },
{ name = "mcp", specifier = ">=1.16.0" }, { name = "mcp", specifier = ">=1.16.0" },
{ name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic", specifier = ">=2.12.0" },
@ -740,26 +716,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
] ]
[[package]]
name = "openapi-core"
version = "0.19.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "isodate" },
{ name = "jsonschema" },
{ name = "jsonschema-path" },
{ name = "more-itertools" },
{ name = "openapi-schema-validator" },
{ name = "openapi-spec-validator" },
{ name = "parse" },
{ name = "typing-extensions" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/35/1acaa5f2fcc6e54eded34a2ec74b479439c4e469fc4e8d0e803fda0234db/openapi_core-0.19.5.tar.gz", hash = "sha256:421e753da56c391704454e66afe4803a290108590ac8fa6f4a4487f4ec11f2d3", size = 103264, upload-time = "2025-03-20T20:17:28.193Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/6f/83ead0e2e30a90445ee4fc0135f43741aebc30cca5b43f20968b603e30b6/openapi_core-0.19.5-py3-none-any.whl", hash = "sha256:ef7210e83a59394f46ce282639d8d26ad6fc8094aa904c9c16eb1bac8908911f", size = 106595, upload-time = "2025-03-20T20:17:26.77Z" },
]
[[package]] [[package]]
name = "openapi-pydantic" name = "openapi-pydantic"
version = "0.5.1" version = "0.5.1"
@ -772,35 +728,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
] ]
[[package]]
name = "openapi-schema-validator"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonschema" },
{ name = "jsonschema-specifications" },
{ name = "rfc3339-validator" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" },
]
[[package]]
name = "openapi-spec-validator"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonschema" },
{ name = "jsonschema-path" },
{ name = "lazy-object-proxy" },
{ name = "openapi-schema-validator" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" version = "25.0"
@ -810,15 +737,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
] ]
[[package]]
name = "parse"
version = "1.20.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" },
]
[[package]] [[package]]
name = "pathable" name = "pathable"
version = "0.4.4" version = "0.4.4"
@ -828,6 +746,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" },
] ]
[[package]]
name = "pathvalidate"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -837,6 +773,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "py-key-value-aio"
version = "0.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "py-key-value-shared" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/35/65310a4818acec0f87a46e5565e341c5a96fc062a9a03495ad28828ff4d7/py_key_value_aio-0.2.8.tar.gz", hash = "sha256:c0cfbb0bd4e962a3fa1a9fa6db9ba9df812899bd9312fa6368aaea7b26008b36", size = 32853, upload-time = "2025-10-24T13:31:04.688Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/5a/e56747d87a97ad2aff0f3700d77f186f0704c90c2da03bfed9e113dae284/py_key_value_aio-0.2.8-py3-none-any.whl", hash = "sha256:561565547ce8162128fd2bd0b9d70ce04a5f4586da8500cce79a54dfac78c46a", size = 69200, upload-time = "2025-10-24T13:31:03.81Z" },
]
[package.optional-dependencies]
disk = [
{ name = "diskcache" },
{ name = "pathvalidate" },
]
keyring = [
{ name = "keyring" },
]
memory = [
{ name = "cachetools" },
]
[[package]]
name = "py-key-value-shared"
version = "0.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/79/05a1f9280cfa0709479319cbfd2b1c5beb23d5034624f548c83fb65b0b61/py_key_value_shared-0.2.8.tar.gz", hash = "sha256:703b4d3c61af124f0d528ba85995c3c8d78f8bd3d2b217377bd3278598070cc1", size = 8216, upload-time = "2025-10-24T13:31:03.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7a/1726ceaa3343874f322dd83c9ec376ad81f533df8422b8b1e1233a59f8ce/py_key_value_shared-0.2.8-py3-none-any.whl", hash = "sha256:aff1bbfd46d065b2d67897d298642e80e5349eae588c6d11b48452b46b8d46ba", size = 14586, upload-time = "2025-10-24T13:31:02.838Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.23" version = "2.23"
@ -998,6 +972,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "pyperclip" name = "pyperclip"
version = "1.11.0" version = "1.11.0"
@ -1079,6 +1067,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
] ]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@ -1172,18 +1169,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
] ]
[[package]]
name = "rfc3339-validator"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "13.9.4" version = "13.9.4"
@ -1347,12 +1332,16 @@ wheels = [
] ]
[[package]] [[package]]
name = "six" name = "secretstorage"
version = "1.17.0" version = "3.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" },
] ]
[[package]] [[package]]
@ -1483,13 +1472,69 @@ wheels = [
] ]
[[package]] [[package]]
name = "werkzeug" name = "websockets"
version = "3.1.1" version = "15.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/af/d4502dc713b4ccea7175d764718d5183caf8d0867a4f0190d5d4a45cea49/werkzeug-3.1.1.tar.gz", hash = "sha256:8cd39dfbdfc1e051965f156163e2974e52c210f130810e9ad36858f0fd3edad4", size = 806453, upload-time = "2024-11-01T16:40:45.462Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" }, { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
] ]