Skip to content

Commit ff9718d

Browse files
authored
feat(mcp): #30 add every_tool and does_not_accept for catalog-wide tool assertions (#31)
* feat(mcp): #30 add every_tool for catalog-wide tool assertions * feat(mcp): #30 add does_not_accept and harden every_tool against non-dict items
1 parent 92c22dd commit ff9718d

3 files changed

Lines changed: 82 additions & 0 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,14 @@ AssertableMCP(payload).lists_tools()\
492492
))
493493
```
494494

495+
Catalog-wide invariants — apply the same assertion to every tool without enumerating names. Useful when a server rewrites its tool schema per caller (auth scopes, feature flags):
496+
497+
```python
498+
AssertableMCP(payload).lists_tools().every_tool(
499+
lambda t: t.does_not_accept(["internal_user_id"])
500+
)
501+
```
502+
495503
#### Building requests with `MessageBuilder`
496504

497505
`MessageBuilder` constructs MCP JSON-RPC messages with native MCP vocabulary on top of any transport-level `RequestBuilder`. Inject `HttpxRequestBuilder` for FastAPI/Starlette/FastMCP testing, or `DjangoRequestBuilder` for Django-hosted MCP servers — the output of `.build()` matches whichever you inject.

src/pyssertive/protocols/mcp/tools.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ def accepts_optional(self, params: list[str]) -> Self:
4141
raise AssertionError(f"Tool '{self._name}' has no input properties {missing!r}; properties={properties!r}")
4242
return self
4343

44+
def does_not_accept(self, params: list[str]) -> Self:
45+
properties = list((self._definition.get("inputSchema") or {}).get("properties") or {})
46+
present = [p for p in params if p in properties]
47+
if present:
48+
raise AssertionError(
49+
f"Tool '{self._name}' should not expose properties {present!r}; properties={properties!r}"
50+
)
51+
return self
52+
4453
def has_output_schema(self) -> Self:
4554
if not self._definition.get("outputSchema"):
4655
raise AssertionError(f"Tool '{self._name}' has no outputSchema")
@@ -79,6 +88,12 @@ def does_not_contain_tool(self, name: str) -> Self:
7988
raise AssertionError(f"Tool '{name}' should not be in tools list, but it was found")
8089
return self
8190

91+
def every_tool(self, callback: Callable[[AssertableToolDef], Any]) -> Self:
92+
for tool in self._tools:
93+
if isinstance(tool, dict):
94+
callback(AssertableToolDef(tool))
95+
return self
96+
8297
def has_more_pages(self) -> Self:
8398
if not self._result.get("nextCursor"):
8499
raise AssertionError("Expected tools/list response to advertise a nextCursor")

tests/protocols/mcp/test_tools_list.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ def test_tool_def_has_output_schema_should_raise_when_field_absent():
8484
AssertableMCP(payload).lists_tools().contains_tool("x", lambda t: t.has_output_schema())
8585

8686

87+
def test_tool_def_does_not_accept_should_pass_when_params_absent():
88+
payload = _list_response([{"name": "x", "inputSchema": {"properties": {"a": {}}}}])
89+
AssertableMCP(payload).lists_tools().contains_tool("x", lambda t: t.does_not_accept(["b", "c"]))
90+
91+
92+
def test_tool_def_does_not_accept_should_pass_when_no_input_schema():
93+
payload = _list_response([{"name": "x"}])
94+
AssertableMCP(payload).lists_tools().contains_tool("x", lambda t: t.does_not_accept(["any"]))
95+
96+
97+
def test_tool_def_does_not_accept_should_raise_when_param_present():
98+
payload = _list_response([{"name": "x", "inputSchema": {"properties": {"internal": {}}}}])
99+
with pytest.raises(AssertionError, match=r"Tool 'x' should not expose properties \['internal'\]"):
100+
AssertableMCP(payload).lists_tools().contains_tool("x", lambda t: t.does_not_accept(["internal"]))
101+
102+
103+
def test_tool_def_does_not_accept_should_return_self_for_chaining():
104+
payload = _list_response([{"name": "x", "description": "doc", "inputSchema": {"properties": {"a": {}}}}])
105+
AssertableMCP(payload).lists_tools().contains_tool("x", lambda t: t.does_not_accept(["forbidden"]).documented())
106+
107+
87108
def test_does_not_contain_tool_should_pass_when_tool_absent():
88109
payload = _list_response([{"name": "send_email"}])
89110
AssertableMCP(payload).lists_tools().does_not_contain_tool("get_weather")
@@ -103,3 +124,41 @@ def test_lists_tools_has_more_pages_should_pass_when_next_cursor_present():
103124
def test_lists_tools_has_more_pages_should_raise_when_next_cursor_absent():
104125
with pytest.raises(AssertionError, match="nextCursor"):
105126
AssertableMCP(_list_response([{"name": "a"}])).lists_tools().has_more_pages()
127+
128+
129+
def test_every_tool_should_apply_callback_to_each_tool():
130+
payload = _list_response([{"name": "a"}, {"name": "b"}, {"name": "c"}])
131+
call_count = []
132+
AssertableMCP(payload).lists_tools().every_tool(lambda t: call_count.append(1))
133+
assert len(call_count) == 3
134+
135+
136+
def test_every_tool_should_raise_with_tool_name_when_callback_fails():
137+
payload = _list_response(
138+
[
139+
{"name": "a", "description": "doc a"},
140+
{"name": "b"},
141+
]
142+
)
143+
with pytest.raises(AssertionError, match="Tool 'b' has no description"):
144+
AssertableMCP(payload).lists_tools().every_tool(lambda t: t.documented())
145+
146+
147+
def test_every_tool_should_pass_silently_when_tools_list_is_empty():
148+
AssertableMCP(_list_response([])).lists_tools().every_tool(lambda t: t.documented())
149+
150+
151+
def test_every_tool_should_return_self_for_chaining():
152+
payload = _list_response([{"name": "a", "description": "doc a"}])
153+
AssertableMCP(payload).lists_tools().every_tool(lambda t: t.documented()).with_count(1)
154+
155+
156+
def test_every_tool_should_skip_non_dict_items():
157+
payload = {
158+
"jsonrpc": "2.0",
159+
"id": 1,
160+
"result": {"tools": [{"name": "a"}, "not a dict", {"name": "b"}]},
161+
}
162+
call_count = []
163+
AssertableMCP(payload).lists_tools().every_tool(lambda t: call_count.append(1))
164+
assert len(call_count) == 2

0 commit comments

Comments
 (0)