diff --git a/src/zeropath_mcp_server/__main__.py b/src/zeropath_mcp_server/__main__.py index 950e2cb..d75b408 100644 --- a/src/zeropath_mcp_server/__main__.py +++ b/src/zeropath_mcp_server/__main__.py @@ -5,6 +5,7 @@ python -m zeropath_mcp_server zeropath-mcp-server """ + import asyncio from mcp.server.stdio import stdio_server diff --git a/src/zeropath_mcp_server/jsonschema_validation.py b/src/zeropath_mcp_server/jsonschema_validation.py index a12b718..53ca395 100644 --- a/src/zeropath_mcp_server/jsonschema_validation.py +++ b/src/zeropath_mcp_server/jsonschema_validation.py @@ -10,11 +10,10 @@ from __future__ import annotations -from dataclasses import dataclass import re +from dataclasses import dataclass from typing import Any - JsonObject = dict[str, Any] @@ -61,7 +60,7 @@ def _is_integer(value: Any) -> bool: def _is_number(value: Any) -> bool: - return (isinstance(value, (int, float)) and not isinstance(value, bool)) + return isinstance(value, (int, float)) and not isinstance(value, bool) def _type_matches(value: Any, typ: str) -> bool: @@ -203,7 +202,9 @@ def _validate( for key, value in instance.items(): sub_path = f"{path}/{key}" if path else key if isinstance(properties, dict) and key in properties: - _validate(value, properties[key], path=sub_path, issues=issues, root_schema=root_schema, ref_stack=ref_stack) + _validate( + value, properties[key], path=sub_path, issues=issues, root_schema=root_schema, ref_stack=ref_stack + ) continue if additional is False: diff --git a/src/zeropath_mcp_server/server.py b/src/zeropath_mcp_server/server.py index 4217a23..329c06b 100644 --- a/src/zeropath_mcp_server/server.py +++ b/src/zeropath_mcp_server/server.py @@ -20,8 +20,9 @@ import mcp.types as types from mcp.server.lowlevel import Server +from .jsonschema_validation import UnsupportedSchemaError +from .jsonschema_validation import validate as validate_jsonschema from .trpc_client import TrpcClient, load_config -from .jsonschema_validation import UnsupportedSchemaError, validate as validate_jsonschema JsonObject = dict[str, Any] @@ -70,8 +71,7 @@ def _apply_org_id(arguments: JsonObject, behavior: str, *, organization_id: str arguments["organizationId"] = organization_id elif behavior == "required": return ( - "organizationId is required for this operation. " - "Pass organizationId explicitly or set ZEROPATH_ORG_ID." + "organizationId is required for this operation. Pass organizationId explicitly or set ZEROPATH_ORG_ID." ) return None @@ -116,26 +116,19 @@ def _build_tools(manifest: JsonObject) -> tuple[list[types.Tool], dict[str, Json org_id_behavior = entry.get("orgIdBehavior", "none") if not isinstance(org_id_behavior, str) or org_id_behavior not in _ALLOWED_ORG_ID_BEHAVIORS: raise RuntimeError( - f"Invalid MCP manifest: tools[{idx}].orgIdBehavior must be one of " - f"{sorted(_ALLOWED_ORG_ID_BEHAVIORS)}" + f"Invalid MCP manifest: tools[{idx}].orgIdBehavior must be one of {sorted(_ALLOWED_ORG_ID_BEHAVIORS)}" ) http_method = entry.get("httpMethod") if not isinstance(http_method, str) or not http_method.strip(): - raise RuntimeError( - f"Invalid MCP manifest: tools[{idx}].httpMethod must be a non-empty string" - ) + raise RuntimeError(f"Invalid MCP manifest: tools[{idx}].httpMethod must be a non-empty string") http_method = http_method.upper() if http_method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}: - raise RuntimeError( - f"Invalid MCP manifest: tools[{idx}].httpMethod must be a valid HTTP method" - ) + raise RuntimeError(f"Invalid MCP manifest: tools[{idx}].httpMethod must be a valid HTTP method") http_path = entry.get("httpPath") if not isinstance(http_path, str) or not http_path.strip(): - raise RuntimeError( - f"Invalid MCP manifest: tools[{idx}].httpPath must be a non-empty string" - ) + raise RuntimeError(f"Invalid MCP manifest: tools[{idx}].httpPath must be a non-empty string") if not http_path.startswith("/api/v2/"): raise RuntimeError( f"Invalid MCP manifest: tools[{idx}].httpPath must start with '/api/v2/' (got {http_path!r})" @@ -181,13 +174,9 @@ async def list_tools() -> list[types.Tool]: return tools @server.call_tool() - async def call_tool( - name: str, arguments: dict[str, Any] | None - ) -> list[types.TextContent]: + async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: if name not in metadata: - raise RuntimeError( - json.dumps({"error": {"code": "NOT_FOUND", "message": f"Unknown tool: {name}"}}) - ) + raise RuntimeError(json.dumps({"error": {"code": "NOT_FOUND", "message": f"Unknown tool: {name}"}})) meta = metadata[name] args = dict(arguments or {}) diff --git a/src/zeropath_mcp_server/trpc_client.py b/src/zeropath_mcp_server/trpc_client.py index ed42db1..da0821f 100644 --- a/src/zeropath_mcp_server/trpc_client.py +++ b/src/zeropath_mcp_server/trpc_client.py @@ -4,11 +4,14 @@ Despite the module name (kept for import compatibility), this client calls the stable `/api/v2/` REST surface. """ + from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Mapping import os +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + import requests JsonObject = dict[str, Any] @@ -41,9 +44,7 @@ def load_config() -> ZeropathConfig: ] if missing: - raise EnvironmentError( - "Missing required environment variables: " + ", ".join(missing) - ) + raise OSError("Missing required environment variables: " + ", ".join(missing)) return ZeropathConfig( base_url=base_url.rstrip("/"), @@ -133,9 +134,7 @@ def call( # REST handlers return errors as {"error": "message"} with non-200 status if response.status_code >= 400: error_message = ( - response_json.get("error", "Unknown error") - if isinstance(response_json, dict) - else str(response_json) + response_json.get("error", "Unknown error") if isinstance(response_json, dict) else str(response_json) ) return make_error( "API_ERROR", @@ -152,21 +151,15 @@ def fetch_manifest(self) -> JsonObject: try: response = requests.get(url, timeout=DEFAULT_TIMEOUT_SECONDS) except requests.RequestException as exc: - raise RuntimeError( - f"Failed to fetch MCP manifest from {url}: {exc}" - ) from exc + raise RuntimeError(f"Failed to fetch MCP manifest from {url}: {exc}") from exc if response.status_code != 200: - raise RuntimeError( - f"MCP manifest returned HTTP {response.status_code}: {response.text[:200]}" - ) + raise RuntimeError(f"MCP manifest returned HTTP {response.status_code}: {response.text[:200]}") try: data = response.json() except ValueError as exc: - raise RuntimeError( - f"MCP manifest returned non-JSON response: {response.text[:200]}" - ) from exc + raise RuntimeError(f"MCP manifest returned non-JSON response: {response.text[:200]}") from exc if not isinstance(data, dict) or data.get("version") != 2: raise RuntimeError( diff --git a/tests/test_tools.py b/tests/test_tools.py index 1909ef7..114d288 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -15,12 +15,10 @@ import json import pytest - import zeropath_mcp_server.trpc_client as trpc_client from zeropath_mcp_server import server from zeropath_mcp_server.jsonschema_validation import validate as validate_jsonschema - SAMPLE_MANIFEST_V2 = { "version": 2, "generatedAt": "2026-01-01T00:00:00.000Z", @@ -83,6 +81,7 @@ } } + class TestBuildTools: """Test _build_tools with v2 manifest format.""" @@ -229,6 +228,7 @@ def fake_request(method, url, headers=None, json=None, timeout=None): assert "error" in result assert result["error"]["code"] == "API_ERROR" + class TestFetchManifest: def test_successful_fetch_v2(self, monkeypatch): def fake_get(url, timeout=None): @@ -278,6 +278,7 @@ class TestCallTool: @pytest.fixture def mock_server_v2(self, monkeypatch): """Create a server with a mocked v2 manifest fetch.""" + def fake_get(url, timeout=None): return DummyResponse(SAMPLE_MANIFEST_V2) @@ -291,6 +292,7 @@ def fake_get(url, timeout=None): def test_unknown_tool_returns_error(self, mock_server_v2): import asyncio + import mcp.types as types handler = mock_server_v2.request_handlers[types.CallToolRequest] @@ -307,6 +309,7 @@ def test_unknown_tool_returns_error(self, mock_server_v2): def test_schema_validation_failure_returns_bad_request(self, mock_server_v2): import asyncio + import mcp.types as types handler = mock_server_v2.request_handlers[types.CallToolRequest]