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.
Files changed (32) hide show
  1. {mcp_api_test-0.2.1/src/mcp_api_test.egg-info → mcp_api_test-0.3.0}/PKG-INFO +1 -1
  2. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/pyproject.toml +1 -1
  3. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/__init__.py +1 -1
  4. mcp_api_test-0.3.0/src/mcp_api_test/assertions.py +192 -0
  5. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/models.py +2 -1
  6. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/server.py +69 -90
  7. mcp_api_test-0.3.0/src/mcp_api_test/utils.py +206 -0
  8. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0/src/mcp_api_test.egg-info}/PKG-INFO +1 -1
  9. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/SOURCES.txt +3 -0
  10. mcp_api_test-0.3.0/tests/test_assertions.py +191 -0
  11. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_mcp_api_tools.py +1 -1
  12. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_run_test_api_behavior.py +29 -17
  13. mcp_api_test-0.3.0/tests/test_run_test_api_options.py +363 -0
  14. mcp_api_test-0.3.0/tests/test_utils.py +178 -0
  15. mcp_api_test-0.2.1/src/mcp_api_test/utils.py +0 -154
  16. mcp_api_test-0.2.1/tests/test_utils.py +0 -23
  17. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/LICENSE +0 -0
  18. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/MANIFEST.in +0 -0
  19. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/README.md +0 -0
  20. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/setup.cfg +0 -0
  21. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/__main__.py +0 -0
  22. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/flags.py +0 -0
  23. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/logger.py +0 -0
  24. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test/py.typed +0 -0
  25. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/dependency_links.txt +0 -0
  26. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/entry_points.txt +0 -0
  27. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/requires.txt +0 -0
  28. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/src/mcp_api_test.egg-info/top_level.txt +0 -0
  29. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_mcp_server_compat.py +0 -0
  30. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_packaging.py +0 -0
  31. {mcp_api_test-0.2.1 → mcp_api_test-0.3.0}/tests/test_parse_curl_command.py +0 -0
  32. {mcp_api_test-0.2.1 → 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.2.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-api-test"
3
- version = "0.2.1"
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"
@@ -1,3 +1,3 @@
1
1
  """Lightweight MCP for REST API testing for Claude Desktop and OpenCode"""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.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 .models import APITestResult, AssertionResult
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
- follow_redirects = False
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 = (0, 0)
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 = parse_status_code_range(required_status_code_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
- follow_redirects = parsed["follow_redirects"]
242
- verify_tls = parsed["verify_tls"]
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=30, follow_redirects=follow_redirects, verify=verify_tls
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, method=method, url=full_path_url, error=str(e)
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: List[Dict[str, Any]] = []
293
-
294
- if required_status_code is not None:
295
- passed = response.status_code == required_status_code
296
- assertions.append(
297
- {
298
- "assertion": "status_code",
299
- "expected": required_status_code,
300
- "actual": response.status_code,
301
- "passed": passed,
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["passed"] for a in assertions) if assertions else False
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=assertion_objects,
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
- return render_console_template_result(result=result)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-api-test
3
- Version: 0.2.1
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