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
81 changes: 81 additions & 0 deletions src/zeropath_mcp_server/jsonschema_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import re
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
from uuid import UUID

JsonObject = dict[str, Any]

Expand Down Expand Up @@ -81,6 +83,44 @@ def _type_matches(value: Any, typ: str) -> bool:
raise UnsupportedSchemaError(f"Unsupported JSON Schema type {typ!r}")


def _non_negative_integer_keyword(schema: JsonObject, key: str) -> int | None:
value = schema.get(key)
if value is None:
return None
if not _is_integer(value) or value < 0:
raise UnsupportedSchemaError(f"{key} must be a non-negative integer")
return value


def _number_keyword(schema: JsonObject, key: str) -> int | float | None:
value = schema.get(key)
if value is None:
return None
if not _is_number(value):
raise UnsupportedSchemaError(f"{key} must be a number")
return value


def _matches_format(value: str, fmt: str) -> bool:
if fmt == "uuid":
try:
UUID(value)
except ValueError:
return False
return True

if fmt == "uri":
parsed = urlparse(value)
return bool(parsed.scheme)

if fmt == "email":
return bool(re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value))

# JSON Schema treats unknown formats as annotations. Do not fail the whole
# client-side validator when the manifest adds a new format we don't know.
return True


def validate(
instance: Any,
schema: JsonObject,
Expand Down Expand Up @@ -181,6 +221,47 @@ def _validate(
issues.append(ValidationIssue(path, f"Expected type {allowed_types!r}"))
return

if isinstance(instance, str):
min_length = _non_negative_integer_keyword(schema, "minLength")
max_length = _non_negative_integer_keyword(schema, "maxLength")
if min_length is not None and len(instance) < min_length:
issues.append(ValidationIssue(path, f"Expected string length at least {min_length}"))
if max_length is not None and len(instance) > max_length:
issues.append(ValidationIssue(path, f"Expected string length at most {max_length}"))

pattern = schema.get("pattern")
if pattern is not None:
if not isinstance(pattern, str):
raise UnsupportedSchemaError("pattern must be a string")
try:
pattern_matches = re.search(pattern, instance) is not None
except re.error as exc:
raise UnsupportedSchemaError(f"Invalid regex pattern {pattern!r}: {exc}") from exc
if not pattern_matches:
issues.append(ValidationIssue(path, "String does not match pattern"))

fmt = schema.get("format")
if fmt is not None:
if not isinstance(fmt, str):
raise UnsupportedSchemaError("format must be a string")
if not _matches_format(instance, fmt):
issues.append(ValidationIssue(path, f"String does not match format {fmt!r}"))

if _is_number(instance):
minimum = _number_keyword(schema, "minimum")
maximum = _number_keyword(schema, "maximum")
exclusive_minimum = _number_keyword(schema, "exclusiveMinimum")
exclusive_maximum = _number_keyword(schema, "exclusiveMaximum")

if minimum is not None and instance < minimum:
issues.append(ValidationIssue(path, f"Expected value at least {minimum}"))
if maximum is not None and instance > maximum:
issues.append(ValidationIssue(path, f"Expected value at most {maximum}"))
if exclusive_minimum is not None and instance <= exclusive_minimum:
issues.append(ValidationIssue(path, f"Expected value greater than {exclusive_minimum}"))
if exclusive_maximum is not None and instance >= exclusive_maximum:
issues.append(ValidationIssue(path, f"Expected value less than {exclusive_maximum}"))

# Type-specific validation
if isinstance(instance, dict):
required = schema.get("required", [])
Expand Down
76 changes: 76 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import pytest
import zeropath_mcp_server.trpc_client as trpc_client
from zeropath_mcp_server import server
from zeropath_mcp_server.jsonschema_validation import UnsupportedSchemaError
from zeropath_mcp_server.jsonschema_validation import validate as validate_jsonschema

SAMPLE_MANIFEST_V2 = {
Expand Down Expand Up @@ -121,6 +122,81 @@ def test_ref_resolution_uses_root_schema_when_provided(self):
issues = validate_jsonschema({"organizationId": "org_test"}, schema, root_schema=REF_ROOT_SCHEMA)
assert any(i.path == "page" and "Missing required" in i.message for i in issues)

def test_string_length_constraints(self):
schema = {
"type": "object",
"properties": {
"repositoryId": {"type": "string", "minLength": 8, "maxLength": 12},
},
}

too_short = validate_jsonschema({"repositoryId": "repo"}, schema)
too_long = validate_jsonschema({"repositoryId": "repo_1234567890"}, schema)
valid = validate_jsonschema({"repositoryId": "repo_12345"}, schema)

assert any(i.path == "repositoryId" and "at least 8" in i.message for i in too_short)
assert any(i.path == "repositoryId" and "at most 12" in i.message for i in too_long)
assert valid == []

def test_numeric_range_constraints(self):
schema = {
"type": "object",
"properties": {
"page": {"type": "integer", "minimum": 1},
"pageSize": {"type": "integer", "minimum": 1, "maximum": 100},
"score": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1},
},
}

issues = validate_jsonschema({"page": 0, "pageSize": 101, "score": 1}, schema)

assert any(i.path == "page" and "at least 1" in i.message for i in issues)
assert any(i.path == "pageSize" and "at most 100" in i.message for i in issues)
assert any(i.path == "score" and "less than 1" in i.message for i in issues)

def test_common_format_constraints(self):
schema = {
"type": "object",
"properties": {
"repositoryId": {"type": "string", "format": "uuid"},
"docsUrl": {"type": "string", "format": "uri"},
"assignee": {"type": "string", "format": "email"},
},
}

invalid = validate_jsonschema(
{
"repositoryId": "not-a-uuid",
"docsUrl": "not a uri",
"assignee": "bad-email",
},
schema,
)
valid = validate_jsonschema(
{
"repositoryId": "00000000-0000-0000-0000-000000000000",
"docsUrl": "https://zeropath.com/docs",
"assignee": "security@example.com",
},
schema,
)

assert any(i.path == "repositoryId" and "uuid" in i.message for i in invalid)
assert any(i.path == "docsUrl" and "uri" in i.message for i in invalid)
assert any(i.path == "assignee" and "email" in i.message for i in invalid)
assert valid == []

def test_pattern_constraint(self):
schema = {"type": "string", "pattern": "^repo_[a-z0-9]+$"}

issues = validate_jsonschema("repository_123", schema)

assert any("pattern" in i.message for i in issues)

def test_invalid_length_keyword_raises_unsupported(self):
with pytest.raises(UnsupportedSchemaError, match="minLength"):
validate_jsonschema("repo_123", {"type": "string", "minLength": -1})


class TestApplyOrgId:
def test_inject_if_missing_adds_org_id(self):
Expand Down