diff --git a/src/zeropath_mcp_server/jsonschema_validation.py b/src/zeropath_mcp_server/jsonschema_validation.py index 53ca395..765e19e 100644 --- a/src/zeropath_mcp_server/jsonschema_validation.py +++ b/src/zeropath_mcp_server/jsonschema_validation.py @@ -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] @@ -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, @@ -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", []) diff --git a/tests/test_tools.py b/tests/test_tools.py index 233f390..3ba03a6 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -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 = { @@ -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):