mcp-tool-contract 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-tool-contract
3
+ Version: 0.1.0
4
+ Summary: Shared taxonomies and decorator for MCP tools to self-declare ADR 0016 Layer-1 facts (risk, content trust, idempotency, lifecycle).
5
+ Project-URL: Repository, https://github.com/vinicius-ssantos/mcp-tool-contract
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest<9.0.0,>=8.3.0; extra == "dev"
10
+ Requires-Dist: ruff<1.0.0,>=0.11.0; extra == "dev"
11
+
12
+ # mcp-tool-contract
13
+
14
+ Shared taxonomies and a `@tool` decorator that let an MCP tool self-declare
15
+ the facts about itself that only its author knows for certain: risk level,
16
+ content trustworthiness, idempotency, and lifecycle (version/deprecation).
17
+
18
+ This implements Layer 1 ("Facts") of
19
+ [ADR 0016](https://github.com/vinicius-ssantos/central-mcp-gateway/blob/main/docs/adr/0016-upstream-declared-tool-contract.md)
20
+ in `central-mcp-gateway`. Every upstream MCP service the gateway aggregates is
21
+ owned by the same team, so instead of the gateway guessing these facts from a
22
+ tool's name/description (or requiring them to be re-declared by hand in a
23
+ separate catalog file), the tool's own definition is the single source of
24
+ truth and the gateway reads it from `tools/list`.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install mcp-tool-contract
30
+ ```
31
+
32
+ or with `uv`:
33
+
34
+ ```bash
35
+ uv add mcp-tool-contract
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from mcp_tool_contract import tool, tool_annotations
42
+
43
+ @tool(
44
+ risk="external-publication", # one of RISK_LEVELS
45
+ content_trust="trusted", # one of CONTENT_TRUST_LEVELS
46
+ idempotent=False,
47
+ version="2.1.0",
48
+ )
49
+ def post_update(...):
50
+ ...
51
+ ```
52
+
53
+ When your MCP server framework builds the `tools/list` response for a tool,
54
+ merge `tool_annotations(post_update)` into that tool's `annotations` dict
55
+ alongside its real `inputSchema` and `description` (which this package does
56
+ not touch — your framework already derives those from the function
57
+ signature/docstring).
58
+
59
+ `tool_annotations()` returns `{}` for an undeclared function, so adoption can
60
+ be incremental: undecorated tools simply fall back to the gateway's existing
61
+ name/description heuristic, exactly as before.
62
+
63
+ ## Taxonomies
64
+
65
+ - `RISK_LEVELS` / `RISK_LEVEL_ORDER` — `read-only` < `low-risk-write` <
66
+ `high-risk-write` < `external-publication` < `paid-operation` <
67
+ `destructive`.
68
+ - `CONTENT_TRUST_LEVELS` — `trusted`, `untrusted`, `sensitive`,
69
+ `prompt-injection-prone`. Orthogonal to risk: a read-only tool can still
70
+ return untrusted or adversarial content.
71
+
72
+ An unrecognised value for either field raises `ValueError` at decoration
73
+ time — a typo in the upstream fails that upstream's own CI, instead of
74
+ silently falling back to the gateway's heuristic.
75
+
76
+ ## Trust boundary
77
+
78
+ The gateway remains the single point of control (ADR 0001). A declared fact
79
+ is the *default*; the gateway's static catalog may still veto or override any
80
+ tool's effective risk, content-trust, or schema (ADR 0016 Layer 3), and an
81
+ anti-downgrade check flags any tool whose effective risk or content-trust
82
+ weakens between discovery cycles without a recorded catalog override.
83
+
84
+ ## Release
85
+
86
+ Bump `version` in `pyproject.toml`, then tag and push:
87
+
88
+ ```bash
89
+ git tag v0.2.0
90
+ git push origin v0.2.0
91
+ ```
92
+
93
+ `.github/workflows/publish.yml` builds and publishes the tagged version to
94
+ PyPI.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ uv sync --extra dev
100
+ uv run pytest
101
+ uv run ruff check .
102
+ ```
@@ -0,0 +1,91 @@
1
+ # mcp-tool-contract
2
+
3
+ Shared taxonomies and a `@tool` decorator that let an MCP tool self-declare
4
+ the facts about itself that only its author knows for certain: risk level,
5
+ content trustworthiness, idempotency, and lifecycle (version/deprecation).
6
+
7
+ This implements Layer 1 ("Facts") of
8
+ [ADR 0016](https://github.com/vinicius-ssantos/central-mcp-gateway/blob/main/docs/adr/0016-upstream-declared-tool-contract.md)
9
+ in `central-mcp-gateway`. Every upstream MCP service the gateway aggregates is
10
+ owned by the same team, so instead of the gateway guessing these facts from a
11
+ tool's name/description (or requiring them to be re-declared by hand in a
12
+ separate catalog file), the tool's own definition is the single source of
13
+ truth and the gateway reads it from `tools/list`.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install mcp-tool-contract
19
+ ```
20
+
21
+ or with `uv`:
22
+
23
+ ```bash
24
+ uv add mcp-tool-contract
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ from mcp_tool_contract import tool, tool_annotations
31
+
32
+ @tool(
33
+ risk="external-publication", # one of RISK_LEVELS
34
+ content_trust="trusted", # one of CONTENT_TRUST_LEVELS
35
+ idempotent=False,
36
+ version="2.1.0",
37
+ )
38
+ def post_update(...):
39
+ ...
40
+ ```
41
+
42
+ When your MCP server framework builds the `tools/list` response for a tool,
43
+ merge `tool_annotations(post_update)` into that tool's `annotations` dict
44
+ alongside its real `inputSchema` and `description` (which this package does
45
+ not touch — your framework already derives those from the function
46
+ signature/docstring).
47
+
48
+ `tool_annotations()` returns `{}` for an undeclared function, so adoption can
49
+ be incremental: undecorated tools simply fall back to the gateway's existing
50
+ name/description heuristic, exactly as before.
51
+
52
+ ## Taxonomies
53
+
54
+ - `RISK_LEVELS` / `RISK_LEVEL_ORDER` — `read-only` < `low-risk-write` <
55
+ `high-risk-write` < `external-publication` < `paid-operation` <
56
+ `destructive`.
57
+ - `CONTENT_TRUST_LEVELS` — `trusted`, `untrusted`, `sensitive`,
58
+ `prompt-injection-prone`. Orthogonal to risk: a read-only tool can still
59
+ return untrusted or adversarial content.
60
+
61
+ An unrecognised value for either field raises `ValueError` at decoration
62
+ time — a typo in the upstream fails that upstream's own CI, instead of
63
+ silently falling back to the gateway's heuristic.
64
+
65
+ ## Trust boundary
66
+
67
+ The gateway remains the single point of control (ADR 0001). A declared fact
68
+ is the *default*; the gateway's static catalog may still veto or override any
69
+ tool's effective risk, content-trust, or schema (ADR 0016 Layer 3), and an
70
+ anti-downgrade check flags any tool whose effective risk or content-trust
71
+ weakens between discovery cycles without a recorded catalog override.
72
+
73
+ ## Release
74
+
75
+ Bump `version` in `pyproject.toml`, then tag and push:
76
+
77
+ ```bash
78
+ git tag v0.2.0
79
+ git push origin v0.2.0
80
+ ```
81
+
82
+ `.github/workflows/publish.yml` builds and publishes the tagged version to
83
+ PyPI.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ uv sync --extra dev
89
+ uv run pytest
90
+ uv run ruff check .
91
+ ```
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mcp-tool-contract"
7
+ version = "0.1.0"
8
+ description = "Shared taxonomies and decorator for MCP tools to self-declare ADR 0016 Layer-1 facts (risk, content trust, idempotency, lifecycle)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = []
12
+
13
+ [project.urls]
14
+ Repository = "https://github.com/vinicius-ssantos/mcp-tool-contract"
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=8.3.0,<9.0.0",
19
+ "ruff>=0.11.0,<1.0.0",
20
+ ]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.pytest.ini_options]
26
+ pythonpath = ["src"]
27
+ testpaths = ["tests"]
28
+
29
+ [tool.ruff]
30
+ line-length = 100
31
+ target-version = "py310"
32
+
33
+ [tool.ruff.lint]
34
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,176 @@
1
+ """Shared tool-contract taxonomies and decorator (ADR 0016).
2
+
3
+ central-mcp-gateway aggregates tools from several upstream MCP services that we
4
+ own. Facts about what a tool *is* and *does* — risk level, content
5
+ trustworthiness, idempotency, version — are known with certainty by the
6
+ upstream that implements the tool, yet historically the gateway either
7
+ guessed them from the tool's name/description or required them to be
8
+ re-declared by hand in a separate catalog file. That duplication drifts.
9
+
10
+ This package lets an upstream tool self-declare those facts once, at the
11
+ point where the tool is defined, via the ``@tool`` decorator. The facts are
12
+ exposed as ``tools/list`` annotations that central-mcp-gateway reads as the
13
+ default value for each field, falling back to its own heuristics only for
14
+ tools that declare nothing.
15
+
16
+ Usage in an upstream MCP server::
17
+
18
+ from mcp_tool_contract import tool, tool_annotations
19
+
20
+ @tool(risk="external-publication", content_trust="trusted", idempotent=False)
21
+ def post_update(...): ...
22
+
23
+ # When building the tools/list response for this tool:
24
+ annotations = tool_annotations(post_update)
25
+
26
+ See docs/adr/0016-upstream-declared-tool-contract.md in central-mcp-gateway
27
+ for the full design (the gateway remains the trust boundary: it may always
28
+ veto or override a declared fact via its static catalog — ADR 0016 Layer 3).
29
+ """
30
+
31
+ from collections.abc import Sequence
32
+ from dataclasses import dataclass, field
33
+ from typing import Any
34
+
35
+ __all__ = [
36
+ "RISK_LEVELS",
37
+ "RISK_LEVEL_ORDER",
38
+ "CONTENT_TRUST_LEVELS",
39
+ "ToolContract",
40
+ "tool",
41
+ "get_contract",
42
+ "tool_annotations",
43
+ ]
44
+
45
+ # Valid risk levels, in ascending order of side-effect impact. Mirrors
46
+ # central_mcp_gateway.tools.RISK_LEVELS — the two must not diverge; this
47
+ # package is the canonical source once the gateway migrates to import it.
48
+ RISK_LEVELS = frozenset(
49
+ {
50
+ "read-only",
51
+ "low-risk-write",
52
+ "high-risk-write",
53
+ "external-publication",
54
+ "paid-operation",
55
+ "destructive",
56
+ }
57
+ )
58
+
59
+ RISK_LEVEL_ORDER = (
60
+ "read-only",
61
+ "low-risk-write",
62
+ "high-risk-write",
63
+ "external-publication",
64
+ "paid-operation",
65
+ "destructive",
66
+ )
67
+
68
+ # Trustworthiness of a tool's *response* content. Orthogonal to risk_level: a
69
+ # read-only tool can still return untrusted or adversarial content (e.g. a
70
+ # tool that fetches a web page).
71
+ CONTENT_TRUST_LEVELS = frozenset(
72
+ {
73
+ "trusted",
74
+ "untrusted",
75
+ "sensitive",
76
+ "prompt-injection-prone",
77
+ }
78
+ )
79
+
80
+ # Annotation keys central-mcp-gateway's discovery reads from tools/list, in
81
+ # addition to the three standard MCP hints (readOnlyHint, destructiveHint,
82
+ # idempotentHint), which cannot alone express a 6-level risk taxonomy or a
83
+ # 4-level content-trust taxonomy.
84
+ ANNOTATION_RISK_LEVEL = "risk_level"
85
+ ANNOTATION_CONTENT_TRUST_RISK = "content_trust_risk"
86
+ ANNOTATION_VERSION = "version"
87
+ ANNOTATION_DEPRECATED_SINCE = "deprecated_since"
88
+ ANNOTATION_ALIASES = "aliases"
89
+
90
+ _CONTRACT_ATTR = "__mcp_tool_contract__"
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ToolContract:
95
+ """A tool's self-declared Layer-1 facts (ADR 0016)."""
96
+
97
+ risk: str
98
+ content_trust: str = "trusted"
99
+ idempotent: bool = False
100
+ version: str = "1.0.0"
101
+ deprecated_since: str | None = None
102
+ aliases: tuple[str, ...] = field(default_factory=tuple)
103
+
104
+ def __post_init__(self) -> None:
105
+ if self.risk not in RISK_LEVELS:
106
+ raise ValueError(
107
+ f"Unknown risk level {self.risk!r}; must be one of {sorted(RISK_LEVELS)}"
108
+ )
109
+ if self.content_trust not in CONTENT_TRUST_LEVELS:
110
+ raise ValueError(
111
+ f"Unknown content_trust {self.content_trust!r}; "
112
+ f"must be one of {sorted(CONTENT_TRUST_LEVELS)}"
113
+ )
114
+
115
+ def to_annotations(self) -> dict[str, Any]:
116
+ """Render this contract as MCP tools/list annotation fields."""
117
+ annotations: dict[str, Any] = {
118
+ "readOnlyHint": self.risk == "read-only",
119
+ "destructiveHint": self.risk == "destructive",
120
+ "idempotentHint": self.idempotent,
121
+ ANNOTATION_RISK_LEVEL: self.risk,
122
+ ANNOTATION_CONTENT_TRUST_RISK: self.content_trust,
123
+ ANNOTATION_VERSION: self.version,
124
+ }
125
+ if self.deprecated_since is not None:
126
+ annotations[ANNOTATION_DEPRECATED_SINCE] = self.deprecated_since
127
+ if self.aliases:
128
+ annotations[ANNOTATION_ALIASES] = list(self.aliases)
129
+ return annotations
130
+
131
+
132
+ def tool(
133
+ *,
134
+ risk: str,
135
+ content_trust: str = "trusted",
136
+ idempotent: bool = False,
137
+ version: str = "1.0.0",
138
+ deprecated_since: str | None = None,
139
+ aliases: Sequence[str] = (),
140
+ ):
141
+ """Decorator: self-declare a tool's Layer-1 facts.
142
+
143
+ Raises ValueError immediately (at import time, not at gateway-discovery
144
+ time) if ``risk`` or ``content_trust`` is not a recognised taxonomy value
145
+ — a typo here must fail the upstream's own CI, not silently fall back to
146
+ the gateway's heuristic.
147
+ """
148
+ contract = ToolContract(
149
+ risk=risk,
150
+ content_trust=content_trust,
151
+ idempotent=idempotent,
152
+ version=version,
153
+ deprecated_since=deprecated_since,
154
+ aliases=tuple(aliases),
155
+ )
156
+
157
+ def decorator(func):
158
+ setattr(func, _CONTRACT_ATTR, contract)
159
+ return func
160
+
161
+ return decorator
162
+
163
+
164
+ def get_contract(func: Any) -> ToolContract | None:
165
+ """Return the ToolContract attached to func by @tool, or None if undeclared."""
166
+ return getattr(func, _CONTRACT_ATTR, None)
167
+
168
+
169
+ def tool_annotations(func: Any) -> dict[str, Any]:
170
+ """Return the tools/list annotation fields for func, or {} if undeclared.
171
+
172
+ Merge this into whatever annotations dict the upstream's MCP framework
173
+ builds for the tool, alongside its real inputSchema and description.
174
+ """
175
+ contract = get_contract(func)
176
+ return contract.to_annotations() if contract is not None else {}
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-tool-contract
3
+ Version: 0.1.0
4
+ Summary: Shared taxonomies and decorator for MCP tools to self-declare ADR 0016 Layer-1 facts (risk, content trust, idempotency, lifecycle).
5
+ Project-URL: Repository, https://github.com/vinicius-ssantos/mcp-tool-contract
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest<9.0.0,>=8.3.0; extra == "dev"
10
+ Requires-Dist: ruff<1.0.0,>=0.11.0; extra == "dev"
11
+
12
+ # mcp-tool-contract
13
+
14
+ Shared taxonomies and a `@tool` decorator that let an MCP tool self-declare
15
+ the facts about itself that only its author knows for certain: risk level,
16
+ content trustworthiness, idempotency, and lifecycle (version/deprecation).
17
+
18
+ This implements Layer 1 ("Facts") of
19
+ [ADR 0016](https://github.com/vinicius-ssantos/central-mcp-gateway/blob/main/docs/adr/0016-upstream-declared-tool-contract.md)
20
+ in `central-mcp-gateway`. Every upstream MCP service the gateway aggregates is
21
+ owned by the same team, so instead of the gateway guessing these facts from a
22
+ tool's name/description (or requiring them to be re-declared by hand in a
23
+ separate catalog file), the tool's own definition is the single source of
24
+ truth and the gateway reads it from `tools/list`.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install mcp-tool-contract
30
+ ```
31
+
32
+ or with `uv`:
33
+
34
+ ```bash
35
+ uv add mcp-tool-contract
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from mcp_tool_contract import tool, tool_annotations
42
+
43
+ @tool(
44
+ risk="external-publication", # one of RISK_LEVELS
45
+ content_trust="trusted", # one of CONTENT_TRUST_LEVELS
46
+ idempotent=False,
47
+ version="2.1.0",
48
+ )
49
+ def post_update(...):
50
+ ...
51
+ ```
52
+
53
+ When your MCP server framework builds the `tools/list` response for a tool,
54
+ merge `tool_annotations(post_update)` into that tool's `annotations` dict
55
+ alongside its real `inputSchema` and `description` (which this package does
56
+ not touch — your framework already derives those from the function
57
+ signature/docstring).
58
+
59
+ `tool_annotations()` returns `{}` for an undeclared function, so adoption can
60
+ be incremental: undecorated tools simply fall back to the gateway's existing
61
+ name/description heuristic, exactly as before.
62
+
63
+ ## Taxonomies
64
+
65
+ - `RISK_LEVELS` / `RISK_LEVEL_ORDER` — `read-only` < `low-risk-write` <
66
+ `high-risk-write` < `external-publication` < `paid-operation` <
67
+ `destructive`.
68
+ - `CONTENT_TRUST_LEVELS` — `trusted`, `untrusted`, `sensitive`,
69
+ `prompt-injection-prone`. Orthogonal to risk: a read-only tool can still
70
+ return untrusted or adversarial content.
71
+
72
+ An unrecognised value for either field raises `ValueError` at decoration
73
+ time — a typo in the upstream fails that upstream's own CI, instead of
74
+ silently falling back to the gateway's heuristic.
75
+
76
+ ## Trust boundary
77
+
78
+ The gateway remains the single point of control (ADR 0001). A declared fact
79
+ is the *default*; the gateway's static catalog may still veto or override any
80
+ tool's effective risk, content-trust, or schema (ADR 0016 Layer 3), and an
81
+ anti-downgrade check flags any tool whose effective risk or content-trust
82
+ weakens between discovery cycles without a recorded catalog override.
83
+
84
+ ## Release
85
+
86
+ Bump `version` in `pyproject.toml`, then tag and push:
87
+
88
+ ```bash
89
+ git tag v0.2.0
90
+ git push origin v0.2.0
91
+ ```
92
+
93
+ `.github/workflows/publish.yml` builds and publishes the tagged version to
94
+ PyPI.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ uv sync --extra dev
100
+ uv run pytest
101
+ uv run ruff check .
102
+ ```
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/mcp_tool_contract/__init__.py
4
+ src/mcp_tool_contract.egg-info/PKG-INFO
5
+ src/mcp_tool_contract.egg-info/SOURCES.txt
6
+ src/mcp_tool_contract.egg-info/dependency_links.txt
7
+ src/mcp_tool_contract.egg-info/requires.txt
8
+ src/mcp_tool_contract.egg-info/top_level.txt
9
+ tests/test_contract.py
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ pytest<9.0.0,>=8.3.0
4
+ ruff<1.0.0,>=0.11.0
@@ -0,0 +1 @@
1
+ mcp_tool_contract
@@ -0,0 +1,103 @@
1
+ import pytest
2
+
3
+ from mcp_tool_contract import (
4
+ CONTENT_TRUST_LEVELS,
5
+ RISK_LEVELS,
6
+ ToolContract,
7
+ get_contract,
8
+ tool,
9
+ tool_annotations,
10
+ )
11
+
12
+
13
+ def test_tool_decorator_attaches_contract_without_changing_callable() -> None:
14
+ @tool(risk="external-publication", content_trust="trusted", idempotent=False)
15
+ def publish(): ...
16
+
17
+ assert publish() is None
18
+ contract = get_contract(publish)
19
+ assert contract is not None
20
+ assert contract.risk == "external-publication"
21
+ assert contract.content_trust == "trusted"
22
+ assert contract.idempotent is False
23
+
24
+
25
+ def test_get_contract_returns_none_for_undeclared_function() -> None:
26
+ def plain(): ...
27
+
28
+ assert get_contract(plain) is None
29
+
30
+
31
+ def test_tool_annotations_empty_for_undeclared_function() -> None:
32
+ def plain(): ...
33
+
34
+ assert tool_annotations(plain) == {}
35
+
36
+
37
+ def test_tool_annotations_includes_standard_mcp_hints() -> None:
38
+ @tool(risk="destructive", idempotent=True)
39
+ def delete_everything(): ...
40
+
41
+ annotations = tool_annotations(delete_everything)
42
+ assert annotations["readOnlyHint"] is False
43
+ assert annotations["destructiveHint"] is True
44
+ assert annotations["idempotentHint"] is True
45
+ assert annotations["risk_level"] == "destructive"
46
+ assert annotations["content_trust_risk"] == "trusted"
47
+
48
+
49
+ def test_tool_annotations_read_only_hints() -> None:
50
+ @tool(risk="read-only")
51
+ def get_status(): ...
52
+
53
+ annotations = tool_annotations(get_status)
54
+ assert annotations["readOnlyHint"] is True
55
+ assert annotations["destructiveHint"] is False
56
+
57
+
58
+ def test_tool_annotations_omits_optional_fields_when_unset() -> None:
59
+ @tool(risk="read-only")
60
+ def get_status(): ...
61
+
62
+ annotations = tool_annotations(get_status)
63
+ assert "deprecated_since" not in annotations
64
+ assert "aliases" not in annotations
65
+
66
+
67
+ def test_tool_annotations_includes_deprecated_since_and_aliases_when_set() -> None:
68
+ @tool(risk="read-only", version="2.0.0", deprecated_since="2026-01-01", aliases=["old.name"])
69
+ def get_status(): ...
70
+
71
+ annotations = tool_annotations(get_status)
72
+ assert annotations["version"] == "2.0.0"
73
+ assert annotations["deprecated_since"] == "2026-01-01"
74
+ assert annotations["aliases"] == ["old.name"]
75
+
76
+
77
+ def test_invalid_risk_level_rejected_at_decoration_time() -> None:
78
+ with pytest.raises(ValueError, match="risk level"):
79
+
80
+ @tool(risk="totally-fine-trust-me")
81
+ def shady(): ...
82
+
83
+
84
+ def test_invalid_content_trust_rejected_at_decoration_time() -> None:
85
+ with pytest.raises(ValueError, match="content_trust"):
86
+
87
+ @tool(risk="read-only", content_trust="not-a-real-level")
88
+ def shady(): ...
89
+
90
+
91
+ def test_tool_contract_dataclass_validates_directly() -> None:
92
+ with pytest.raises(ValueError):
93
+ ToolContract(risk="not-a-risk-level")
94
+
95
+
96
+ @pytest.mark.parametrize("risk", sorted(RISK_LEVELS))
97
+ def test_every_risk_level_accepted(risk: str) -> None:
98
+ ToolContract(risk=risk)
99
+
100
+
101
+ @pytest.mark.parametrize("content_trust", sorted(CONTENT_TRUST_LEVELS))
102
+ def test_every_content_trust_level_accepted(content_trust: str) -> None:
103
+ ToolContract(risk="read-only", content_trust=content_trust)