Skip to content

Commit 02fc117

Browse files
authored
fix(things): harden ToolEnvelope (optional summary, extra=ignore) (shelf) (#36)
1 parent c2cde6e commit 02fc117

2 files changed

Lines changed: 53 additions & 6 deletions

File tree

src/things_mcp/models.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,29 @@
3030

3131

3232
class ToolEnvelope(BaseModel, Generic[DataT]):
33-
"""Uniform JSON envelope returned by every tool's `structured_content`."""
33+
"""Uniform JSON envelope returned by every tool's `structured_content`.
34+
35+
Hardened to never raise at construction time on an internal path:
36+
37+
- ``summary`` is optional and defaults to ``""``. Every tool still sets a
38+
real summary on the wire (the external return contract is unchanged), but
39+
an internal caller that omits it gets an empty headline instead of a
40+
runtime ``ValidationError``.
41+
- ``extra="ignore"`` means an unexpected key (e.g. one re-attached by a
42+
client/middleware round-trip) is dropped rather than rejected.
43+
``ClientCompatibilityMiddleware`` already strips extras on the wire, so
44+
the shape clients see is unchanged; this only removes the footgun where a
45+
stray key would crash construction.
46+
"""
3447

35-
model_config = ConfigDict(extra="forbid")
48+
model_config = ConfigDict(extra="ignore")
3649

3750
data: DataT | None = Field(
3851
default=None,
3952
description="Typed payload. Null when the tool's only useful signal is the summary.",
4053
)
4154
summary: str = Field(
42-
...,
55+
default="",
4356
description="One-sentence machine-readable headline.",
4457
)
4558
meta: dict[str, Any] = Field(

tests/test_models.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,43 @@ def test_envelope_meta_defaults_to_empty_dict(self):
215215
env = ToolEnvelope(data=None, summary="ok")
216216
assert env.meta == {}
217217

218-
def test_envelope_extra_fields_forbidden(self):
219-
with pytest.raises(Exception):
220-
ToolEnvelope.model_validate({"data": None, "summary": "ok", "extra": 1})
218+
def test_envelope_summary_is_optional_and_defaults_to_empty(self):
219+
"""Hardened envelope: omitting `summary` must not raise (footgun fix).
220+
221+
Internal paths that forget to set a summary should get an empty
222+
headline instead of a runtime ValidationError.
223+
"""
224+
env = ToolEnvelope(data=None)
225+
assert env.summary == ""
226+
# Same via model_validate (the structured-output path).
227+
validated = ToolEnvelope.model_validate({"data": None})
228+
assert validated.summary == ""
229+
230+
def test_envelope_ignores_unexpected_extra_key(self):
231+
"""Hardened envelope: an unexpected extra key must be dropped, not raise.
232+
233+
ClientCompatibilityMiddleware already strips extras on the wire; this
234+
guarantees the model itself degrades gracefully if one slips through.
235+
"""
236+
# Construction with an unexpected kwarg must not raise.
237+
env = ToolEnvelope(data=None, summary="ok", unexpected="boom")
238+
assert env.summary == "ok"
239+
assert not hasattr(env, "unexpected")
240+
# And via model_validate (the round-trip path).
241+
validated = ToolEnvelope.model_validate(
242+
{"data": None, "summary": "ok", "extra": 1}
243+
)
244+
assert validated.summary == "ok"
245+
# The extra key is ignored, so the serialized shape clients see is
246+
# unchanged (data/summary/meta only).
247+
assert set(validated.model_dump().keys()) == {"data", "summary", "meta"}
248+
249+
def test_envelope_no_args_does_not_raise(self):
250+
"""Belt-and-suspenders: a fully bare envelope is constructible."""
251+
env = ToolEnvelope()
252+
assert env.data is None
253+
assert env.summary == ""
254+
assert env.meta == {}
221255

222256
def test_todo_status_enum_enforced(self):
223257
with pytest.raises(Exception):

0 commit comments

Comments
 (0)