mcp-api-test 0.1.4__tar.gz → 0.2.1__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 (30) hide show
  1. {mcp_api_test-0.1.4/src/mcp_api_test.egg-info → mcp_api_test-0.2.1}/PKG-INFO +10 -6
  2. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/README.md +6 -4
  3. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/pyproject.toml +19 -6
  4. mcp_api_test-0.2.1/src/mcp_api_test/__init__.py +3 -0
  5. {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/__main__.py +1 -1
  6. {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/flags.py +3 -8
  7. {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/logger.py +4 -3
  8. mcp_api_test-0.2.1/src/mcp_api_test/py.typed +0 -0
  9. {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/server.py +108 -42
  10. {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/utils.py +40 -3
  11. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1/src/mcp_api_test.egg-info}/PKG-INFO +10 -6
  12. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/src/mcp_api_test.egg-info/SOURCES.txt +10 -6
  13. mcp_api_test-0.2.1/src/mcp_api_test.egg-info/entry_points.txt +2 -0
  14. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/src/mcp_api_test.egg-info/requires.txt +2 -1
  15. mcp_api_test-0.2.1/src/mcp_api_test.egg-info/top_level.txt +1 -0
  16. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/tests/test_mcp_api_tools.py +1 -1
  17. mcp_api_test-0.2.1/tests/test_mcp_server_compat.py +30 -0
  18. mcp_api_test-0.2.1/tests/test_packaging.py +58 -0
  19. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/tests/test_parse_curl_command.py +59 -4
  20. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/tests/test_parse_status_code.py +1 -1
  21. mcp_api_test-0.2.1/tests/test_run_test_api_behavior.py +266 -0
  22. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/tests/test_utils.py +1 -1
  23. mcp_api_test-0.1.4/src/mcp_api_test.egg-info/entry_points.txt +0 -2
  24. mcp_api_test-0.1.4/src/mcp_api_test.egg-info/top_level.txt +0 -6
  25. mcp_api_test-0.1.4/tests/test_packaging.py +0 -46
  26. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/LICENSE +0 -0
  27. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/MANIFEST.in +0 -0
  28. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/setup.cfg +0 -0
  29. {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/models.py +0 -0
  30. {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/src/mcp_api_test.egg-info/dependency_links.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-api-test
3
- Version: 0.1.4
3
+ Version: 0.2.1
4
4
  Summary: MCP server for testing REST API with built-in assertions
5
5
  Author: Ryan Febriansyah
6
6
  License-Expression: MIT
@@ -15,16 +15,18 @@ 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
21
- Requires-Dist: mcp<2.0.0,>=1.0.0
22
+ Requires-Dist: mcp<3.0.0,>=2.0.0
22
23
  Requires-Dist: httpx
23
24
  Requires-Dist: python-dotenv
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: pytest; extra == "dev"
26
27
  Requires-Dist: pytest-asyncio; extra == "dev"
27
28
  Requires-Dist: respx; extra == "dev"
29
+ Requires-Dist: mypy; extra == "dev"
28
30
  Provides-Extra: docs
29
31
  Requires-Dist: mkdocs-material; extra == "docs"
30
32
  Dynamic: license-file
@@ -39,6 +41,8 @@ Unlike traditional testing frameworks, `mcp-api-test` exposes API testing as a *
39
41
 
40
42
  It complements (rather than replaces) tools like pytest, Postman, and Playwright.
41
43
 
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.
45
+
42
46
  ## Use cases
43
47
 
44
48
  - **QA & test engineering**: prompt an agent with natural language instead of crafting Postman requests
@@ -102,10 +106,10 @@ The agent invokes `run_test_api`, validates against the expected contract, and r
102
106
 
103
107
  Full documentation is hosted on this [page](https://ryaneatfood.gitlab.io/mcp-api-testing/), and another way to navigate the docs:
104
108
 
105
- - **[Getting Started](docs/getting-started/installation.md)**: installation and client setup
106
- - **[Usage](docs/usage/request-modes.md)**: cURL mode, structured mode, assertions, response format
107
- - **[Guides](docs/guides/troubleshooting.md)**: troubleshooting, development, configuration
108
- - **[Reference](docs/reference/tool-parameters.md)**: full parameter 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
109
113
 
110
114
  ## License
111
115
 
@@ -8,6 +8,8 @@ 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](https://ryaneatfood.gitlab.io/mcp-api-testing/changelog/) for details.
12
+
11
13
  ## Use cases
12
14
 
13
15
  - **QA & test engineering**: prompt an agent with natural language instead of crafting Postman requests
@@ -71,10 +73,10 @@ The agent invokes `run_test_api`, validates against the expected contract, and r
71
73
 
72
74
  Full documentation is hosted on this [page](https://ryaneatfood.gitlab.io/mcp-api-testing/), and another way to navigate the docs:
73
75
 
74
- - **[Getting Started](docs/getting-started/installation.md)**: installation and client setup
75
- - **[Usage](docs/usage/request-modes.md)**: cURL mode, structured mode, assertions, response format
76
- - **[Guides](docs/guides/troubleshooting.md)**: troubleshooting, development, configuration
77
- - **[Reference](docs/reference/tool-parameters.md)**: full parameter 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
78
80
 
79
81
  ## License
80
82
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-api-test"
3
- version = "0.1.4"
3
+ version = "0.2.1"
4
4
  description = "MCP server for testing REST API with built-in assertions"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,7 +10,7 @@ authors = [
10
10
 
11
11
  requires-python = ">=3.10"
12
12
  dependencies = [
13
- "mcp>=1.0.0,<2.0.0",
13
+ "mcp>=2.0.0,<3.0.0",
14
14
  "httpx",
15
15
  "python-dotenv"
16
16
  ]
@@ -28,10 +28,11 @@ 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]
34
- mcp-api-test = "server:main"
35
+ mcp-api-test = "mcp_api_test.server:main"
35
36
 
36
37
  [build-system]
37
38
  requires = ["setuptools>=61"]
@@ -39,8 +40,20 @@ build-backend = "setuptools.build_meta"
39
40
 
40
41
  [tool.setuptools]
41
42
  package-dir = {"" = "src"}
42
- py-modules = ["server", "__main__", "logger", "models", "utils", "flags"]
43
+ packages = ["mcp_api_test"]
44
+
45
+ [tool.setuptools.package-data]
46
+ mcp_api_test = ["py.typed"]
43
47
 
44
48
  [project.optional-dependencies]
45
- dev = ["pytest", "pytest-asyncio", "respx"]
46
- docs = ["mkdocs-material"]
49
+ dev = ["pytest", "pytest-asyncio", "respx", "mypy"]
50
+ docs = ["mkdocs-material"]
51
+
52
+ [tool.mypy]
53
+ python_version = "3.10"
54
+ packages = ["mcp_api_test"]
55
+ check_untyped_defs = true
56
+ disallow_untyped_defs = true
57
+ no_implicit_optional = true
58
+ warn_redundant_casts = true
59
+ warn_unused_ignores = true
@@ -0,0 +1,3 @@
1
+ """Lightweight MCP for REST API testing for Claude Desktop and OpenCode"""
2
+
3
+ __version__ = "0.2.1"
@@ -1,6 +1,6 @@
1
1
  """Entry point for the initialize mcp-api-test package when run as a module"""
2
2
 
3
- from src.server import mcp
3
+ from .server import mcp
4
4
 
5
5
  if __name__ == "__main__":
6
6
  mcp.run()
@@ -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",
@@ -28,7 +28,7 @@ CURL_VALUE_FLAGS: dict[str, str] = {
28
28
  }
29
29
 
30
30
  # standalone flags with no following value
31
- CURL_BOOLEAN_FLAGS: dict[str, str] = {
31
+ CURL_BOOLEAN_FLAGS: set[str] = {
32
32
  "-L",
33
33
  "--location", # follow redirects, very common in browser exports
34
34
  "-k",
@@ -53,9 +53,4 @@ CURL_BOOLEAN_FLAGS: dict[str, 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,6 @@
1
1
  import logging
2
2
  import os
3
+ from typing import Any
3
4
 
4
5
 
5
6
  class Logger:
@@ -31,7 +32,7 @@ class Logger:
31
32
 
32
33
  self.logger.addHandler(file_handler)
33
34
 
34
- def info(self, message: str, *args, **kwargs) -> None:
35
+ def info(self, message: str, *args: Any, **kwargs: Any) -> None:
35
36
  """
36
37
  Log an info message with particular arguments
37
38
  """
@@ -39,7 +40,7 @@ class Logger:
39
40
  message = message % args
40
41
  self.logger.info(message, **kwargs)
41
42
 
42
- def warning(self, message: str, *args, **kwargs) -> None:
43
+ def warning(self, message: str, *args: Any, **kwargs: Any) -> None:
43
44
  """
44
45
  Log a warning message with particular arguments
45
46
  """
@@ -47,7 +48,7 @@ class Logger:
47
48
  message = message % args
48
49
  self.logger.warning(message, **kwargs)
49
50
 
50
- def error(self, message: str, *args, **kwargs) -> None:
51
+ def error(self, message: str, *args: Any, **kwargs: Any) -> None:
51
52
  """
52
53
  Log an error message with particular arguments
53
54
  """
File without changes
@@ -2,26 +2,27 @@ import json
2
2
  import shlex
3
3
  import time
4
4
  import httpx
5
- from logger import get_logger
6
5
  from dotenv import load_dotenv
7
6
  from urllib.parse import urlparse
8
- from typing import Optional, Dict, Any, List
9
- from mcp.server.fastmcp import FastMCP
10
- from models import APITestResult, AssertionResult
11
- from utils import (
7
+ from typing import Optional, Dict, Any, List, Union
8
+ from mcp.server.mcpserver import MCPServer
9
+ from .logger import get_logger
10
+ from .models import APITestResult, AssertionResult
11
+ from .utils import (
12
12
  render_console_template_result,
13
13
  render_error_result,
14
14
  build_fullpath_url,
15
+ redact_curl,
15
16
  get_nested_fields,
16
17
  )
17
- from flags import CURL_BOOLEAN_FLAGS, CURL_VALUE_FLAGS, CURL_WARN_FLAGS
18
+ from .flags import CURL_BOOLEAN_FLAGS, CURL_VALUE_FLAGS, CURL_WARN_FLAGS
18
19
 
19
20
  load_dotenv()
20
21
 
21
22
  logger = get_logger(name="mcp-api-testing")
22
23
 
23
24
 
24
- mcp = FastMCP("mcp-api-test")
25
+ mcp = MCPServer("mcp-api-test")
25
26
 
26
27
 
27
28
  def parse_status_code_range(range_str: str) -> tuple[int, int]:
@@ -49,12 +50,16 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
49
50
  """
50
51
 
51
52
  tokens = shlex.split(command)
52
-
53
53
  method = "GET" # initial HTTP methods
54
54
  headers = {}
55
- payload = None
55
+ explicit_method = False
56
+ data_parts: List[str] = []
57
+ use_get = False # -G: send -d data as query string
58
+ head = False # -I: HEAD request
59
+ follow_redirects = False
60
+ verify_tls = True
61
+ auth: Optional[tuple[str, str]] = None
56
62
  url = None
57
-
58
63
  index = 0
59
64
 
60
65
  # skip if the leading was 'curl' token
@@ -84,6 +89,7 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
84
89
 
85
90
  if role == "method":
86
91
  method = value
92
+ explicit_method = True
87
93
  elif role == "header":
88
94
  if ":" in value:
89
95
  key, header_value = value.split(":", 1)
@@ -91,14 +97,10 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
91
97
  else:
92
98
  logger.error(message=f"[curl parser] skipping malformed header")
93
99
  elif role == "data":
94
- raw_data = value
95
- try:
96
- payload = json.loads(raw_data)
97
- except json.JSONDecodeError:
98
- logger.error(
99
- f"[curl parser] payload for '{token}' is not valid JSON"
100
- )
101
- payload = raw_data
100
+ data_parts.append(value)
101
+ elif role == "user":
102
+ username, _, password = value.partition(":")
103
+ auth = (username, password)
102
104
  elif role == "url_flag":
103
105
  url = value
104
106
  elif role == "user_agent":
@@ -112,6 +114,14 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
112
114
  if token in CURL_BOOLEAN_FLAGS:
113
115
  if token in CURL_WARN_FLAGS:
114
116
  logger.warning(message=f"[curl parser] {CURL_WARN_FLAGS[token]}")
117
+ if token in ("-L", "--location"):
118
+ follow_redirects = True
119
+ elif token in ("-k", "--insecure"):
120
+ verify_tls = False
121
+ elif token in ("-G", "--get"):
122
+ use_get = True
123
+ elif token in ("-I", "--head"):
124
+ head = True
115
125
  index += 1
116
126
  continue
117
127
 
@@ -133,6 +143,23 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
133
143
  if not url:
134
144
  raise ValueError("URL must be provided in curl command")
135
145
 
146
+ # multiple -d flags are joined with '&' just like curl does, a single
147
+ # body is kept as JSON when it parses and as a raw string otherwise
148
+ payload: Any = None
149
+ if data_parts and not use_get:
150
+ raw_data = "&".join(data_parts)
151
+ try:
152
+ payload = json.loads(raw_data)
153
+ except json.JSONDecodeError:
154
+ logger.error("[curl parser] payload is not valid JSON, sending as raw")
155
+ payload = raw_data
156
+
157
+ # curl defaults to POST when a body is present and no -X was given
158
+ if head and not explicit_method:
159
+ method = "HEAD"
160
+ elif payload is not None and not explicit_method:
161
+ method = "POST"
162
+
136
163
  parsed = urlparse(url=url)
137
164
  base_url = f"{parsed.scheme}://{parsed.netloc}"
138
165
  endpoint = parsed.path or "/"
@@ -140,12 +167,18 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
140
167
  if parsed.query:
141
168
  endpoint += "?" + parsed.query
142
169
 
170
+ if use_get and data_parts:
171
+ endpoint += ("&" if parsed.query else "?") + "&".join(data_parts)
172
+
143
173
  return {
144
174
  "method": method.upper(),
145
175
  "base_url": base_url,
146
176
  "endpoint": endpoint,
147
177
  "headers": headers,
148
178
  "request_payload": payload,
179
+ "follow_redirects": follow_redirects,
180
+ "verify_tls": verify_tls,
181
+ "auth": auth,
149
182
  }
150
183
 
151
184
 
@@ -157,12 +190,12 @@ async def run_test_api(
157
190
  endpoint: Optional[str] = None,
158
191
  method: str = "GET",
159
192
  headers: Optional[Dict[str, str]] = None,
160
- payload: Optional[Dict[str, Any]] = None,
193
+ payload: Optional[Union[Dict[str, Any], List[Any]]] = None,
161
194
  required_status_code: Optional[int] = None,
162
195
  required_status_code_range: Optional[str] = None,
163
196
  required_response_fields: Optional[List[str]] = None,
164
197
  required_response_contains: Optional[str] = None,
165
- ):
198
+ ) -> str:
166
199
  """
167
200
  Test a given API services, it will supports either:
168
201
 
@@ -173,38 +206,71 @@ async def run_test_api(
173
206
  `Please test this API based on this cURL, ensure status
174
207
  code is 2xx, and response contains 'id'`
175
208
  """
176
- logger.info(message=f"Testing API: {api_name} with cURL: {curl_command}")
177
- if curl_command:
178
- parsed = parse_curl_command(command=curl_command)
209
+ logger.info(
210
+ message=f"Testing API: {api_name} with cURL: {redact_curl(curl_command) if curl_command else None}"
211
+ )
212
+ follow_redirects = False
213
+ verify_tls = True
214
+ auth: Optional[tuple[str, str]] = None
215
+ status_range = (0, 0)
179
216
 
217
+ try:
218
+ if required_status_code_range is not None:
219
+ status_range = parse_status_code_range(required_status_code_range)
220
+ if curl_command:
221
+ parsed = parse_curl_command(command=curl_command)
222
+ elif not base_url or not endpoint:
223
+ raise ValueError(
224
+ "`base_url` and `endpoint` are required unless cURL command is used"
225
+ )
226
+ except ValueError as e:
227
+ logger.error(message=f"Invalid input for API: {api_name}, error: {e}")
228
+ return render_error_result(
229
+ api_name=api_name,
230
+ method=method.upper(),
231
+ url=base_url or "",
232
+ error=f"ValueError: {e}",
233
+ )
234
+
235
+ if curl_command:
180
236
  method = parsed["method"]
181
237
  base_url = parsed["base_url"]
182
238
  endpoint = parsed["endpoint"]
183
239
  headers = parsed["headers"]
184
240
  payload = parsed["request_payload"]
241
+ follow_redirects = parsed["follow_redirects"]
242
+ verify_tls = parsed["verify_tls"]
243
+ auth = parsed["auth"]
185
244
 
186
- if not base_url:
187
- raise ValueError("`base_url` is required unless cURL command is used")
188
-
189
- if not endpoint:
190
- raise ValueError("`endpoint` is required unless cURL command is used")
245
+ assert base_url and endpoint # validated above
191
246
 
192
247
  full_path_url = build_fullpath_url(base_url=base_url, endpoint=endpoint)
193
248
  method = method.upper()
194
249
 
250
+ content: Optional[str] = None
251
+ json_payload: Optional[Union[Dict[str, Any], List[Any]]] = None
252
+ if payload is not None:
253
+ # cURL parser may produce a raw string for non-JSON -d payloads
254
+ # (example: form-encoded data). Use content= to send as-is rather
255
+ # than json= which would double-encode the string.
256
+ if isinstance(payload, str):
257
+ content = payload
258
+ else:
259
+ json_payload = payload
260
+
195
261
  start_time = time.perf_counter()
196
262
  try:
197
- async with httpx.AsyncClient(timeout=30) as client:
198
- kwargs = {"method": method, "url": full_path_url, "headers": headers}
199
- if payload is not None:
200
- # cURL parser may produce a raw string for non-JSON -d payloads
201
- # (example: form-encoded data). Use content= to send as-is rather
202
- # than json= which would double-encode the string.
203
- if isinstance(payload, str):
204
- kwargs["content"] = payload
205
- else:
206
- kwargs["json"] = payload
207
- response = await client.request(**kwargs)
263
+ async with httpx.AsyncClient(
264
+ timeout=30, follow_redirects=follow_redirects, verify=verify_tls
265
+ ) as client:
266
+ response = await client.request(
267
+ method=method,
268
+ url=full_path_url,
269
+ headers=headers,
270
+ content=content,
271
+ json=json_payload,
272
+ auth=auth,
273
+ )
208
274
  end_time = time.perf_counter()
209
275
  response_time_ms = (end_time - start_time) * 1000
210
276
  except Exception as e:
@@ -223,7 +289,7 @@ async def run_test_api(
223
289
  )
224
290
  resp_body = response.text
225
291
 
226
- assertions = []
292
+ assertions: List[Dict[str, Any]] = []
227
293
 
228
294
  if required_status_code is not None:
229
295
  passed = response.status_code == required_status_code
@@ -238,7 +304,7 @@ async def run_test_api(
238
304
 
239
305
  if required_status_code_range is not None:
240
306
  # low refers to 1xx - 2xx and high refers to 3xx - 5xx
241
- low, high = parse_status_code_range(required_status_code_range)
307
+ low, high = status_range
242
308
  passed = low <= response.status_code <= high
243
309
  assertions.append(
244
310
  {
@@ -321,7 +387,7 @@ async def run_test_api(
321
387
 
322
388
 
323
389
  # expose this function so it can be called in toml files
324
- def main():
390
+ def main() -> None:
325
391
  mcp.run()
326
392
 
327
393
 
@@ -1,7 +1,26 @@
1
1
  """Utility functions for MCP API testing"""
2
2
 
3
+ import json
4
+ import re
3
5
  from typing import Any
4
- from models import APITestResult
6
+ from .models import APITestResult
7
+
8
+ MAX_BODY_CHARS = 2000
9
+
10
+ _SENSITIVE_HEADER = re.compile(
11
+ r"(?i)((?:proxy-)?authorization|cookie|set-cookie|x-api-key|x-auth-token)"
12
+ r"(\s*:\s*)[^'\"]*"
13
+ )
14
+ _USER_FLAG = re.compile(r"(?i)(\s(?:-u|--user)[\s=])(?:'[^']*'|\"[^\"]*\"|\S+)")
15
+
16
+
17
+ def redact_curl(command: str) -> str:
18
+ """
19
+ Mask credentials (auth headers, cookies, -u user:pass) in a cURL command
20
+ so it is safe to write to the log file
21
+ """
22
+ command = _SENSITIVE_HEADER.sub(r"\1\2***", command)
23
+ return _USER_FLAG.sub(r"\1***", command)
5
24
 
6
25
 
7
26
  def render_console_template_result(result: APITestResult) -> str:
@@ -24,7 +43,7 @@ def render_console_template_result(result: APITestResult) -> str:
24
43
  lines.append(f"Method : {result.method}")
25
44
  lines.append(f"URL : {result.url}")
26
45
  lines.append(f"Status Code : {result.status_code}")
27
- lines.append(f"Response Time : {result.response_time_ms} ms")
46
+ lines.append(f"Response Time : {result.response_time_ms:.2f} ms")
28
47
  lines.append(f"Overall Result : {'Passed' if result.passed else 'Failed'}")
29
48
 
30
49
  lines.append("")
@@ -34,7 +53,12 @@ def render_console_template_result(result: APITestResult) -> str:
34
53
  if result.assertions:
35
54
  for assertion in result.assertions:
36
55
  symbol = "✓" if assertion.passed else "x"
37
- lines.append(f"{symbol} {assertion.assertion}")
56
+ line = f"{symbol} {assertion.assertion}"
57
+ if assertion.expected is not None or assertion.actual is not None:
58
+ line += (
59
+ f" (expected: {assertion.expected!r}, actual: {assertion.actual!r})"
60
+ )
61
+ lines.append(line)
38
62
 
39
63
  lines.append("")
40
64
  lines.append("Summary")
@@ -45,6 +69,19 @@ def render_console_template_result(result: APITestResult) -> str:
45
69
  else:
46
70
  lines.append(" (no assertions provided)")
47
71
 
72
+ lines.append("")
73
+ lines.append("Response Body")
74
+ lines.append(divider)
75
+ body = (
76
+ result.response_body
77
+ if isinstance(result.response_body, str)
78
+ else json.dumps(result.response_body)
79
+ )
80
+ if len(body) > MAX_BODY_CHARS:
81
+ omitted = len(body) - MAX_BODY_CHARS
82
+ body = body[:MAX_BODY_CHARS] + f"... [truncated {omitted} chars]"
83
+ lines.append(body)
84
+
48
85
  return "\n".join(lines)
49
86
 
50
87
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-api-test
3
- Version: 0.1.4
3
+ Version: 0.2.1
4
4
  Summary: MCP server for testing REST API with built-in assertions
5
5
  Author: Ryan Febriansyah
6
6
  License-Expression: MIT
@@ -15,16 +15,18 @@ 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
21
- Requires-Dist: mcp<2.0.0,>=1.0.0
22
+ Requires-Dist: mcp<3.0.0,>=2.0.0
22
23
  Requires-Dist: httpx
23
24
  Requires-Dist: python-dotenv
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: pytest; extra == "dev"
26
27
  Requires-Dist: pytest-asyncio; extra == "dev"
27
28
  Requires-Dist: respx; extra == "dev"
29
+ Requires-Dist: mypy; extra == "dev"
28
30
  Provides-Extra: docs
29
31
  Requires-Dist: mkdocs-material; extra == "docs"
30
32
  Dynamic: license-file
@@ -39,6 +41,8 @@ Unlike traditional testing frameworks, `mcp-api-test` exposes API testing as a *
39
41
 
40
42
  It complements (rather than replaces) tools like pytest, Postman, and Playwright.
41
43
 
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.
45
+
42
46
  ## Use cases
43
47
 
44
48
  - **QA & test engineering**: prompt an agent with natural language instead of crafting Postman requests
@@ -102,10 +106,10 @@ The agent invokes `run_test_api`, validates against the expected contract, and r
102
106
 
103
107
  Full documentation is hosted on this [page](https://ryaneatfood.gitlab.io/mcp-api-testing/), and another way to navigate the docs:
104
108
 
105
- - **[Getting Started](docs/getting-started/installation.md)**: installation and client setup
106
- - **[Usage](docs/usage/request-modes.md)**: cURL mode, structured mode, assertions, response format
107
- - **[Guides](docs/guides/troubleshooting.md)**: troubleshooting, development, configuration
108
- - **[Reference](docs/reference/tool-parameters.md)**: full parameter 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
109
113
 
110
114
  ## License
111
115
 
@@ -2,12 +2,14 @@ LICENSE
2
2
  MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
- src/__main__.py
6
- src/flags.py
7
- src/logger.py
8
- src/models.py
9
- src/server.py
10
- src/utils.py
5
+ src/mcp_api_test/__init__.py
6
+ src/mcp_api_test/__main__.py
7
+ src/mcp_api_test/flags.py
8
+ src/mcp_api_test/logger.py
9
+ src/mcp_api_test/models.py
10
+ src/mcp_api_test/py.typed
11
+ src/mcp_api_test/server.py
12
+ src/mcp_api_test/utils.py
11
13
  src/mcp_api_test.egg-info/PKG-INFO
12
14
  src/mcp_api_test.egg-info/SOURCES.txt
13
15
  src/mcp_api_test.egg-info/dependency_links.txt
@@ -15,7 +17,9 @@ src/mcp_api_test.egg-info/entry_points.txt
15
17
  src/mcp_api_test.egg-info/requires.txt
16
18
  src/mcp_api_test.egg-info/top_level.txt
17
19
  tests/test_mcp_api_tools.py
20
+ tests/test_mcp_server_compat.py
18
21
  tests/test_packaging.py
19
22
  tests/test_parse_curl_command.py
20
23
  tests/test_parse_status_code.py
24
+ tests/test_run_test_api_behavior.py
21
25
  tests/test_utils.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-api-test = mcp_api_test.server:main
@@ -1,4 +1,4 @@
1
- mcp<2.0.0,>=1.0.0
1
+ mcp<3.0.0,>=2.0.0
2
2
  httpx
3
3
  python-dotenv
4
4
 
@@ -6,6 +6,7 @@ python-dotenv
6
6
  pytest
7
7
  pytest-asyncio
8
8
  respx
9
+ mypy
9
10
 
10
11
  [docs]
11
12
  mkdocs-material
@@ -0,0 +1 @@
1
+ mcp_api_test
@@ -3,7 +3,7 @@
3
3
  import pytest
4
4
  import respx
5
5
  import httpx
6
- from src.server import run_test_api
6
+ from mcp_api_test.server import run_test_api
7
7
 
8
8
 
9
9
  @pytest.mark.asyncio
@@ -0,0 +1,30 @@
1
+ """Regression tests for the mcp 2.x migration (FastMCP -> MCPServer)"""
2
+
3
+ import pytest
4
+ from mcp_api_test.server import mcp
5
+
6
+
7
+ def test_server_is_built_on_mcpserver():
8
+ # regression: mcp 2.x removed mcp.server.fastmcp.FastMCP in favor of
9
+ # mcp.server.mcpserver.MCPServer; importing the old path now raises
10
+ # ModuleNotFoundError instead of silently breaking at a later call site
11
+ from mcp.server.mcpserver import MCPServer
12
+
13
+ assert isinstance(mcp, MCPServer)
14
+
15
+
16
+ def test_old_fastmcp_import_path_is_gone():
17
+ # confirms we're actually running against the mcp 2.x line pinned in
18
+ # pyproject.toml, not an mcp 1.x install that would mask this migration
19
+ with pytest.raises(ModuleNotFoundError):
20
+ import mcp.server.fastmcp # noqa: F401
21
+
22
+
23
+ def test_run_test_api_is_registered_as_a_tool():
24
+ # @mcp.tool() kept the same decorator signature across the migration;
25
+ # this would fail if MCPServer's tool manager changed shape
26
+ import asyncio
27
+
28
+ tools = asyncio.run(mcp.list_tools())
29
+ tool_names = {tool.name for tool in tools}
30
+ assert "run_test_api" in tool_names
@@ -0,0 +1,58 @@
1
+ """Static checks that the source layout matches the wheel packaging config"""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
7
+ PACKAGE_DIR = PROJECT_ROOT / "src" / "mcp_api_test"
8
+
9
+
10
+ def _pyproject() -> dict:
11
+ with open(PROJECT_ROOT / "pyproject.toml", "rb") as f:
12
+ return tomllib.load(f)
13
+
14
+
15
+ def _project_version() -> str:
16
+ return _pyproject()["project"]["version"]
17
+
18
+
19
+ def test_declared_package_matches_src_directory():
20
+ packages = _pyproject()["tool"]["setuptools"]["packages"]
21
+ assert packages == ["mcp_api_test"]
22
+ assert PACKAGE_DIR.is_dir()
23
+
24
+
25
+ def test_all_modules_exist_in_package_dir():
26
+ # regression: flat py-modules layout drifting from the package dir
27
+ expected = {"server", "utils", "models", "logger", "flags", "__init__", "__main__"}
28
+ actual = {p.stem for p in PACKAGE_DIR.glob("*.py")}
29
+ assert expected <= actual
30
+
31
+
32
+ def test_py_typed_marker_exists_and_is_packaged():
33
+ # PEP 561: inline-typed packages must ship a py.typed marker
34
+ assert (PACKAGE_DIR / "py.typed").exists()
35
+
36
+ package_data = _pyproject()["tool"]["setuptools"]["package-data"]
37
+ assert "py.typed" in package_data.get("mcp_api_test", [])
38
+
39
+
40
+ def test_entry_point_targets_the_package():
41
+ scripts = _pyproject()["project"]["scripts"]
42
+ assert scripts["mcp-api-test"] == "mcp_api_test.server:main"
43
+
44
+
45
+ def test_version_matches_pyproject():
46
+ # regression: src/mcp_api_test/__init__.py __version__ drifted from pyproject.toml
47
+ import mcp_api_test
48
+
49
+ assert mcp_api_test.__version__ == _project_version()
50
+
51
+
52
+ def test_release_tooling_is_not_packaged():
53
+ # smoke script and skill live in the repo only, never in the wheel/sdist
54
+ packages = _pyproject()["tool"]["setuptools"]["packages"]
55
+ assert packages == ["mcp_api_test"]
56
+ manifest = (PROJECT_ROOT / "MANIFEST.in").read_text()
57
+ assert "scripts" not in manifest and ".claude" not in manifest
58
+ assert not (PACKAGE_DIR / "smoke.py").exists()
@@ -1,9 +1,9 @@
1
1
  """Unit tests for the cURL command parser"""
2
2
 
3
3
  import logging
4
-
5
4
  import pytest
6
- from src.server import parse_curl_command
5
+ from mcp_api_test import server
6
+ from mcp_api_test.server import parse_curl_command
7
7
 
8
8
 
9
9
  def test_basic_curl_command():
@@ -44,8 +44,10 @@ def test_warn_flag_value_does_not_raise():
44
44
  assert result["endpoint"] == "/v1/users"
45
45
 
46
46
 
47
- def test_boolean_warn_flag_logs_warning_not_info(caplog):
48
- # regression: -k must be emitted at WARNING level, not INFO
47
+ def test_boolean_warn_flag_logs_warning_not_info(caplog, monkeypatch):
48
+ # regression: warn flags must be emitted at WARNING level, not INFO.
49
+ # -k is honoured since v0.2.1, so register a warn entry for it here.
50
+ monkeypatch.setitem(server.CURL_WARN_FLAGS, "-k", "insecure is not supported")
49
51
  with caplog.at_level(logging.WARNING):
50
52
  parse_curl_command(command="curl -k https://api.example.com/v1/users")
51
53
 
@@ -68,3 +70,56 @@ def test_non_json_payload_kept_as_raw_string():
68
70
  command="curl -d 'not json' https://api.example.com/v1/users"
69
71
  )
70
72
  assert result["request_payload"] == "not json"
73
+
74
+
75
+ # v0.2.1: cURL semantics
76
+
77
+
78
+ def test_data_without_method_defaults_to_post():
79
+ result = parse_curl_command("curl -d '{\"a\":1}' https://api.example.com/v1/users")
80
+ assert result["method"] == "POST"
81
+ assert result["request_payload"] == {"a": 1}
82
+
83
+
84
+ def test_explicit_method_wins_over_data_default():
85
+ result = parse_curl_command("curl -X PUT -d '{}' https://api.example.com/v1/users")
86
+ assert result["method"] == "PUT"
87
+
88
+
89
+ def test_get_flag_moves_data_to_query_string():
90
+ result = parse_curl_command(
91
+ "curl -G -d 'q=cat' -d 'page=2' https://api.example.com/search"
92
+ )
93
+ assert result["method"] == "GET"
94
+ assert result["request_payload"] is None
95
+ assert result["endpoint"] == "/search?q=cat&page=2"
96
+
97
+
98
+ def test_get_flag_appends_to_existing_query():
99
+ result = parse_curl_command("curl -G -d 'b=2' 'https://api.example.com/s?a=1'")
100
+ assert result["endpoint"] == "/s?a=1&b=2"
101
+
102
+
103
+ def test_head_flag_sets_head_method():
104
+ result = parse_curl_command("curl -I https://api.example.com/health")
105
+ assert result["method"] == "HEAD"
106
+
107
+
108
+ def test_multiple_data_flags_are_joined():
109
+ result = parse_curl_command("curl -d a=1 -d b=2 https://api.example.com/form")
110
+ assert result["request_payload"] == "a=1&b=2"
111
+ assert result["method"] == "POST"
112
+
113
+
114
+ def test_location_insecure_and_user_are_captured():
115
+ result = parse_curl_command("curl -L -k -u me:s3cret https://api.example.com/")
116
+ assert result["follow_redirects"] is True
117
+ assert result["verify_tls"] is False
118
+ assert result["auth"] == ("me", "s3cret")
119
+
120
+
121
+ def test_defaults_for_transport_options():
122
+ result = parse_curl_command("curl https://api.example.com/")
123
+ assert result["follow_redirects"] is False
124
+ assert result["verify_tls"] is True
125
+ assert result["auth"] is None
@@ -1,5 +1,5 @@
1
1
  import pytest
2
- from src.server import parse_status_code_range
2
+ from mcp_api_test.server import parse_status_code_range
3
3
 
4
4
 
5
5
  @pytest.mark.parametrize(
@@ -0,0 +1,266 @@
1
+ """Behaviour tests for run_test_api and its helpers: result rendering, log
2
+ redaction, structured error results, cURL transport options and payload types"""
3
+
4
+ import json
5
+ import logging
6
+ import pytest
7
+ import respx
8
+ import httpx
9
+ from mcp_api_test import server
10
+ from mcp_api_test.models import APITestResult, AssertionResult
11
+ from mcp_api_test.server import run_test_api
12
+ from mcp_api_test.utils import (
13
+ MAX_BODY_CHARS,
14
+ redact_curl,
15
+ render_console_template_result,
16
+ )
17
+
18
+
19
+ def _result(**overrides):
20
+ base = dict(
21
+ api_name="t",
22
+ passed=False,
23
+ method="GET",
24
+ url="https://api.example.com/",
25
+ status_code=500,
26
+ response_time_ms=123.456789,
27
+ assertions=[
28
+ AssertionResult("status_code", False, expected=200, actual=500),
29
+ ],
30
+ response_body={"error": "boom"},
31
+ )
32
+ base.update(overrides)
33
+ return APITestResult(**base)
34
+
35
+
36
+ # rendering
37
+
38
+
39
+ def test_render_shows_expected_and_actual():
40
+ out = render_console_template_result(_result())
41
+ assert "x status_code (expected: 200, actual: 500)" in out
42
+
43
+
44
+ def test_render_shows_response_body():
45
+ out = render_console_template_result(_result())
46
+ assert "Response Body" in out
47
+ assert '{"error": "boom"}' in out
48
+
49
+
50
+ def test_render_rounds_response_time():
51
+ out = render_console_template_result(_result())
52
+ assert "123.46 ms" in out
53
+ assert "123.456789" not in out
54
+
55
+
56
+ def test_render_truncates_large_body():
57
+ big = "x" * (MAX_BODY_CHARS + 50)
58
+ out = render_console_template_result(_result(response_body=big))
59
+ assert "[truncated 50 chars]" in out
60
+ assert big not in out
61
+
62
+
63
+ def test_render_plain_text_body_not_json_quoted():
64
+ out = render_console_template_result(_result(response_body="plain text"))
65
+ assert "\nplain text" in out
66
+
67
+
68
+ # redaction
69
+
70
+
71
+ @pytest.mark.parametrize(
72
+ "command,secret",
73
+ [
74
+ ("curl -H 'Authorization: Bearer abc123' https://x.io", "abc123"),
75
+ ('curl -H "authorization: Basic zzz" https://x.io', "zzz"),
76
+ ("curl -H 'Cookie: sid=topsecret' https://x.io", "topsecret"),
77
+ ("curl -H 'X-API-Key: k-999' https://x.io", "k-999"),
78
+ ("curl -u admin:hunter2 https://x.io", "hunter2"),
79
+ ("curl --user 'admin:hunter2' https://x.io", "hunter2"),
80
+ ],
81
+ )
82
+ def test_redact_curl_masks_secrets(command, secret):
83
+ redacted = redact_curl(command)
84
+ assert secret not in redacted
85
+ assert "***" in redacted
86
+ assert "https://x.io" in redacted
87
+
88
+
89
+ def test_redact_curl_keeps_harmless_headers():
90
+ cmd = "curl -H 'Accept: application/json' https://x.io"
91
+ assert redact_curl(cmd) == cmd
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ @respx.mock
96
+ async def test_bearer_token_not_written_to_log(caplog):
97
+ respx.get("https://api.example.com/me").mock(return_value=httpx.Response(200))
98
+ with caplog.at_level(logging.INFO):
99
+ await run_test_api(
100
+ api_name="leak-check",
101
+ curl_command="curl -H 'Authorization: Bearer SUPERSECRET' https://api.example.com/me",
102
+ )
103
+ assert "SUPERSECRET" not in caplog.text
104
+
105
+
106
+ # error handling: bad input returns a structured result instead of raising
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_bad_status_range_returns_error_result():
111
+ out = await run_test_api(
112
+ api_name="bad-range",
113
+ base_url="https://api.example.com",
114
+ endpoint="/",
115
+ required_status_code_range="abc",
116
+ )
117
+ assert "Overall Result : Failed" in out
118
+ assert "ValueError" in out
119
+
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_bad_curl_returns_error_result():
123
+ out = await run_test_api(
124
+ api_name="bad-curl", curl_command="curl --bogus https://x.io"
125
+ )
126
+ assert "Overall Result : Failed" in out
127
+ assert "Unsupported cURL option" in out
128
+
129
+
130
+ @pytest.mark.asyncio
131
+ async def test_missing_url_returns_error_result():
132
+ out = await run_test_api(api_name="no-url")
133
+ assert "Overall Result : Failed" in out
134
+ assert "required" in out
135
+
136
+
137
+ # end-to-end cURL semantics
138
+
139
+
140
+ @pytest.mark.asyncio
141
+ @respx.mock
142
+ async def test_curl_data_without_method_sends_post():
143
+ route = respx.post("https://api.example.com/v1/users").mock(
144
+ return_value=httpx.Response(201, json={"id": 1})
145
+ )
146
+ out = await run_test_api(
147
+ api_name="create",
148
+ curl_command='curl -d \'{"name":"x"}\' https://api.example.com/v1/users',
149
+ required_status_code=201,
150
+ )
151
+ assert route.called
152
+ assert "Overall Result : Passed" in out
153
+
154
+
155
+ @pytest.mark.asyncio
156
+ @respx.mock
157
+ async def test_curl_user_sends_basic_auth():
158
+ route = respx.get("https://api.example.com/me").mock(
159
+ return_value=httpx.Response(200)
160
+ )
161
+ await run_test_api(
162
+ api_name="basic", curl_command="curl -u me:pw https://api.example.com/me"
163
+ )
164
+ assert route.calls.last.request.headers["authorization"].startswith("Basic ")
165
+
166
+
167
+ @pytest.mark.asyncio
168
+ @respx.mock
169
+ async def test_curl_location_follows_redirects():
170
+ respx.get("https://api.example.com/old").mock(
171
+ return_value=httpx.Response(
172
+ 302, headers={"location": "https://api.example.com/new"}
173
+ )
174
+ )
175
+ respx.get("https://api.example.com/new").mock(return_value=httpx.Response(200))
176
+ out = await run_test_api(
177
+ api_name="redir",
178
+ curl_command="curl -L https://api.example.com/old",
179
+ required_status_code=200,
180
+ )
181
+ assert "Overall Result : Passed" in out
182
+
183
+
184
+ @pytest.mark.asyncio
185
+ @respx.mock
186
+ async def test_without_location_redirect_not_followed():
187
+ respx.get("https://api.example.com/old").mock(
188
+ return_value=httpx.Response(
189
+ 302, headers={"location": "https://api.example.com/new"}
190
+ )
191
+ )
192
+ out = await run_test_api(
193
+ api_name="no-redir",
194
+ curl_command="curl https://api.example.com/old",
195
+ required_status_code=302,
196
+ )
197
+ assert "Overall Result : Passed" in out
198
+
199
+
200
+ @pytest.mark.asyncio
201
+ @respx.mock
202
+ async def test_curl_head_flag_sends_head():
203
+ route = respx.head("https://api.example.com/health").mock(
204
+ return_value=httpx.Response(200)
205
+ )
206
+ await run_test_api(
207
+ api_name="head", curl_command="curl -I https://api.example.com/health"
208
+ )
209
+ assert route.called
210
+
211
+
212
+ @pytest.mark.asyncio
213
+ @respx.mock
214
+ async def test_curl_get_flag_sends_data_as_query():
215
+ route = respx.get("https://api.example.com/search").mock(
216
+ return_value=httpx.Response(200)
217
+ )
218
+ await run_test_api(
219
+ api_name="q", curl_command="curl -G -d q=cat https://api.example.com/search"
220
+ )
221
+ assert route.calls.last.request.url.query == b"q=cat"
222
+
223
+
224
+ @pytest.mark.asyncio
225
+ @respx.mock
226
+ async def test_curl_insecure_disables_tls_verification(monkeypatch):
227
+ seen = {}
228
+ real = httpx.AsyncClient
229
+
230
+ def spy(*args, **kwargs):
231
+ seen.update(kwargs)
232
+ return real(*args, **kwargs)
233
+
234
+ monkeypatch.setattr(server.httpx, "AsyncClient", spy)
235
+ respx.get("https://api.example.com/").mock(return_value=httpx.Response(200))
236
+ await run_test_api(api_name="k", curl_command="curl -k https://api.example.com/")
237
+ assert seen["verify"] is False
238
+
239
+
240
+ # structured mode: JSON array payloads
241
+
242
+
243
+ @pytest.mark.asyncio
244
+ @respx.mock
245
+ async def test_list_payload_is_sent_as_json_array():
246
+ route = respx.post("https://api.example.com/bulk").mock(
247
+ return_value=httpx.Response(200)
248
+ )
249
+ await run_test_api(
250
+ api_name="bulk",
251
+ base_url="https://api.example.com",
252
+ endpoint="/bulk",
253
+ method="POST",
254
+ payload=[{"id": 1}, {"id": 2}],
255
+ required_status_code=200,
256
+ )
257
+ assert json.loads(route.calls.last.request.content) == [{"id": 1}, {"id": 2}]
258
+
259
+
260
+ def test_run_test_api_schema_accepts_array_payload():
261
+ import asyncio
262
+
263
+ tools = asyncio.run(server.mcp.list_tools())
264
+ tool = next(t for t in tools if t.name == "run_test_api")
265
+ payload_schema = tool.input_schema["properties"]["payload"]
266
+ assert "array" in str(payload_schema)
@@ -1,6 +1,6 @@
1
1
  """Unit tests for URL building helpers"""
2
2
 
3
- from src.utils import build_fullpath_url
3
+ from mcp_api_test.utils import build_fullpath_url
4
4
 
5
5
 
6
6
  def test_empty_endpoint_returns_base_with_slash():
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- mcp-api-test = server:main
@@ -1,6 +0,0 @@
1
- __main__
2
- flags
3
- logger
4
- models
5
- server
6
- utils
@@ -1,46 +0,0 @@
1
- """Static checks that the source layout matches the wheel packaging config"""
2
-
3
- import tomllib
4
- from pathlib import Path
5
-
6
- PROJECT_ROOT = Path(__file__).resolve().parents[1]
7
- SRC_DIR = PROJECT_ROOT / "src"
8
-
9
-
10
- def _packaged_modules() -> list[str]:
11
- with open(PROJECT_ROOT / "pyproject.toml", "rb") as f:
12
- config = tomllib.load(f)
13
- return config["tool"]["setuptools"]["py-modules"]
14
-
15
-
16
- def _project_version() -> str:
17
- with open(PROJECT_ROOT / "pyproject.toml", "rb") as f:
18
- config = tomllib.load(f)
19
- return config["project"]["version"]
20
-
21
-
22
- def test_all_src_modules_are_packaged():
23
- src_modules = {
24
- p.stem
25
- for p in SRC_DIR.glob("*.py")
26
- if p.name != "__init__.py" and not p.name.startswith("__")
27
- }
28
- missing = src_modules - set(_packaged_modules())
29
- assert not missing, (
30
- f"Modules in src/ that are missing from pyproject.toml "
31
- f"[tool.setuptools] py-modules: {sorted(missing)}"
32
- )
33
-
34
-
35
- def test_packaged_modules_exist_in_src():
36
- for module in _packaged_modules():
37
- assert (
38
- SRC_DIR / f"{module}.py"
39
- ).exists(), f"'{module}' is listed in py-modules but has no src/{module}.py"
40
-
41
-
42
- def test_version_matches_pyproject():
43
- # regression: src/__init__.py __version__ drifted from pyproject.toml
44
- import src
45
-
46
- assert src.__version__ == _project_version()
File without changes
File without changes
File without changes