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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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())