Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/zeropath_mcp_server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
python -m zeropath_mcp_server
zeropath-mcp-server
"""

import asyncio

from mcp.server.stdio import stdio_server
Expand Down
9 changes: 5 additions & 4 deletions src/zeropath_mcp_server/jsonschema_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 9 additions & 20 deletions src/zeropath_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})"
Expand Down Expand Up @@ -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 {})
Expand Down
27 changes: 10 additions & 17 deletions src/zeropath_mcp_server/trpc_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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("/"),
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -83,6 +81,7 @@
}
}


class TestBuildTools:
"""Test _build_tools with v2 manifest format."""

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand All @@ -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]
Expand All @@ -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]
Expand Down