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.
- mcp_tool_contract-0.1.0/PKG-INFO +102 -0
- mcp_tool_contract-0.1.0/README.md +91 -0
- mcp_tool_contract-0.1.0/pyproject.toml +34 -0
- mcp_tool_contract-0.1.0/setup.cfg +4 -0
- mcp_tool_contract-0.1.0/src/mcp_tool_contract/__init__.py +176 -0
- mcp_tool_contract-0.1.0/src/mcp_tool_contract.egg-info/PKG-INFO +102 -0
- mcp_tool_contract-0.1.0/src/mcp_tool_contract.egg-info/SOURCES.txt +9 -0
- mcp_tool_contract-0.1.0/src/mcp_tool_contract.egg-info/dependency_links.txt +1 -0
- mcp_tool_contract-0.1.0/src/mcp_tool_contract.egg-info/requires.txt +4 -0
- mcp_tool_contract-0.1.0/src/mcp_tool_contract.egg-info/top_level.txt +1 -0
- mcp_tool_contract-0.1.0/tests/test_contract.py +103 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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)
|