pytest-mcp-server 0.1.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.
- mcptest/__init__.py +45 -0
- mcptest/__main__.py +5 -0
- mcptest/cli.py +180 -0
- mcptest/client.py +553 -0
- mcptest/lint.py +214 -0
- mcptest/plugin.py +161 -0
- mcptest/snapshot.py +200 -0
- pytest_mcp_server-0.1.0.dist-info/METADATA +239 -0
- pytest_mcp_server-0.1.0.dist-info/RECORD +12 -0
- pytest_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- pytest_mcp_server-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
mcptest/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""mcptest: automated tests and CI checks for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
from .client import (
|
|
8
|
+
MCPClient,
|
|
9
|
+
MCPError,
|
|
10
|
+
MCPTimeout,
|
|
11
|
+
MCPTransportError,
|
|
12
|
+
ToolResult,
|
|
13
|
+
)
|
|
14
|
+
from .lint import RULES, Finding, at_or_above, lint_tools
|
|
15
|
+
from .snapshot import Change, diff, normalize
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"MCPClient",
|
|
21
|
+
"MCPError",
|
|
22
|
+
"MCPTimeout",
|
|
23
|
+
"MCPTransportError",
|
|
24
|
+
"ToolResult",
|
|
25
|
+
"Finding",
|
|
26
|
+
"RULES",
|
|
27
|
+
"lint_tools",
|
|
28
|
+
"assert_lint_clean",
|
|
29
|
+
"Change",
|
|
30
|
+
"diff",
|
|
31
|
+
"normalize",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def assert_lint_clean(source: Any, *, fail_on: str = "warning", ignore: Iterable[str] = ()) -> None:
|
|
37
|
+
"""Raise ``AssertionError`` if the tools have findings at ``fail_on`` or worse.
|
|
38
|
+
|
|
39
|
+
``source`` is an :class:`MCPClient` or a ``tools/list`` result.
|
|
40
|
+
"""
|
|
41
|
+
tools = source.list_tools() if isinstance(source, MCPClient) else source
|
|
42
|
+
findings = at_or_above(lint_tools(tools, ignore=ignore), fail_on)
|
|
43
|
+
if findings:
|
|
44
|
+
body = "\n ".join(str(f) for f in findings)
|
|
45
|
+
raise AssertionError(f"{len(findings)} MCP lint finding(s):\n {body}")
|
mcptest/__main__.py
ADDED
mcptest/cli.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Command line: ``mcptest lint|tools|call|snapshot|diff``.
|
|
2
|
+
|
|
3
|
+
The server comes after ``--`` (a command to start over stdio) or from
|
|
4
|
+
``--url`` (a running Streamable HTTP server)::
|
|
5
|
+
|
|
6
|
+
mcptest lint -- python server.py
|
|
7
|
+
mcptest lint --url http://localhost:8000/mcp
|
|
8
|
+
mcptest snapshot -o mcp-tools.json -- npx -y @acme/mcp-server
|
|
9
|
+
mcptest diff mcp-tools.json -- python server.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from . import snapshot as snap
|
|
21
|
+
from .client import MCPClient, MCPError, MCPTransportError
|
|
22
|
+
from .lint import RULES, SEVERITIES, at_or_above, count_by_severity, lint_tools
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _split(argv: Sequence[str]):
|
|
26
|
+
argv = list(argv)
|
|
27
|
+
if "--" in argv:
|
|
28
|
+
i = argv.index("--")
|
|
29
|
+
return argv[:i], argv[i + 1:]
|
|
30
|
+
return argv, []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _add_server_args(p: argparse.ArgumentParser) -> None:
|
|
34
|
+
p.add_argument("--url", help="Streamable HTTP URL of a running server")
|
|
35
|
+
p.add_argument("--header", action="append", default=[], metavar="NAME:VALUE",
|
|
36
|
+
help="HTTP header to send, e.g. 'Authorization: Bearer ...' (repeatable)")
|
|
37
|
+
p.add_argument("--env", action="append", default=[], metavar="NAME=VALUE",
|
|
38
|
+
help="environment variable for a stdio server (repeatable)")
|
|
39
|
+
p.add_argument("--timeout", type=float, default=30.0, help="seconds per reply (default 30)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _connect(args: argparse.Namespace, command: List[str]) -> MCPClient:
|
|
43
|
+
if args.url and command:
|
|
44
|
+
raise SystemExit("mcptest: give --url or a command after --, not both")
|
|
45
|
+
if args.url:
|
|
46
|
+
headers: Dict[str, str] = {}
|
|
47
|
+
for h in args.header:
|
|
48
|
+
name, _, value = h.partition(":")
|
|
49
|
+
headers[name.strip()] = value.strip()
|
|
50
|
+
return MCPClient.http(args.url, headers=headers, timeout=args.timeout)
|
|
51
|
+
if command:
|
|
52
|
+
env = dict(e.split("=", 1) for e in args.env if "=" in e)
|
|
53
|
+
return MCPClient.stdio(command, env=env, timeout=args.timeout)
|
|
54
|
+
raise SystemExit("mcptest: no server given; add --url URL or -- COMMAND ...")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parser() -> argparse.ArgumentParser:
|
|
58
|
+
p = argparse.ArgumentParser(
|
|
59
|
+
prog="mcptest",
|
|
60
|
+
description="Test and lint MCP servers. Put the server command after --.",
|
|
61
|
+
)
|
|
62
|
+
p.add_argument("--version", action="version", version=f"mcptest {__version__}")
|
|
63
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
64
|
+
|
|
65
|
+
lint = sub.add_parser("lint", help="check tool names, descriptions and schemas")
|
|
66
|
+
_add_server_args(lint)
|
|
67
|
+
lint.add_argument("--ignore", default="", help="comma-separated rule codes to skip")
|
|
68
|
+
lint.add_argument("--fail-on", choices=SEVERITIES, default="error",
|
|
69
|
+
help="exit 1 if a finding is at this level or worse (default: error)")
|
|
70
|
+
lint.add_argument("--format", choices=("text", "json"), default="text")
|
|
71
|
+
lint.add_argument("--rules", action="store_true", help="list the rules and exit")
|
|
72
|
+
|
|
73
|
+
tools = sub.add_parser("tools", help="print the server's tools as JSON")
|
|
74
|
+
_add_server_args(tools)
|
|
75
|
+
|
|
76
|
+
call = sub.add_parser("call", help="call one tool and print the result")
|
|
77
|
+
_add_server_args(call)
|
|
78
|
+
call.add_argument("tool")
|
|
79
|
+
call.add_argument("arguments", nargs="?", default="{}", help="JSON object of arguments")
|
|
80
|
+
|
|
81
|
+
snapshot = sub.add_parser("snapshot", help="write the tool schemas to a JSON snapshot")
|
|
82
|
+
_add_server_args(snapshot)
|
|
83
|
+
snapshot.add_argument("-o", "--output", default="-", help="file to write (default: stdout)")
|
|
84
|
+
|
|
85
|
+
diff = sub.add_parser("diff", help="compare a snapshot with another snapshot or a live server")
|
|
86
|
+
_add_server_args(diff)
|
|
87
|
+
diff.add_argument("old", help="the earlier snapshot file")
|
|
88
|
+
diff.add_argument("new", nargs="?", help="the later snapshot file (omit to use the live server)")
|
|
89
|
+
diff.add_argument("--format", choices=("text", "json"), default="text")
|
|
90
|
+
diff.add_argument("--allow-breaking", action="store_true",
|
|
91
|
+
help="exit 0 even when there are breaking changes")
|
|
92
|
+
return p
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
96
|
+
own, command = _split(sys.argv[1:] if argv is None else argv)
|
|
97
|
+
args = _parser().parse_args(own)
|
|
98
|
+
try:
|
|
99
|
+
return _run(args, command)
|
|
100
|
+
except (MCPTransportError, MCPError) as exc:
|
|
101
|
+
print(f"mcptest: {exc}", file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _run(args: argparse.Namespace, command: List[str]) -> int:
|
|
106
|
+
if args.cmd == "lint":
|
|
107
|
+
if args.rules:
|
|
108
|
+
for code, text in RULES.items():
|
|
109
|
+
print(f"{code} {text}")
|
|
110
|
+
return 0
|
|
111
|
+
with _connect(args, command) as client:
|
|
112
|
+
tools = client.list_tools()
|
|
113
|
+
name = client.server_info.get("name") or "server"
|
|
114
|
+
ignore = [c.strip() for c in args.ignore.split(",") if c.strip()]
|
|
115
|
+
findings = lint_tools(tools, ignore=ignore)
|
|
116
|
+
if args.format == "json":
|
|
117
|
+
print(json.dumps({"server": name, "tools": len(tools),
|
|
118
|
+
"findings": [f.as_dict() for f in findings]}, indent=2))
|
|
119
|
+
else:
|
|
120
|
+
for f in findings:
|
|
121
|
+
print(f)
|
|
122
|
+
counts = count_by_severity(findings)
|
|
123
|
+
size = len(json.dumps(tools))
|
|
124
|
+
print(
|
|
125
|
+
f"\n{name}: {len(tools)} tools, about {size // 4:,} tokens of definitions; "
|
|
126
|
+
f"{counts['error']} errors, {counts['warning']} warnings, {counts['info']} info"
|
|
127
|
+
)
|
|
128
|
+
return 1 if at_or_above(findings, args.fail_on) else 0
|
|
129
|
+
|
|
130
|
+
if args.cmd == "tools":
|
|
131
|
+
with _connect(args, command) as client:
|
|
132
|
+
print(json.dumps(client.list_tools(), indent=2, ensure_ascii=False))
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
if args.cmd == "call":
|
|
136
|
+
try:
|
|
137
|
+
arguments: Any = json.loads(args.arguments)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise SystemExit(f"mcptest: arguments must be a JSON object: {exc}") from None
|
|
140
|
+
if not isinstance(arguments, dict):
|
|
141
|
+
raise SystemExit("mcptest: arguments must be a JSON object")
|
|
142
|
+
with _connect(args, command) as client:
|
|
143
|
+
result = client.call_tool(args.tool, arguments)
|
|
144
|
+
print(json.dumps(result.raw, indent=2, ensure_ascii=False))
|
|
145
|
+
return 1 if result.is_error else 0
|
|
146
|
+
|
|
147
|
+
if args.cmd == "snapshot":
|
|
148
|
+
with _connect(args, command) as client:
|
|
149
|
+
data = snap.normalize(client.list_tools())
|
|
150
|
+
if args.output == "-":
|
|
151
|
+
sys.stdout.write(snap.dumps(data))
|
|
152
|
+
else:
|
|
153
|
+
snap.save(args.output, data)
|
|
154
|
+
print(f"wrote {len(data['tools'])} tools to {args.output}", file=sys.stderr)
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
if args.cmd == "diff":
|
|
158
|
+
old = snap.load(args.old)
|
|
159
|
+
if args.new:
|
|
160
|
+
new = snap.load(args.new)
|
|
161
|
+
else:
|
|
162
|
+
with _connect(args, command) as client:
|
|
163
|
+
new = snap.normalize(client.list_tools())
|
|
164
|
+
changes = snap.diff(old, new)
|
|
165
|
+
if args.format == "json":
|
|
166
|
+
print(json.dumps([c.as_dict() for c in changes], indent=2))
|
|
167
|
+
elif not changes:
|
|
168
|
+
print("no changes")
|
|
169
|
+
else:
|
|
170
|
+
for c in changes:
|
|
171
|
+
print(c)
|
|
172
|
+
n = len(snap.breaking(changes))
|
|
173
|
+
print(f"\n{n} breaking, {len(changes) - n} compatible")
|
|
174
|
+
return 1 if snap.breaking(changes) and not args.allow_breaking else 0
|
|
175
|
+
|
|
176
|
+
raise AssertionError(args.cmd) # pragma: no cover
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__": # pragma: no cover
|
|
180
|
+
sys.exit(main())
|