Skip to content

Commit f5d866f

Browse files
authored
Merge pull request #28 from microsoft/constraint/tool-schema-flatness
Add Tool Schema Flatness (TSF) constraint
2 parents 57e8f4d + ea302e2 commit f5d866f

6 files changed

Lines changed: 588 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"mcp>=1.10.1",
99
"openai>=1.93.3",
1010
"tiktoken>=0.11.0",
11+
"jsonschema>=4.0.0",
1112
]
1213

1314
[dependency-groups]
@@ -16,6 +17,7 @@ dev = [
1617
"pre-commit>=4.3.0",
1718
"pyright>=1.1.403",
1819
"poethepoet>=0.37.0",
20+
"pytest>=8.4.2",
1921
]
2022

2123
[project.scripts]

src/mcp_interviewer/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def cli():
7474
parser.add_argument(
7575
"--constraints",
7676
nargs="+",
77-
help="Specify which constraint violations to check (all enabled by default). Can use full names (e.g., openai-tool-count, openai-name-length) or shorthand codes (e.g., OTC, ONL, ONP, OTL, OA)",
77+
help="Specify which constraint violations to check (all enabled by default). Can use full names (e.g., openai-tool-count, openai-name-length, tool-schema-flatness) or shorthand codes (e.g., OTC, ONL, ONP, OTL, TSF, OA)",
7878
)
7979
parser.add_argument(
8080
"--test",

src/mcp_interviewer/constraints/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
OpenAIToolNamePatternConstraint,
1414
OpenAIToolResultTokenLengthConstraint,
1515
)
16+
from .tool_schema_flatness import ToolInputSchemaFlatnessConstraint
1617

1718

1819
class AllConstraints(CompositeConstraint):
@@ -26,6 +27,7 @@ def __init__(self):
2627
"""Initialize with all available constraint sets."""
2728
super().__init__(
2829
OpenAIConstraints(),
30+
ToolInputSchemaFlatnessConstraint(),
2931
)
3032

3133

@@ -35,6 +37,7 @@ def __init__(self):
3537
OpenAIToolNameLengthConstraint,
3638
OpenAIToolNamePatternConstraint,
3739
OpenAIToolResultTokenLengthConstraint,
40+
ToolInputSchemaFlatnessConstraint,
3841
]
3942

4043
# Create mappings for names and codes
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from collections.abc import Generator
2+
from typing import Any
3+
4+
from jsonschema import RefResolver
5+
from mcp import Tool
6+
7+
from mcp_interviewer.constraints.base import ConstraintViolation, Severity
8+
9+
from .base import ToolConstraint
10+
11+
12+
class ToolInputSchemaFlatnessConstraint(ToolConstraint):
13+
"""Validates that tool input schemas are flat (no nested objects or arrays).
14+
15+
Nested structures in tool schemas can make them difficult to understand and use.
16+
This constraint ensures that the inputSchema doesn't contain:
17+
- Nested "properties" fields (objects within objects)
18+
- Nested arrays (arrays of arrays)
19+
20+
A flat schema has all parameters at the top level. Arrays of primitives and
21+
unions (oneOf/anyOf/allOf) are allowed.
22+
"""
23+
24+
@classmethod
25+
def cli_name(cls) -> str:
26+
"""Return the CLI-friendly name for this constraint."""
27+
return "tool-schema-flatness"
28+
29+
@classmethod
30+
def cli_code(cls) -> str:
31+
"""Return the shorthand code for this constraint."""
32+
return "TSF"
33+
34+
def test_tool(self, tool: Tool) -> Generator[ConstraintViolation, None, None]:
35+
"""Test if the tool's inputSchema has nested properties fields.
36+
37+
Args:
38+
tool: The tool to validate
39+
40+
Yields:
41+
ConstraintViolation: Warning if inputSchema contains nested "properties" fields
42+
"""
43+
# Create a resolver for handling $ref references
44+
resolver = RefResolver.from_schema(tool.inputSchema)
45+
46+
def has_nested_structure(
47+
obj: Any,
48+
resolver: RefResolver,
49+
depth: int = 0,
50+
inside_array: bool = False,
51+
visited: set[str] | None = None,
52+
) -> bool:
53+
"""Check if an object contains nested "properties" fields or nested arrays.
54+
55+
Args:
56+
obj: The object to check
57+
resolver: JSON Schema reference resolver
58+
depth: Current depth (0 = top level properties)
59+
inside_array: Whether we're currently inside an array's items
60+
visited: Set of visited $ref URLs to prevent infinite loops
61+
62+
Returns:
63+
True if nested structures found, False otherwise
64+
"""
65+
if not isinstance(obj, dict):
66+
return False
67+
68+
if visited is None:
69+
visited = set()
70+
71+
# If we're already inside a property definition and we find another "properties" field
72+
if depth > 0 and "properties" in obj:
73+
return True
74+
75+
# If we're inside an array and we find another array type
76+
if inside_array and obj.get("type") == "array":
77+
return True
78+
79+
# Check $ref
80+
if "$ref" in obj:
81+
ref_url = obj["$ref"]
82+
# Prevent infinite loops from circular references
83+
if ref_url in visited:
84+
return False
85+
visited.add(ref_url)
86+
87+
try:
88+
_, resolved = resolver.resolve(ref_url)
89+
if isinstance(resolved, dict) and has_nested_structure(
90+
resolved, resolver, depth, inside_array, visited
91+
):
92+
return True
93+
except Exception:
94+
# If resolution fails, skip this ref
95+
pass
96+
97+
# Recursively check all values in the current object
98+
for key, value in obj.items():
99+
if key == "properties" and depth == 0:
100+
# This is the top-level properties, check its children at depth 1
101+
if isinstance(value, dict):
102+
for prop_value in value.values():
103+
if has_nested_structure(
104+
prop_value, resolver, depth + 1, inside_array, visited
105+
):
106+
return True
107+
elif key == "items":
108+
# Check array items - set inside_array=True
109+
if isinstance(value, dict):
110+
if has_nested_structure(value, resolver, depth, True, visited):
111+
return True
112+
elif isinstance(value, dict):
113+
# Check nested structures (like oneOf, anyOf, allOf, etc.)
114+
if has_nested_structure(
115+
value, resolver, depth, inside_array, visited
116+
):
117+
return True
118+
elif isinstance(value, list):
119+
# Check each item in arrays (like oneOf, anyOf, allOf)
120+
for item in value:
121+
if isinstance(item, dict) and has_nested_structure(
122+
item, resolver, depth, inside_array, visited
123+
):
124+
return True
125+
126+
return False
127+
128+
if has_nested_structure(tool.inputSchema, resolver):
129+
yield ConstraintViolation(
130+
self,
131+
f"Tool '{tool.name}': inputSchema contains nested structures (nested objects or arrays). Tool parameters should be flat.",
132+
severity=Severity.WARNING,
133+
)

0 commit comments

Comments
 (0)