mcp-api-test 0.2.0__tar.gz → 0.3.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_api_test-0.2.0/src/mcp_api_test.egg-info → mcp_api_test-0.3.0}/PKG-INFO +7 -6
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/README.md +5 -5
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/pyproject.toml +2 -1
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test/__init__.py +1 -1
- mcp_api_test-0.3.0/src/mcp_api_test/assertions.py +192 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test/flags.py +2 -7
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test/models.py +2 -1
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test/server.py +143 -106
- mcp_api_test-0.3.0/src/mcp_api_test/utils.py +206 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0/src/mcp_api_test.egg-info}/PKG-INFO +7 -6
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/SOURCES.txt +4 -0
- mcp_api_test-0.3.0/tests/test_assertions.py +191 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/tests/test_mcp_api_tools.py +1 -1
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/tests/test_packaging.py +9 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/tests/test_parse_curl_command.py +58 -2
- mcp_api_test-0.3.0/tests/test_run_test_api_behavior.py +278 -0
- mcp_api_test-0.3.0/tests/test_run_test_api_options.py +363 -0
- mcp_api_test-0.3.0/tests/test_utils.py +178 -0
- mcp_api_test-0.2.0/src/mcp_api_test/utils.py +0 -117
- mcp_api_test-0.2.0/tests/test_utils.py +0 -23
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/LICENSE +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/MANIFEST.in +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/setup.cfg +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test/__main__.py +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test/logger.py +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test/py.typed +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/dependency_links.txt +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/entry_points.txt +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/requires.txt +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/top_level.txt +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/tests/test_mcp_server_compat.py +0 -0
- {mcp_api_test-0.2.0 → mcp_api_test-0.3.0}/tests/test_parse_status_code.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-api-test
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: MCP server for testing REST API with built-in assertions
|
|
5
5
|
Author: Ryan Febriansyah
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
17
|
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
18
19
|
Requires-Python: >=3.10
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
License-File: LICENSE
|
|
@@ -40,7 +41,7 @@ Unlike traditional testing frameworks, `mcp-api-test` exposes API testing as a *
|
|
|
40
41
|
|
|
41
42
|
It complements (rather than replaces) tools like pytest, Postman, and Playwright.
|
|
42
43
|
|
|
43
|
-
> **Breaking changes in v0.2.0.** The server now runs on `mcp` 2.x, and the package layout moved from flat top-level modules to a proper `mcp_api_test` package. If you import internals directly, you may update to `from mcp_api_test.server import ...`. MCP client configuration (OpenCode, Claude Desktop, Claude Code) is unaffected. See the [changelog](
|
|
44
|
+
> **Breaking changes in v0.2.0.** The server now runs on `mcp` 2.x, and the package layout moved from flat top-level modules to a proper `mcp_api_test` package. If you import internals directly, you may update to `from mcp_api_test.server import ...`. MCP client configuration (OpenCode, Claude Desktop, Claude Code) is unaffected. See the [changelog](https://ryaneatfood.gitlab.io/mcp-api-testing/changelog/) for details.
|
|
44
45
|
|
|
45
46
|
## Use cases
|
|
46
47
|
|
|
@@ -105,10 +106,10 @@ The agent invokes `run_test_api`, validates against the expected contract, and r
|
|
|
105
106
|
|
|
106
107
|
Full documentation is hosted on this [page](https://ryaneatfood.gitlab.io/mcp-api-testing/), and another way to navigate the docs:
|
|
107
108
|
|
|
108
|
-
- **[Getting Started](
|
|
109
|
-
- **[Usage](
|
|
110
|
-
- **[Guides](
|
|
111
|
-
- **[Reference](
|
|
109
|
+
- **[Getting Started](https://ryaneatfood.gitlab.io/mcp-api-testing/getting-started/installation/)**: installation and client setup
|
|
110
|
+
- **[Usage](https://ryaneatfood.gitlab.io/mcp-api-testing/usage/request-modes/)**: cURL mode, structured mode, assertions, response format
|
|
111
|
+
- **[Guides](https://ryaneatfood.gitlab.io/mcp-api-testing/guides/troubleshooting/)**: troubleshooting, development, configuration
|
|
112
|
+
- **[Reference](https://ryaneatfood.gitlab.io/mcp-api-testing/reference/tool-parameters/)**: full parameter reference
|
|
112
113
|
|
|
113
114
|
## License
|
|
114
115
|
|
|
@@ -8,7 +8,7 @@ Unlike traditional testing frameworks, `mcp-api-test` exposes API testing as a *
|
|
|
8
8
|
|
|
9
9
|
It complements (rather than replaces) tools like pytest, Postman, and Playwright.
|
|
10
10
|
|
|
11
|
-
> **Breaking changes in v0.2.0.** The server now runs on `mcp` 2.x, and the package layout moved from flat top-level modules to a proper `mcp_api_test` package. If you import internals directly, you may update to `from mcp_api_test.server import ...`. MCP client configuration (OpenCode, Claude Desktop, Claude Code) is unaffected. See the [changelog](
|
|
11
|
+
> **Breaking changes in v0.2.0.** The server now runs on `mcp` 2.x, and the package layout moved from flat top-level modules to a proper `mcp_api_test` package. If you import internals directly, you may update to `from mcp_api_test.server import ...`. MCP client configuration (OpenCode, Claude Desktop, Claude Code) is unaffected. See the [changelog](https://ryaneatfood.gitlab.io/mcp-api-testing/changelog/) for details.
|
|
12
12
|
|
|
13
13
|
## Use cases
|
|
14
14
|
|
|
@@ -73,10 +73,10 @@ The agent invokes `run_test_api`, validates against the expected contract, and r
|
|
|
73
73
|
|
|
74
74
|
Full documentation is hosted on this [page](https://ryaneatfood.gitlab.io/mcp-api-testing/), and another way to navigate the docs:
|
|
75
75
|
|
|
76
|
-
- **[Getting Started](
|
|
77
|
-
- **[Usage](
|
|
78
|
-
- **[Guides](
|
|
79
|
-
- **[Reference](
|
|
76
|
+
- **[Getting Started](https://ryaneatfood.gitlab.io/mcp-api-testing/getting-started/installation/)**: installation and client setup
|
|
77
|
+
- **[Usage](https://ryaneatfood.gitlab.io/mcp-api-testing/usage/request-modes/)**: cURL mode, structured mode, assertions, response format
|
|
78
|
+
- **[Guides](https://ryaneatfood.gitlab.io/mcp-api-testing/guides/troubleshooting/)**: troubleshooting, development, configuration
|
|
79
|
+
- **[Reference](https://ryaneatfood.gitlab.io/mcp-api-testing/reference/tool-parameters/)**: full parameter reference
|
|
80
80
|
|
|
81
81
|
## License
|
|
82
82
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mcp-api-test"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "MCP server for testing REST API with built-in assertions"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -28,6 +28,7 @@ classifiers = [
|
|
|
28
28
|
"Programming Language :: Python :: 3.12",
|
|
29
29
|
"Topic :: Software Development :: Quality Assurance",
|
|
30
30
|
"Topic :: Software Development :: Testing",
|
|
31
|
+
"Typing :: Typed",
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
[project.scripts]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assertion evaluation for a completed HTTP response.
|
|
3
|
+
|
|
4
|
+
``evaluate_assertions`` is the single entry point: it takes the response, the
|
|
5
|
+
parsed body and the measured time, and returns one ``AssertionResult`` per
|
|
6
|
+
requested check. It never raises for a failed check, a mismatch is reported as
|
|
7
|
+
``passed=False`` with the expected and actual values, so the caller can render
|
|
8
|
+
every failure at once instead of stopping at the first one.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import httpx
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
from .models import AssertionResult
|
|
15
|
+
from .utils import get_nested_fields, get_nested_value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def values_equal(expected: Any, actual: Any) -> bool:
|
|
19
|
+
"""
|
|
20
|
+
Compare an expected JSON value with the actual one.
|
|
21
|
+
|
|
22
|
+
Same as ``==`` except that a boolean never equals a number: in Python
|
|
23
|
+
``True == 1`` and ``False == 0``, but in JSON they are different types, so
|
|
24
|
+
an assertion of ``{"ok": true}`` must not pass against ``{"ok": 1}``.
|
|
25
|
+
Nested dicts and lists are compared with plain ``==``.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(expected, bool) != isinstance(actual, bool):
|
|
28
|
+
return False
|
|
29
|
+
return bool(expected == actual)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def body_as_text(body: Any) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Serialize a response body into the text the substring assertions search.
|
|
35
|
+
|
|
36
|
+
Dicts and lists become JSON (with real double quotes, and non-ASCII
|
|
37
|
+
characters kept as-is rather than ``\\uXXXX`` escapes). Anything else, such
|
|
38
|
+
as a plain-text body, is returned as ``str``.
|
|
39
|
+
"""
|
|
40
|
+
return (
|
|
41
|
+
json.dumps(body, ensure_ascii=False)
|
|
42
|
+
if isinstance(body, (dict, list))
|
|
43
|
+
else str(body)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def evaluate_assertions(
|
|
48
|
+
response: httpx.Response,
|
|
49
|
+
body: Any,
|
|
50
|
+
response_time_ms: float,
|
|
51
|
+
*,
|
|
52
|
+
status_code: Optional[int] = None,
|
|
53
|
+
status_range: Optional[Tuple[str, Tuple[int, int]]] = None,
|
|
54
|
+
fields: Optional[List[str]] = None,
|
|
55
|
+
values: Optional[Dict[str, Any]] = None,
|
|
56
|
+
headers: Optional[Dict[str, str]] = None,
|
|
57
|
+
contains: Optional[str] = None,
|
|
58
|
+
not_contains: Optional[str] = None,
|
|
59
|
+
max_time_ms: Optional[float] = None,
|
|
60
|
+
) -> List[AssertionResult]:
|
|
61
|
+
"""
|
|
62
|
+
Run every requested assertion independently and return one result each.
|
|
63
|
+
|
|
64
|
+
Only the checks whose argument is given are run, so with no keyword
|
|
65
|
+
arguments the result is an empty list. Results keep a stable order:
|
|
66
|
+
status code, status range, fields, values, headers, contains,
|
|
67
|
+
not-contains, then response time.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
response: The completed response, used for the status code and headers.
|
|
71
|
+
body: The parsed response body, JSON when it parsed, otherwise raw text.
|
|
72
|
+
response_time_ms: How long the request took, in milliseconds.
|
|
73
|
+
status_code: Exact status code the response must have.
|
|
74
|
+
status_range: ``(label, (low, high))`` inclusive range the status code
|
|
75
|
+
must fall in. The label (for example ``"2xx"``) is only used for
|
|
76
|
+
reporting.
|
|
77
|
+
fields: Dot-notation paths that must exist. For a JSON array body, a
|
|
78
|
+
path passes when at least one item has it. For a non-JSON body the
|
|
79
|
+
entry is matched as a plain substring.
|
|
80
|
+
values: Dot-notation path to exact expected value. Numeric path parts
|
|
81
|
+
index into lists (``items.0.id``). A missing path fails, even when
|
|
82
|
+
the expected value is ``None``. One result is produced per path.
|
|
83
|
+
headers: Header name to expected text. Names are case-insensitive and
|
|
84
|
+
the value passes when it *contains* the expected text, ignoring
|
|
85
|
+
case, so ``"application/json"`` matches
|
|
86
|
+
``"application/json; charset=utf-8"``. An empty string only checks
|
|
87
|
+
that the header is present. One result is produced per header.
|
|
88
|
+
contains: Text the serialized body must contain.
|
|
89
|
+
not_contains: Text the serialized body must not contain.
|
|
90
|
+
max_time_ms: Upper limit, inclusive, for ``response_time_ms``.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
One ``AssertionResult`` per check. ``expected`` and ``actual`` are set
|
|
94
|
+
so a failure can be explained without re-running the request.
|
|
95
|
+
"""
|
|
96
|
+
results: List[AssertionResult] = []
|
|
97
|
+
actual_status = response.status_code
|
|
98
|
+
|
|
99
|
+
if status_code is not None:
|
|
100
|
+
results.append(
|
|
101
|
+
AssertionResult(
|
|
102
|
+
"status_code", actual_status == status_code, status_code, actual_status
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if status_range is not None:
|
|
107
|
+
label, (low, high) = status_range
|
|
108
|
+
results.append(
|
|
109
|
+
AssertionResult(
|
|
110
|
+
"status_code_range", low <= actual_status <= high, label, actual_status
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
for field in fields or []:
|
|
115
|
+
if isinstance(body, dict):
|
|
116
|
+
exists = get_nested_fields(body=body, field_path=field)
|
|
117
|
+
elif isinstance(body, list):
|
|
118
|
+
# For JSON arrays, check whether at least one item
|
|
119
|
+
# (if dict) contains the expected field.
|
|
120
|
+
exists = any(
|
|
121
|
+
(
|
|
122
|
+
get_nested_fields(body=item, field_path=field)
|
|
123
|
+
if isinstance(item, dict)
|
|
124
|
+
else field in str(item)
|
|
125
|
+
)
|
|
126
|
+
for item in body
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
# Scalar or plain-text response like substring match.
|
|
130
|
+
exists = field in str(body)
|
|
131
|
+
results.append(
|
|
132
|
+
AssertionResult(
|
|
133
|
+
"response_field_exist",
|
|
134
|
+
exists,
|
|
135
|
+
field,
|
|
136
|
+
"field is present" if exists else "field is missing",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
for path, expected in (values or {}).items():
|
|
141
|
+
found, actual = get_nested_value(body, path)
|
|
142
|
+
results.append(
|
|
143
|
+
AssertionResult(
|
|
144
|
+
f"response_value:{path}",
|
|
145
|
+
found and values_equal(expected, actual),
|
|
146
|
+
expected,
|
|
147
|
+
actual if found else "field is missing",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
for name, expected in (headers or {}).items():
|
|
152
|
+
actual_header = response.headers.get(name)
|
|
153
|
+
# header exists and its value contains the expected text, so that
|
|
154
|
+
# "application/json" also matches "application/json; charset=utf-8"
|
|
155
|
+
ok = actual_header is not None and expected.lower() in actual_header.lower()
|
|
156
|
+
results.append(
|
|
157
|
+
AssertionResult(
|
|
158
|
+
f"response_header:{name.lower()}",
|
|
159
|
+
ok,
|
|
160
|
+
expected,
|
|
161
|
+
actual_header if actual_header is not None else "header is missing",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if contains is not None:
|
|
166
|
+
results.append(
|
|
167
|
+
AssertionResult(
|
|
168
|
+
"response_contains", contains in body_as_text(body), contains, None
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if not_contains is not None:
|
|
173
|
+
results.append(
|
|
174
|
+
AssertionResult(
|
|
175
|
+
"response_not_contains",
|
|
176
|
+
not_contains not in body_as_text(body),
|
|
177
|
+
not_contains,
|
|
178
|
+
None,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if max_time_ms is not None:
|
|
183
|
+
results.append(
|
|
184
|
+
AssertionResult(
|
|
185
|
+
"max_response_time_ms",
|
|
186
|
+
response_time_ms <= max_time_ms,
|
|
187
|
+
f"<= {max_time_ms:g}",
|
|
188
|
+
round(response_time_ms, 2),
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return results
|
|
@@ -12,7 +12,7 @@ CURL_VALUE_FLAGS: dict[str, str] = {
|
|
|
12
12
|
"--data-raw": "data", # browser DevTools "Copy as cURL" uses this
|
|
13
13
|
"--data-binary": "data", # common in API clients
|
|
14
14
|
"--data-urlencode": "data", # URL-encoded payloads
|
|
15
|
-
"-u": "user",
|
|
15
|
+
"-u": "user", # basic auth, applied via httpx.BasicAuth
|
|
16
16
|
"--user": "user",
|
|
17
17
|
"--url": "url_flag", # explicit --url form
|
|
18
18
|
"-A": "user_agent",
|
|
@@ -53,9 +53,4 @@ CURL_BOOLEAN_FLAGS: set[str] = {
|
|
|
53
53
|
|
|
54
54
|
# flags that we recognize but want to surface with a warning for,
|
|
55
55
|
# emit a logger.warning with a human-readable explanation
|
|
56
|
-
CURL_WARN_FLAGS: dict[str, str] = {
|
|
57
|
-
"-k": "--insecure is not supported: TLS verification will still be enforced",
|
|
58
|
-
"--insecure": "--insecure is not supported: TLS verification will still be enforced",
|
|
59
|
-
"-u": "-u/--user (basic auth) is not supported: add an Authorization header explicitly",
|
|
60
|
-
"--user": "-u/--user (basic auth) is not supported: add an Authorization header explicitly",
|
|
61
|
-
}
|
|
56
|
+
CURL_WARN_FLAGS: dict[str, str] = {}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from dataclasses import dataclass, field
|
|
2
|
-
from typing import List, Any
|
|
2
|
+
from typing import Dict, List, Any, Optional
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
@dataclass
|
|
@@ -20,3 +20,4 @@ class APITestResult:
|
|
|
20
20
|
response_time_ms: float
|
|
21
21
|
assertions: List[AssertionResult] = field(default_factory=list)
|
|
22
22
|
response_body: Any = None
|
|
23
|
+
response_headers: Optional[Dict[str, str]] = None
|