pytest-mcp-assert 0.5.0__py3-none-any.whl
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.
- pytest_mcp_assert/__init__.py +3 -0
- pytest_mcp_assert/plugin.py +226 -0
- pytest_mcp_assert-0.5.0.dist-info/METADATA +95 -0
- pytest_mcp_assert-0.5.0.dist-info/RECORD +7 -0
- pytest_mcp_assert-0.5.0.dist-info/WHEEL +5 -0
- pytest_mcp_assert-0.5.0.dist-info/entry_points.txt +2 -0
- pytest_mcp_assert-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""pytest plugin that runs mcp-assert YAML assertions as test items.
|
|
2
|
+
|
|
3
|
+
Each .yaml file in the configured suite directory becomes a pytest test item.
|
|
4
|
+
The plugin calls the mcp-assert binary with --json output and maps the result
|
|
5
|
+
to pytest pass/fail/skip.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
pytest --mcp-suite evals/
|
|
9
|
+
pytest --mcp-suite evals/ --mcp-fixture /path/to/fixtures
|
|
10
|
+
pytest --mcp-suite evals/ --mcp-server "agent-lsp go:gopls"
|
|
11
|
+
pytest --mcp-suite evals/ --mcp-timeout 60s
|
|
12
|
+
|
|
13
|
+
Configuration via pyproject.toml:
|
|
14
|
+
[tool.pytest.ini_options]
|
|
15
|
+
mcp_suite = "evals/"
|
|
16
|
+
mcp_fixture = "fixtures/"
|
|
17
|
+
mcp_timeout = "30s"
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pytest_addoption(parser):
|
|
29
|
+
"""Register mcp-assert CLI options."""
|
|
30
|
+
group = parser.getgroup("mcp-assert", "MCP server assertion testing")
|
|
31
|
+
group.addoption(
|
|
32
|
+
"--mcp-suite",
|
|
33
|
+
action="store",
|
|
34
|
+
default=None,
|
|
35
|
+
help="Directory containing mcp-assert YAML assertion files",
|
|
36
|
+
)
|
|
37
|
+
group.addoption(
|
|
38
|
+
"--mcp-fixture",
|
|
39
|
+
action="store",
|
|
40
|
+
default=None,
|
|
41
|
+
help="Fixture directory (substituted for {{fixture}} in assertions)",
|
|
42
|
+
)
|
|
43
|
+
group.addoption(
|
|
44
|
+
"--mcp-server",
|
|
45
|
+
action="store",
|
|
46
|
+
default=None,
|
|
47
|
+
help="Override server command for all assertions",
|
|
48
|
+
)
|
|
49
|
+
group.addoption(
|
|
50
|
+
"--mcp-timeout",
|
|
51
|
+
action="store",
|
|
52
|
+
default="30s",
|
|
53
|
+
help="Per-assertion timeout (default: 30s)",
|
|
54
|
+
)
|
|
55
|
+
group.addoption(
|
|
56
|
+
"--mcp-binary",
|
|
57
|
+
action="store",
|
|
58
|
+
default=None,
|
|
59
|
+
help="Path to mcp-assert binary (default: auto-detect from PATH or pip package)",
|
|
60
|
+
)
|
|
61
|
+
parser.addini(
|
|
62
|
+
"mcp_suite", "Directory containing mcp-assert YAML assertion files"
|
|
63
|
+
)
|
|
64
|
+
parser.addini("mcp_fixture", "Fixture directory for assertions")
|
|
65
|
+
parser.addini("mcp_timeout", "Per-assertion timeout")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _find_binary(config):
|
|
69
|
+
"""Find the mcp-assert binary."""
|
|
70
|
+
# 1. Explicit --mcp-binary flag
|
|
71
|
+
explicit = config.getoption("--mcp-binary")
|
|
72
|
+
if explicit:
|
|
73
|
+
return explicit
|
|
74
|
+
|
|
75
|
+
# 2. PATH lookup
|
|
76
|
+
found = shutil.which("mcp-assert")
|
|
77
|
+
if found:
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
# 3. PyPI package (mcp_assert/bin/mcp-assert)
|
|
81
|
+
try:
|
|
82
|
+
import mcp_assert # noqa: F811
|
|
83
|
+
|
|
84
|
+
pkg_dir = os.path.dirname(os.path.abspath(mcp_assert.__file__))
|
|
85
|
+
bin_path = os.path.join(pkg_dir, "bin", "mcp-assert")
|
|
86
|
+
if os.path.isfile(bin_path):
|
|
87
|
+
return bin_path
|
|
88
|
+
except ImportError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pytest_configure(config):
|
|
95
|
+
"""Add the mcp-suite directory to pytest's collection paths."""
|
|
96
|
+
suite_dir = _get_suite_dir(config)
|
|
97
|
+
if suite_dir and os.path.isdir(suite_dir):
|
|
98
|
+
# Only add if no explicit test paths were given (avoid overriding user intent)
|
|
99
|
+
if not config.args or config.args == ["."]:
|
|
100
|
+
config.args = [suite_dir]
|
|
101
|
+
elif suite_dir not in config.args:
|
|
102
|
+
config.args.append(suite_dir)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pytest_collect_file(parent, file_path):
|
|
106
|
+
"""Collect .yaml files from the mcp-suite directory as test items."""
|
|
107
|
+
suite_dir = _get_suite_dir(parent.config)
|
|
108
|
+
if suite_dir is None:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
suite_path = os.path.abspath(suite_dir)
|
|
112
|
+
file_abs = str(file_path)
|
|
113
|
+
|
|
114
|
+
if file_abs.startswith(suite_path) and file_path.suffix == ".yaml":
|
|
115
|
+
return McpAssertFile.from_parent(parent, path=file_path)
|
|
116
|
+
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _get_suite_dir(config):
|
|
121
|
+
"""Get suite directory from CLI option or ini."""
|
|
122
|
+
suite = config.getoption("--mcp-suite")
|
|
123
|
+
if suite:
|
|
124
|
+
return suite
|
|
125
|
+
ini = config.getini("mcp_suite")
|
|
126
|
+
if ini:
|
|
127
|
+
return ini
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class McpAssertFile(pytest.File):
|
|
132
|
+
"""Collector for a single mcp-assert YAML file."""
|
|
133
|
+
|
|
134
|
+
def collect(self):
|
|
135
|
+
"""Yield a single test item for this YAML assertion file."""
|
|
136
|
+
# Extract assertion name from YAML (first "name:" line)
|
|
137
|
+
name = self.path.stem
|
|
138
|
+
try:
|
|
139
|
+
with open(self.path) as f:
|
|
140
|
+
for line in f:
|
|
141
|
+
line = line.strip()
|
|
142
|
+
if line.startswith("name:"):
|
|
143
|
+
name = line[5:].strip().strip('"').strip("'")
|
|
144
|
+
break
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
yield McpAssertItem.from_parent(self, name=name, yaml_path=self.path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class McpAssertItem(pytest.Item):
|
|
152
|
+
"""A single mcp-assert YAML assertion as a pytest test item."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, name, parent, yaml_path):
|
|
155
|
+
super().__init__(name, parent)
|
|
156
|
+
self.yaml_path = yaml_path
|
|
157
|
+
self._result = None
|
|
158
|
+
|
|
159
|
+
def runtest(self):
|
|
160
|
+
"""Run the mcp-assert binary on this YAML file."""
|
|
161
|
+
config = self.config
|
|
162
|
+
binary = _find_binary(config)
|
|
163
|
+
if binary is None:
|
|
164
|
+
pytest.skip(
|
|
165
|
+
"mcp-assert binary not found. Install via: "
|
|
166
|
+
"brew install blackwell-systems/tap/mcp-assert, "
|
|
167
|
+
"pip install mcp-assert, or "
|
|
168
|
+
"npm install -g @blackwell-systems/mcp-assert"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
cmd = [
|
|
172
|
+
binary,
|
|
173
|
+
"run",
|
|
174
|
+
"--suite",
|
|
175
|
+
str(self.yaml_path),
|
|
176
|
+
"--json",
|
|
177
|
+
"--timeout",
|
|
178
|
+
config.getoption("--mcp-timeout"),
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
fixture = config.getoption("--mcp-fixture") or config.getini("mcp_fixture")
|
|
182
|
+
if fixture:
|
|
183
|
+
cmd.extend(["--fixture", fixture])
|
|
184
|
+
|
|
185
|
+
server = config.getoption("--mcp-server")
|
|
186
|
+
if server:
|
|
187
|
+
cmd.extend(["--server", server])
|
|
188
|
+
|
|
189
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
190
|
+
|
|
191
|
+
# Parse JSON output
|
|
192
|
+
try:
|
|
193
|
+
results = json.loads(result.stdout)
|
|
194
|
+
except json.JSONDecodeError:
|
|
195
|
+
if result.returncode != 0:
|
|
196
|
+
raise McpAssertFailure(
|
|
197
|
+
f"mcp-assert failed (exit {result.returncode}): {result.stderr.strip()}"
|
|
198
|
+
)
|
|
199
|
+
raise McpAssertFailure(f"Could not parse mcp-assert output: {result.stdout[:500]}")
|
|
200
|
+
|
|
201
|
+
if not results:
|
|
202
|
+
raise McpAssertFailure("mcp-assert returned no results")
|
|
203
|
+
|
|
204
|
+
# Each YAML file produces one result
|
|
205
|
+
r = results[0] if isinstance(results, list) else results
|
|
206
|
+
|
|
207
|
+
self._result = r
|
|
208
|
+
status = r.get("status", "").lower()
|
|
209
|
+
|
|
210
|
+
if status == "skip":
|
|
211
|
+
pytest.skip(r.get("detail", "skipped"))
|
|
212
|
+
elif status == "fail":
|
|
213
|
+
raise McpAssertFailure(r.get("detail", "assertion failed"))
|
|
214
|
+
|
|
215
|
+
def repr_failure(self, excinfo):
|
|
216
|
+
"""Format assertion failure for pytest output."""
|
|
217
|
+
if isinstance(excinfo.value, McpAssertFailure):
|
|
218
|
+
return str(excinfo.value)
|
|
219
|
+
return super().repr_failure(excinfo)
|
|
220
|
+
|
|
221
|
+
def reportinfo(self):
|
|
222
|
+
return self.path, None, f"mcp-assert: {self.name}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class McpAssertFailure(Exception):
|
|
226
|
+
"""Raised when an mcp-assert assertion fails."""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-mcp-assert
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: pytest plugin for mcp-assert: run MCP server assertions as pytest test items
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/blackwell-systems/mcp-assert
|
|
7
|
+
Project-URL: Documentation, https://blackwell-systems.github.io/mcp-assert
|
|
8
|
+
Project-URL: Repository, https://github.com/blackwell-systems/mcp-assert
|
|
9
|
+
Keywords: pytest,mcp,testing,assertions,model-context-protocol
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: pytest>=7.0
|
|
18
|
+
|
|
19
|
+
# pytest-mcp-assert
|
|
20
|
+
|
|
21
|
+
pytest plugin for [mcp-assert](https://github.com/blackwell-systems/mcp-assert). Run MCP server assertions as pytest test items.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install pytest-mcp-assert
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
You also need the mcp-assert binary. Any of these:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
brew install blackwell-systems/tap/mcp-assert
|
|
33
|
+
pip install mcp-assert
|
|
34
|
+
npm install -g @blackwell-systems/mcp-assert
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
Write assertion YAML files (see [mcp-assert docs](https://blackwell-systems.github.io/mcp-assert)):
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
# evals/echo.yaml
|
|
43
|
+
name: echo returns the message
|
|
44
|
+
server:
|
|
45
|
+
command: my-mcp-server
|
|
46
|
+
assert:
|
|
47
|
+
tool: echo
|
|
48
|
+
args:
|
|
49
|
+
message: hello
|
|
50
|
+
expect:
|
|
51
|
+
not_error: true
|
|
52
|
+
contains: ["hello"]
|
|
53
|
+
timeout: 30s
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Run with pytest:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pytest --mcp-suite evals/
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each YAML file becomes a pytest test item with pass/fail/skip semantics.
|
|
63
|
+
|
|
64
|
+
## Options
|
|
65
|
+
|
|
66
|
+
| Option | pyproject.toml | Description |
|
|
67
|
+
|--------|---------------|-------------|
|
|
68
|
+
| `--mcp-suite DIR` | `mcp_suite` | Directory containing assertion YAML files |
|
|
69
|
+
| `--mcp-fixture DIR` | `mcp_fixture` | Fixture directory (substituted for `{{fixture}}`) |
|
|
70
|
+
| `--mcp-server CMD` | | Override server command for all assertions |
|
|
71
|
+
| `--mcp-timeout DUR` | `mcp_timeout` | Per-assertion timeout (default: 30s) |
|
|
72
|
+
| `--mcp-binary PATH` | | Path to mcp-assert binary |
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
```toml
|
|
77
|
+
# pyproject.toml
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
mcp_suite = "evals/"
|
|
80
|
+
mcp_fixture = "fixtures/"
|
|
81
|
+
mcp_timeout = "30s"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then just run `pytest` with no extra flags.
|
|
85
|
+
|
|
86
|
+
## How it works
|
|
87
|
+
|
|
88
|
+
The plugin discovers `.yaml` files in the suite directory, creates a pytest Item for each one, and calls `mcp-assert run --suite <file> --json` to execute it. The JSON result is mapped to pytest outcomes:
|
|
89
|
+
|
|
90
|
+
- `status: "pass"` becomes a pytest pass
|
|
91
|
+
- `status: "fail"` becomes a pytest failure with the detail as the message
|
|
92
|
+
- `status: "skip"` becomes a pytest skip
|
|
93
|
+
- `skip: true` in the YAML skips the test (for known bugs)
|
|
94
|
+
|
|
95
|
+
The Go binary handles all MCP protocol interaction. The plugin is a thin bridge.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pytest_mcp_assert/__init__.py,sha256=AwkOYbGTNYY9Ozwld4txKf8_PNGCdPr_5-2m9dCVPoQ,59
|
|
2
|
+
pytest_mcp_assert/plugin.py,sha256=LmDt-TKqmP7tD_Wk7ixNK8xf9zPFrKChNoiU7h2Broc,6902
|
|
3
|
+
pytest_mcp_assert-0.5.0.dist-info/METADATA,sha256=1GWVmM5VhwCt1aeOWDGa20NVtzzW6QTPCh2zEsMo1H8,2779
|
|
4
|
+
pytest_mcp_assert-0.5.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
pytest_mcp_assert-0.5.0.dist-info/entry_points.txt,sha256=Jcttm0yzYDldIGc1u9Hv5UkUh8HgTkGu7u579oCx_Lk,49
|
|
6
|
+
pytest_mcp_assert-0.5.0.dist-info/top_level.txt,sha256=5aFjvITEKhfPOe4eYFqStY3uToxqBsnKvPTe2kJQeL4,18
|
|
7
|
+
pytest_mcp_assert-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytest_mcp_assert
|