Skip to content

Commit 4fc4c71

Browse files
fix(prompting): avoid rejecting prefilled payloads
- Skip final jsonschema validation for prompt_for_schema (prompt schema is lossy) - Add schema_validation helper and regression coverage - Bump Typer minimum version Signed-off-by: Matthew Grigsby <38010437+MatthewGrigsby@users.noreply.github.com>
1 parent 603b51e commit 4fc4c71

12 files changed

Lines changed: 3068 additions & 2069 deletions

cforge/common/prompting.py

Lines changed: 212 additions & 43 deletions
Large diffs are not rendered by default.

cforge/common/schema_validation.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# -*- coding: utf-8 -*-
2+
"""Copyright 2025
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
JSON Schema validation helpers.
6+
7+
This module centralizes jsonschema-based validation so callers can validate
8+
full payloads or individual field values against sub-schemas while preserving
9+
root-schema `$ref` resolution.
10+
"""
11+
12+
# Standard
13+
from typing import Any, Dict, Optional, Tuple
14+
15+
# Third-Party
16+
from jsonschema import SchemaError
17+
from jsonschema.exceptions import ValidationError
18+
from jsonschema.validators import validator_for
19+
20+
21+
def _error_sort_key(error: ValidationError) -> Tuple[Tuple[str, ...], Tuple[str, ...]]:
22+
"""Sort errors deterministically by instance path then schema path."""
23+
return tuple(str(part) for part in error.path), tuple(str(part) for part in error.schema_path)
24+
25+
26+
def _format_error_path(error: ValidationError) -> str:
27+
"""Format an instance path in a compact jsonpath-like style."""
28+
if not error.path:
29+
return "$"
30+
31+
segments = ["$"]
32+
for part in error.path:
33+
if isinstance(part, int):
34+
segments.append(f"[{part}]")
35+
else:
36+
segments.append(f".{part}")
37+
return "".join(segments)
38+
39+
40+
def _format_validation_error(error: ValidationError) -> str:
41+
"""Format a validation error for user-facing CLI messages."""
42+
location = _format_error_path(error)
43+
if location == "$":
44+
return error.message
45+
return f"{location}: {error.message}"
46+
47+
48+
def _first_validation_error_message(errors: list[ValidationError]) -> Optional[str]:
49+
"""Return a formatted first error message when validation fails."""
50+
if not errors:
51+
return None
52+
return _format_validation_error(errors[0])
53+
54+
55+
def _build_root_validator(schema: Dict[str, Any]) -> Any:
56+
"""Build a jsonschema validator from a root schema."""
57+
validator_cls = validator_for(schema)
58+
validator_cls.check_schema(schema)
59+
return validator_cls(schema)
60+
61+
62+
def validate_instance(schema: Dict[str, Any], instance: Any) -> Optional[str]:
63+
"""Validate an instance against a full schema.
64+
65+
Returns:
66+
A user-facing error message when invalid, otherwise ``None``.
67+
"""
68+
if not isinstance(schema, dict):
69+
return "Input schema must be a JSON object"
70+
71+
try:
72+
validator = _build_root_validator(schema)
73+
errors = sorted(validator.iter_errors(instance), key=_error_sort_key)
74+
return _first_validation_error_message(errors)
75+
except SchemaError as exc:
76+
return f"Invalid JSON Schema: {exc}"
77+
except Exception as exc: # pragma: no cover - defensive fallback for validator internals
78+
return f"Schema validation failed: {exc}"
79+
80+
81+
def validate_instance_against_subschema(root_schema: Dict[str, Any], subschema: Dict[str, Any], instance: Any) -> Optional[str]:
82+
"""Validate an instance against a subschema with root `$ref` context.
83+
84+
Returns:
85+
A user-facing error message when invalid, otherwise ``None``.
86+
"""
87+
if not isinstance(root_schema, dict):
88+
return "Input schema must be a JSON object"
89+
if not isinstance(subschema, dict):
90+
return "Input schema must be a JSON object"
91+
92+
try:
93+
root_validator = _build_root_validator(root_schema)
94+
root_validator.__class__.check_schema(subschema)
95+
subschema_validator = root_validator.evolve(schema=subschema)
96+
errors = sorted(subschema_validator.iter_errors(instance), key=_error_sort_key)
97+
return _first_validation_error_message(errors)
98+
except SchemaError as exc:
99+
return f"Invalid JSON Schema: {exc}"
100+
except Exception as exc: # pragma: no cover - defensive fallback for validator internals
101+
return f"Schema validation failed: {exc}"

cforge/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from mcpgateway.config import Settings
2222
from mcpgateway.config import get_settings as cf_get_settings
2323

24-
2524
HOME_DIR_NAME = ".contextforge"
2625
DEFAULT_HOME = Path.home() / HOME_DIR_NAME
2726

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ maintainers = [
4747
# ----------------------------------------------------------------
4848
dependencies = [
4949
"rich>=13.9.4",
50-
"typer>=0.20.0",
50+
"typer>=0.23.1",
5151
"mcp-contextforge-gateway==1.0.0b2",
5252
"cryptography>=44.0.0",
53+
"jsonschema>=4.23.0",
5354
]
5455

5556
# ----------------------------------------------------------------

tests/common/test_console.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# -*- coding: utf-8 -*-
2+
"""Tests for cforge.common.console."""
3+
4+
# First-Party
5+
from cforge.common.console import get_app, get_console
6+
7+
8+
class TestSingletons:
9+
"""Tests for singleton getter functions."""
10+
11+
def test_get_console_returns_console(self) -> None:
12+
"""Test that get_console returns a Console instance."""
13+
console = get_console()
14+
assert console is not None
15+
# Should return same instance
16+
assert get_console() is console
17+
18+
def test_get_app_returns_typer_app(self) -> None:
19+
"""Test that get_app returns a Typer instance."""
20+
app = get_app()
21+
assert app is not None
22+
# Should return same instance
23+
assert get_app() is app

tests/common/test_errors.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# -*- coding: utf-8 -*-
2+
"""Tests for cforge.common.errors."""
3+
4+
# First-Party
5+
from cforge.common.errors import AuthenticationError, CLIError
6+
7+
8+
class TestErrors:
9+
"""Tests for custom error classes."""
10+
11+
def test_cli_error(self) -> None:
12+
"""Test CLIError exception."""
13+
error = CLIError("Test error")
14+
assert str(error) == "Test error"
15+
16+
def test_authentication_error(self) -> None:
17+
"""Test AuthenticationError exception."""
18+
error = AuthenticationError("Auth failed")
19+
assert str(error) == "Auth failed"
20+
assert isinstance(error, CLIError)

0 commit comments

Comments
 (0)