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.
- {mcp_api_test-0.1.4/src/mcp_api_test.egg-info → mcp_api_test-0.2.1}/PKG-INFO +10 -6
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/README.md +6 -4
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/pyproject.toml +19 -6
- mcp_api_test-0.2.1/src/mcp_api_test/__init__.py +3 -0
- {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/__main__.py +1 -1
- {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/flags.py +3 -8
- {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/logger.py +4 -3
- mcp_api_test-0.2.1/src/mcp_api_test/py.typed +0 -0
- {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/server.py +108 -42
- {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/utils.py +40 -3
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1/src/mcp_api_test.egg-info}/PKG-INFO +10 -6
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/src/mcp_api_test.egg-info/SOURCES.txt +10 -6
- mcp_api_test-0.2.1/src/mcp_api_test.egg-info/entry_points.txt +2 -0
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/src/mcp_api_test.egg-info/requires.txt +2 -1
- mcp_api_test-0.2.1/src/mcp_api_test.egg-info/top_level.txt +1 -0
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/tests/test_mcp_api_tools.py +1 -1
- mcp_api_test-0.2.1/tests/test_mcp_server_compat.py +30 -0
- mcp_api_test-0.2.1/tests/test_packaging.py +58 -0
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/tests/test_parse_curl_command.py +59 -4
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/tests/test_parse_status_code.py +1 -1
- mcp_api_test-0.2.1/tests/test_run_test_api_behavior.py +266 -0
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/tests/test_utils.py +1 -1
- mcp_api_test-0.1.4/src/mcp_api_test.egg-info/entry_points.txt +0 -2
- mcp_api_test-0.1.4/src/mcp_api_test.egg-info/top_level.txt +0 -6
- mcp_api_test-0.1.4/tests/test_packaging.py +0 -46
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/LICENSE +0 -0
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/MANIFEST.in +0 -0
- {mcp_api_test-0.1.4 → mcp_api_test-0.2.1}/setup.cfg +0 -0
- {mcp_api_test-0.1.4/src → mcp_api_test-0.2.1/src/mcp_api_test}/models.py +0 -0
- {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
|
|
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<
|
|
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](
|
|
106
|
-
- **[Usage](
|
|
107
|
-
- **[Guides](
|
|
108
|
-
- **[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](
|
|
75
|
-
- **[Usage](
|
|
76
|
-
- **[Guides](
|
|
77
|
-
- **[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
|
|
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>=
|
|
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
|
-
|
|
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
|
|
@@ -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:
|
|
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.
|
|
10
|
-
from
|
|
11
|
-
from
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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<
|
|
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](
|
|
106
|
-
- **[Usage](
|
|
107
|
-
- **[Guides](
|
|
108
|
-
- **[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/
|
|
6
|
-
src/
|
|
7
|
-
src/
|
|
8
|
-
src/
|
|
9
|
-
src/
|
|
10
|
-
src/
|
|
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 @@
|
|
|
1
|
+
mcp_api_test
|
|
@@ -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
|
|
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:
|
|
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
|
|
@@ -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,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
|
|
File without changes
|
|
File without changes
|