mcp-api-test 0.2.1__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.1/src/mcp_api_test.egg-info → mcp_api_test-0.3.0}/PKG-INFO +1 -1
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/pyproject.toml +1 -1
- {mcp_api_test-0.2.1 → 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.1 → mcp_api_test-0.3.0}/src/mcp_api_test/models.py +2 -1
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/server.py +69 -90
- mcp_api_test-0.3.0/src/mcp_api_test/utils.py +206 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0/src/mcp_api_test.egg-info}/PKG-INFO +1 -1
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/SOURCES.txt +3 -0
- mcp_api_test-0.3.0/tests/test_assertions.py +191 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_mcp_api_tools.py +1 -1
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_run_test_api_behavior.py +29 -17
- 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.1/src/mcp_api_test/utils.py +0 -154
- mcp_api_test-0.2.1/tests/test_utils.py +0 -23
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/LICENSE +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/MANIFEST.in +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/README.md +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/setup.cfg +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/__main__.py +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/flags.py +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/logger.py +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/py.typed +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/dependency_links.txt +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/entry_points.txt +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/requires.txt +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/top_level.txt +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_mcp_server_compat.py +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_packaging.py +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_parse_curl_command.py +0 -0
- {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_parse_status_code.py +0 -0
|
@@ -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
|
|
@@ -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
|
|
@@ -7,13 +7,17 @@ from urllib.parse import urlparse
|
|
|
7
7
|
from typing import Optional, Dict, Any, List, Union
|
|
8
8
|
from mcp.server.mcpserver import MCPServer
|
|
9
9
|
from .logger import get_logger
|
|
10
|
-
from .
|
|
10
|
+
from .assertions import evaluate_assertions
|
|
11
|
+
from .models import APITestResult
|
|
11
12
|
from .utils import (
|
|
13
|
+
DEFAULT_MAX_BODY_CHARS,
|
|
14
|
+
OUTPUT_FORMATS,
|
|
12
15
|
render_console_template_result,
|
|
13
16
|
render_error_result,
|
|
17
|
+
render_json_result,
|
|
14
18
|
build_fullpath_url,
|
|
19
|
+
expand_env,
|
|
15
20
|
redact_curl,
|
|
16
|
-
get_nested_fields,
|
|
17
21
|
)
|
|
18
22
|
from .flags import CURL_BOOLEAN_FLAGS, CURL_VALUE_FLAGS, CURL_WARN_FLAGS
|
|
19
23
|
|
|
@@ -191,10 +195,21 @@ async def run_test_api(
|
|
|
191
195
|
method: str = "GET",
|
|
192
196
|
headers: Optional[Dict[str, str]] = None,
|
|
193
197
|
payload: Optional[Union[Dict[str, Any], List[Any]]] = None,
|
|
198
|
+
query_params: Optional[Dict[str, Any]] = None,
|
|
199
|
+
timeout: float = 30,
|
|
200
|
+
follow_redirects: bool = False,
|
|
201
|
+
verify_tls: bool = True,
|
|
194
202
|
required_status_code: Optional[int] = None,
|
|
195
203
|
required_status_code_range: Optional[str] = None,
|
|
196
204
|
required_response_fields: Optional[List[str]] = None,
|
|
205
|
+
required_response_values: Optional[Dict[str, Any]] = None,
|
|
206
|
+
required_response_headers: Optional[Dict[str, str]] = None,
|
|
197
207
|
required_response_contains: Optional[str] = None,
|
|
208
|
+
response_not_contains: Optional[str] = None,
|
|
209
|
+
max_response_time_ms: Optional[float] = None,
|
|
210
|
+
include_response_headers: bool = False,
|
|
211
|
+
max_body_chars: int = DEFAULT_MAX_BODY_CHARS,
|
|
212
|
+
output_format: str = "text",
|
|
198
213
|
) -> str:
|
|
199
214
|
"""
|
|
200
215
|
Test a given API services, it will supports either:
|
|
@@ -202,21 +217,38 @@ async def run_test_api(
|
|
|
202
217
|
1. Shared cURL command and it will be structured
|
|
203
218
|
2. Or, pre-defined structured requests data
|
|
204
219
|
|
|
220
|
+
Use ``${ENV_VAR}`` in the cURL command, URL, headers, query params or
|
|
221
|
+
payload to inject secrets from the server environment (or its .env file)
|
|
222
|
+
without handling them directly.
|
|
223
|
+
|
|
205
224
|
Example prompt:
|
|
206
225
|
`Please test this API based on this cURL, ensure status
|
|
207
226
|
code is 2xx, and response contains 'id'`
|
|
208
227
|
"""
|
|
228
|
+
# log the command before env expansion so secrets never reach the log
|
|
209
229
|
logger.info(
|
|
210
230
|
message=f"Testing API: {api_name} with cURL: {redact_curl(curl_command) if curl_command else None}"
|
|
211
231
|
)
|
|
212
|
-
|
|
213
|
-
verify_tls = True
|
|
232
|
+
fmt = output_format if output_format in OUTPUT_FORMATS else "text"
|
|
214
233
|
auth: Optional[tuple[str, str]] = None
|
|
215
|
-
status_range
|
|
234
|
+
status_range: Optional[tuple[str, tuple[int, int]]] = None
|
|
216
235
|
|
|
217
236
|
try:
|
|
237
|
+
if output_format not in OUTPUT_FORMATS:
|
|
238
|
+
raise ValueError(f"`output_format` must be one of {OUTPUT_FORMATS}")
|
|
239
|
+
if max_body_chars < 0:
|
|
240
|
+
raise ValueError("`max_body_chars` must be >= 0")
|
|
218
241
|
if required_status_code_range is not None:
|
|
219
|
-
status_range =
|
|
242
|
+
status_range = (
|
|
243
|
+
required_status_code_range,
|
|
244
|
+
parse_status_code_range(required_status_code_range),
|
|
245
|
+
)
|
|
246
|
+
curl_command = expand_env(curl_command)
|
|
247
|
+
base_url = expand_env(base_url)
|
|
248
|
+
endpoint = expand_env(endpoint)
|
|
249
|
+
headers = expand_env(headers)
|
|
250
|
+
payload = expand_env(payload)
|
|
251
|
+
query_params = expand_env(query_params)
|
|
220
252
|
if curl_command:
|
|
221
253
|
parsed = parse_curl_command(command=curl_command)
|
|
222
254
|
elif not base_url or not endpoint:
|
|
@@ -230,6 +262,7 @@ async def run_test_api(
|
|
|
230
262
|
method=method.upper(),
|
|
231
263
|
url=base_url or "",
|
|
232
264
|
error=f"ValueError: {e}",
|
|
265
|
+
output_format=fmt,
|
|
233
266
|
)
|
|
234
267
|
|
|
235
268
|
if curl_command:
|
|
@@ -238,13 +271,16 @@ async def run_test_api(
|
|
|
238
271
|
endpoint = parsed["endpoint"]
|
|
239
272
|
headers = parsed["headers"]
|
|
240
273
|
payload = parsed["request_payload"]
|
|
241
|
-
|
|
242
|
-
|
|
274
|
+
# cURL flags (-L, -k) switch the option, the parameters set the default
|
|
275
|
+
follow_redirects = follow_redirects or parsed["follow_redirects"]
|
|
276
|
+
verify_tls = verify_tls and parsed["verify_tls"]
|
|
243
277
|
auth = parsed["auth"]
|
|
244
278
|
|
|
245
279
|
assert base_url and endpoint # validated above
|
|
246
280
|
|
|
247
281
|
full_path_url = build_fullpath_url(base_url=base_url, endpoint=endpoint)
|
|
282
|
+
if query_params:
|
|
283
|
+
full_path_url = str(httpx.URL(full_path_url).copy_merge_params(query_params))
|
|
248
284
|
method = method.upper()
|
|
249
285
|
|
|
250
286
|
content: Optional[str] = None
|
|
@@ -261,7 +297,7 @@ async def run_test_api(
|
|
|
261
297
|
start_time = time.perf_counter()
|
|
262
298
|
try:
|
|
263
299
|
async with httpx.AsyncClient(
|
|
264
|
-
timeout=
|
|
300
|
+
timeout=timeout, follow_redirects=follow_redirects, verify=verify_tls
|
|
265
301
|
) as client:
|
|
266
302
|
response = await client.request(
|
|
267
303
|
method=method,
|
|
@@ -278,7 +314,11 @@ async def run_test_api(
|
|
|
278
314
|
message=f"Error occurred while testing API: {api_name}, error: {str(e)}"
|
|
279
315
|
)
|
|
280
316
|
return render_error_result(
|
|
281
|
-
api_name=api_name,
|
|
317
|
+
api_name=api_name,
|
|
318
|
+
method=method,
|
|
319
|
+
url=full_path_url,
|
|
320
|
+
error=str(e),
|
|
321
|
+
output_format=fmt,
|
|
282
322
|
)
|
|
283
323
|
|
|
284
324
|
try:
|
|
@@ -289,87 +329,23 @@ async def run_test_api(
|
|
|
289
329
|
)
|
|
290
330
|
resp_body = response.text
|
|
291
331
|
|
|
292
|
-
assertions
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if required_status_code_range is not None:
|
|
306
|
-
# low refers to 1xx - 2xx and high refers to 3xx - 5xx
|
|
307
|
-
low, high = status_range
|
|
308
|
-
passed = low <= response.status_code <= high
|
|
309
|
-
assertions.append(
|
|
310
|
-
{
|
|
311
|
-
"assertion": "status_code_range",
|
|
312
|
-
"expected": required_status_code_range,
|
|
313
|
-
"actual": response.status_code,
|
|
314
|
-
"passed": passed,
|
|
315
|
-
}
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
if required_response_fields:
|
|
319
|
-
for field in required_response_fields:
|
|
320
|
-
if isinstance(resp_body, dict):
|
|
321
|
-
is_exist = get_nested_fields(body=resp_body, field_path=field)
|
|
322
|
-
elif isinstance(resp_body, list):
|
|
323
|
-
# For JSON arrays, check whether at least one item
|
|
324
|
-
# (if dict) contains the expected field.
|
|
325
|
-
is_exist = any(
|
|
326
|
-
(
|
|
327
|
-
get_nested_fields(body=item, field_path=field)
|
|
328
|
-
if isinstance(item, dict)
|
|
329
|
-
else field in str(item)
|
|
330
|
-
)
|
|
331
|
-
for item in resp_body
|
|
332
|
-
)
|
|
333
|
-
else:
|
|
334
|
-
# Scalar or plain-text response like substring match.
|
|
335
|
-
is_exist = field in str(resp_body)
|
|
336
|
-
assertions.append(
|
|
337
|
-
{
|
|
338
|
-
"assertion": "response_field_exist",
|
|
339
|
-
"field": field,
|
|
340
|
-
"passed": is_exist,
|
|
341
|
-
"expected": field,
|
|
342
|
-
"actual": "field is present" if is_exist else "field is missing",
|
|
343
|
-
}
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
if required_response_contains is not None:
|
|
347
|
-
body_str = (
|
|
348
|
-
json.dumps(resp_body) if isinstance(resp_body, dict) else str(resp_body)
|
|
349
|
-
)
|
|
350
|
-
found = required_response_contains in body_str
|
|
351
|
-
assertions.append(
|
|
352
|
-
{
|
|
353
|
-
"assertion": "response_contains",
|
|
354
|
-
"expected": required_response_contains,
|
|
355
|
-
"passed": found,
|
|
356
|
-
}
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
assertion_objects = []
|
|
360
|
-
for a in assertions:
|
|
361
|
-
assertion_objects.append(
|
|
362
|
-
AssertionResult(
|
|
363
|
-
assertion=a["assertion"],
|
|
364
|
-
passed=a["passed"],
|
|
365
|
-
expected=a.get("expected"),
|
|
366
|
-
actual=a.get("actual"),
|
|
367
|
-
)
|
|
368
|
-
)
|
|
332
|
+
assertions = evaluate_assertions(
|
|
333
|
+
response,
|
|
334
|
+
resp_body,
|
|
335
|
+
response_time_ms,
|
|
336
|
+
status_code=required_status_code,
|
|
337
|
+
status_range=status_range,
|
|
338
|
+
fields=required_response_fields,
|
|
339
|
+
values=required_response_values,
|
|
340
|
+
headers=required_response_headers,
|
|
341
|
+
contains=required_response_contains,
|
|
342
|
+
not_contains=response_not_contains,
|
|
343
|
+
max_time_ms=max_response_time_ms,
|
|
344
|
+
)
|
|
369
345
|
|
|
370
346
|
# When no assertions are provided there is nothing to check,
|
|
371
347
|
# set default to False so the user can see the raw response and decide.
|
|
372
|
-
overall_result = all(a
|
|
348
|
+
overall_result = all(a.passed for a in assertions) if assertions else False
|
|
373
349
|
|
|
374
350
|
result = APITestResult(
|
|
375
351
|
api_name=api_name,
|
|
@@ -378,12 +354,15 @@ async def run_test_api(
|
|
|
378
354
|
url=full_path_url,
|
|
379
355
|
status_code=response.status_code,
|
|
380
356
|
response_time_ms=response_time_ms,
|
|
381
|
-
assertions=
|
|
357
|
+
assertions=assertions,
|
|
382
358
|
response_body=resp_body,
|
|
359
|
+
response_headers=dict(response.headers) if include_response_headers else None,
|
|
383
360
|
)
|
|
384
361
|
|
|
385
362
|
logger.info(message="Finished testing API, now returning the result to MCP core...")
|
|
386
|
-
|
|
363
|
+
if fmt == "json":
|
|
364
|
+
return render_json_result(result=result, max_body_chars=max_body_chars)
|
|
365
|
+
return render_console_template_result(result=result, max_body_chars=max_body_chars)
|
|
387
366
|
|
|
388
367
|
|
|
389
368
|
# expose this function so it can be called in toml files
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Utility functions for MCP API testing"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Optional, Tuple
|
|
7
|
+
from .models import APITestResult
|
|
8
|
+
|
|
9
|
+
DEFAULT_MAX_BODY_CHARS = 2000
|
|
10
|
+
OUTPUT_FORMATS = ("text", "json")
|
|
11
|
+
|
|
12
|
+
_SENSITIVE_HEADER = re.compile(
|
|
13
|
+
r"(?i)((?:proxy-)?authorization|cookie|set-cookie|x-api-key|x-auth-token)"
|
|
14
|
+
r"(\s*:\s*)[^'\"]*"
|
|
15
|
+
)
|
|
16
|
+
_USER_FLAG = re.compile(r"(?i)(\s(?:-u|--user)[\s=])(?:'[^']*'|\"[^\"]*\"|\S+)")
|
|
17
|
+
_ENV_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def redact_curl(command: str) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Mask credentials (auth headers, cookies, -u user:pass) in a cURL command
|
|
23
|
+
so it is safe to write to the log file
|
|
24
|
+
"""
|
|
25
|
+
command = _SENSITIVE_HEADER.sub(r"\1\2***", command)
|
|
26
|
+
return _USER_FLAG.sub(r"\1***", command)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def expand_env(value: Any) -> Any:
|
|
30
|
+
"""
|
|
31
|
+
Replace ``${VAR}`` with the value of the environment variable VAR, walking
|
|
32
|
+
through nested dicts and lists. Raises ``ValueError`` when VAR is not set, so
|
|
33
|
+
a literal ``${TOKEN}`` is never sent to the server by accident
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
|
|
37
|
+
def _sub(match: "re.Match[str]") -> str:
|
|
38
|
+
name = match.group(1)
|
|
39
|
+
if name not in os.environ:
|
|
40
|
+
raise ValueError(f"Environment variable '{name}' is not set")
|
|
41
|
+
return os.environ[name]
|
|
42
|
+
|
|
43
|
+
return _ENV_VAR.sub(_sub, value)
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return {k: expand_env(v) for k, v in value.items()}
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
return [expand_env(v) for v in value]
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _compact(value: Any) -> str:
|
|
52
|
+
"""Serialize without padding whitespace, to spend as few tokens as possible"""
|
|
53
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _body_text(body: Any, limit: int) -> str:
|
|
57
|
+
text = body if isinstance(body, str) else _compact(body)
|
|
58
|
+
if len(text) > limit:
|
|
59
|
+
text = text[:limit] + f"...[truncated {len(text) - limit} chars]"
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_console_template_result(
|
|
64
|
+
result: APITestResult, max_body_chars: int = DEFAULT_MAX_BODY_CHARS
|
|
65
|
+
) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Render the test result as compact text. Passing assertions are listed by
|
|
68
|
+
name only, failing ones carry the expected and actual values.
|
|
69
|
+
|
|
70
|
+
``max_body_chars`` truncates the response body, 0 omits it entirely
|
|
71
|
+
"""
|
|
72
|
+
status = "PASS" if result.passed else "FAIL"
|
|
73
|
+
head = (
|
|
74
|
+
f"[{status}] {result.api_name} | {result.method} {result.url}"
|
|
75
|
+
f" | {result.status_code} | {result.response_time_ms:.0f}ms"
|
|
76
|
+
)
|
|
77
|
+
lines = [head]
|
|
78
|
+
|
|
79
|
+
if result.assertions:
|
|
80
|
+
passed = sum(1 for a in result.assertions if a.passed)
|
|
81
|
+
lines[0] += f" | {passed}/{len(result.assertions)} assertions"
|
|
82
|
+
for a in result.assertions:
|
|
83
|
+
if a.passed:
|
|
84
|
+
lines.append(f"ok {a.assertion}")
|
|
85
|
+
else:
|
|
86
|
+
lines.append(
|
|
87
|
+
f"FAIL {a.assertion}: expected {a.expected!r}, actual {a.actual!r}"
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
lines.append("no assertions provided")
|
|
91
|
+
|
|
92
|
+
if result.response_headers:
|
|
93
|
+
lines.append("headers:")
|
|
94
|
+
lines.extend(f" {k}: {v}" for k, v in result.response_headers.items())
|
|
95
|
+
|
|
96
|
+
if max_body_chars > 0:
|
|
97
|
+
lines.append("body: " + _body_text(result.response_body, max_body_chars))
|
|
98
|
+
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def render_json_result(
|
|
103
|
+
result: APITestResult, max_body_chars: int = DEFAULT_MAX_BODY_CHARS
|
|
104
|
+
) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Render the test result as a single line of compact JSON
|
|
107
|
+
"""
|
|
108
|
+
data: dict[str, Any] = {
|
|
109
|
+
"api_name": result.api_name,
|
|
110
|
+
"passed": result.passed,
|
|
111
|
+
"method": result.method,
|
|
112
|
+
"url": result.url,
|
|
113
|
+
"status_code": result.status_code,
|
|
114
|
+
"response_time_ms": round(result.response_time_ms, 2),
|
|
115
|
+
"assertions": [
|
|
116
|
+
{
|
|
117
|
+
"assertion": a.assertion,
|
|
118
|
+
"passed": a.passed,
|
|
119
|
+
"expected": a.expected,
|
|
120
|
+
"actual": a.actual,
|
|
121
|
+
}
|
|
122
|
+
for a in result.assertions
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
if result.response_headers:
|
|
126
|
+
data["response_headers"] = result.response_headers
|
|
127
|
+
if max_body_chars > 0:
|
|
128
|
+
body = result.response_body
|
|
129
|
+
# keep the parsed object when it fits, otherwise fall back to
|
|
130
|
+
# a truncated string
|
|
131
|
+
oversized = (
|
|
132
|
+
len(body if isinstance(body, str) else _compact(body)) > max_body_chars
|
|
133
|
+
)
|
|
134
|
+
data["response_body"] = _body_text(body, max_body_chars) if oversized else body
|
|
135
|
+
return _compact(data)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def render_error_result(
|
|
139
|
+
api_name: str,
|
|
140
|
+
method: str,
|
|
141
|
+
url: str,
|
|
142
|
+
error: str,
|
|
143
|
+
output_format: str = "text",
|
|
144
|
+
) -> str:
|
|
145
|
+
"""
|
|
146
|
+
Render a runtime error in the same format as a successful result
|
|
147
|
+
"""
|
|
148
|
+
if output_format == "json":
|
|
149
|
+
return _compact(
|
|
150
|
+
{
|
|
151
|
+
"api_name": api_name,
|
|
152
|
+
"passed": False,
|
|
153
|
+
"method": method,
|
|
154
|
+
"url": url,
|
|
155
|
+
"error": error,
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
return f"[ERROR] {api_name} | {method} {url}\nerror: {error}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_nested_value(body: Any, field_path: str) -> Tuple[bool, Any]:
|
|
162
|
+
"""
|
|
163
|
+
Resolve a dot-notation path (numeric parts index into lists, for example
|
|
164
|
+
``items.0.name``). Returns ``(found, value)``
|
|
165
|
+
"""
|
|
166
|
+
current = body
|
|
167
|
+
for part in field_path.split("."):
|
|
168
|
+
if isinstance(current, dict):
|
|
169
|
+
if part not in current:
|
|
170
|
+
return False, None
|
|
171
|
+
current = current[part]
|
|
172
|
+
elif isinstance(current, list):
|
|
173
|
+
try:
|
|
174
|
+
current = current[int(part)]
|
|
175
|
+
except (ValueError, IndexError):
|
|
176
|
+
return False, None
|
|
177
|
+
else:
|
|
178
|
+
# can't descend further
|
|
179
|
+
return False, None
|
|
180
|
+
return True, current
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_nested_fields(body: Any, field_path: str) -> bool:
|
|
184
|
+
"""
|
|
185
|
+
Utility functions to check whether a dot-notation field path exists
|
|
186
|
+
in the nested structure
|
|
187
|
+
"""
|
|
188
|
+
return get_nested_value(body, field_path)[0]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_fullpath_url(base_url: str, endpoint: str) -> str:
|
|
192
|
+
"""
|
|
193
|
+
Safely join base_url and endpoint without double "//" or incorrectly
|
|
194
|
+
dropped a particular path segments
|
|
195
|
+
"""
|
|
196
|
+
base = base_url.strip("/")
|
|
197
|
+
_endpoint = endpoint.strip()
|
|
198
|
+
|
|
199
|
+
if not _endpoint:
|
|
200
|
+
_endpoint = "/"
|
|
201
|
+
|
|
202
|
+
if not _endpoint.startswith("/"):
|
|
203
|
+
# automaticaly prepend the '/' before raise an assertion
|
|
204
|
+
_endpoint = "/" + _endpoint
|
|
205
|
+
|
|
206
|
+
return base + _endpoint
|