mcp-api-test 0.1.3__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mcp_api_test-0.1.3/src/mcp_api_test.egg-info → mcp_api_test-0.2.0}/PKG-INFO +7 -4
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/README.md +4 -2
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/pyproject.toml +18 -6
- mcp_api_test-0.2.0/src/mcp_api_test/__init__.py +3 -0
- {mcp_api_test-0.1.3/src → mcp_api_test-0.2.0/src/mcp_api_test}/__main__.py +1 -1
- mcp_api_test-0.2.0/src/mcp_api_test/flags.py +61 -0
- {mcp_api_test-0.1.3/src → mcp_api_test-0.2.0/src/mcp_api_test}/logger.py +11 -2
- mcp_api_test-0.2.0/src/mcp_api_test/py.typed +0 -0
- {mcp_api_test-0.1.3/src → mcp_api_test-0.2.0/src/mcp_api_test}/server.py +32 -22
- {mcp_api_test-0.1.3/src → mcp_api_test-0.2.0/src/mcp_api_test}/utils.py +2 -2
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0/src/mcp_api_test.egg-info}/PKG-INFO +7 -4
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/src/mcp_api_test.egg-info/SOURCES.txt +12 -6
- mcp_api_test-0.2.0/src/mcp_api_test.egg-info/entry_points.txt +2 -0
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/src/mcp_api_test.egg-info/requires.txt +2 -1
- mcp_api_test-0.2.0/src/mcp_api_test.egg-info/top_level.txt +1 -0
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/tests/test_mcp_api_tools.py +1 -1
- mcp_api_test-0.2.0/tests/test_mcp_server_compat.py +30 -0
- mcp_api_test-0.2.0/tests/test_packaging.py +49 -0
- mcp_api_test-0.2.0/tests/test_parse_curl_command.py +69 -0
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/tests/test_parse_status_code.py +1 -1
- mcp_api_test-0.2.0/tests/test_utils.py +23 -0
- mcp_api_test-0.1.3/src/mcp_api_test.egg-info/entry_points.txt +0 -2
- mcp_api_test-0.1.3/src/mcp_api_test.egg-info/top_level.txt +0 -5
- mcp_api_test-0.1.3/tests/test_parse_curl_command.py +0 -23
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/LICENSE +0 -0
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/MANIFEST.in +0 -0
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/setup.cfg +0 -0
- {mcp_api_test-0.1.3/src → mcp_api_test-0.2.0/src/mcp_api_test}/models.py +0 -0
- {mcp_api_test-0.1.3 → mcp_api_test-0.2.0}/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.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: MCP server for testing REST API with built-in assertions
|
|
5
5
|
Author: Ryan Febriansyah
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,20 +18,21 @@ Classifier: Topic :: Software Development :: Testing
|
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: mcp
|
|
21
|
+
Requires-Dist: mcp<3.0.0,>=2.0.0
|
|
22
22
|
Requires-Dist: httpx
|
|
23
23
|
Requires-Dist: python-dotenv
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: pytest; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
27
27
|
Requires-Dist: respx; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy; extra == "dev"
|
|
28
29
|
Provides-Extra: docs
|
|
29
30
|
Requires-Dist: mkdocs-material; extra == "docs"
|
|
30
31
|
Dynamic: license-file
|
|
31
32
|
|
|
32
33
|
# mcp-api-test
|
|
33
34
|
|
|
34
|
-

|
|
35
|
+
  
|
|
35
36
|
|
|
36
37
|
An AI-native API testing engine that enables LLM agents to execute, validate, and reason about REST APIs using natural language or cURL inputs.
|
|
37
38
|
|
|
@@ -39,6 +40,8 @@ Unlike traditional testing frameworks, `mcp-api-test` exposes API testing as a *
|
|
|
39
40
|
|
|
40
41
|
It complements (rather than replaces) tools like pytest, Postman, and Playwright.
|
|
41
42
|
|
|
43
|
+
> **Breaking changes in v0.2.0.** The server now runs on `mcp` 2.x, and the package layout moved from flat top-level modules to a proper `mcp_api_test` package. If you import internals directly, you may update to `from mcp_api_test.server import ...`. MCP client configuration (OpenCode, Claude Desktop, Claude Code) is unaffected. See the [changelog](docs/changelog.md) for details.
|
|
44
|
+
|
|
42
45
|
## Use cases
|
|
43
46
|
|
|
44
47
|
- **QA & test engineering**: prompt an agent with natural language instead of crafting Postman requests
|
|
@@ -100,7 +103,7 @@ The agent invokes `run_test_api`, validates against the expected contract, and r
|
|
|
100
103
|
|
|
101
104
|
## Documentation
|
|
102
105
|
|
|
103
|
-
Full documentation is hosted on [
|
|
106
|
+
Full documentation is hosted on this [page](https://ryaneatfood.gitlab.io/mcp-api-testing/), and another way to navigate the docs:
|
|
104
107
|
|
|
105
108
|
- **[Getting Started](docs/getting-started/installation.md)**: installation and client setup
|
|
106
109
|
- **[Usage](docs/usage/request-modes.md)**: cURL mode, structured mode, assertions, response format
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# mcp-api-test
|
|
2
2
|
|
|
3
|
-

|
|
3
|
+
  
|
|
4
4
|
|
|
5
5
|
An AI-native API testing engine that enables LLM agents to execute, validate, and reason about REST APIs using natural language or cURL inputs.
|
|
6
6
|
|
|
@@ -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](docs/changelog.md) 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
|
|
@@ -69,7 +71,7 @@ The agent invokes `run_test_api`, validates against the expected contract, and r
|
|
|
69
71
|
|
|
70
72
|
## Documentation
|
|
71
73
|
|
|
72
|
-
Full documentation is hosted on [
|
|
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
76
|
- **[Getting Started](docs/getting-started/installation.md)**: installation and client setup
|
|
75
77
|
- **[Usage](docs/usage/request-modes.md)**: cURL mode, structured mode, assertions, response format
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mcp-api-test"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
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
|
]
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
]
|
|
32
32
|
|
|
33
33
|
[project.scripts]
|
|
34
|
-
mcp-api-test = "server:main"
|
|
34
|
+
mcp-api-test = "mcp_api_test.server:main"
|
|
35
35
|
|
|
36
36
|
[build-system]
|
|
37
37
|
requires = ["setuptools>=61"]
|
|
@@ -39,8 +39,20 @@ build-backend = "setuptools.build_meta"
|
|
|
39
39
|
|
|
40
40
|
[tool.setuptools]
|
|
41
41
|
package-dir = {"" = "src"}
|
|
42
|
-
|
|
42
|
+
packages = ["mcp_api_test"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
mcp_api_test = ["py.typed"]
|
|
43
46
|
|
|
44
47
|
[project.optional-dependencies]
|
|
45
|
-
dev = ["pytest", "pytest-asyncio", "respx"]
|
|
46
|
-
docs = ["mkdocs-material"]
|
|
48
|
+
dev = ["pytest", "pytest-asyncio", "respx", "mypy"]
|
|
49
|
+
docs = ["mkdocs-material"]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
python_version = "3.10"
|
|
53
|
+
packages = ["mcp_api_test"]
|
|
54
|
+
check_untyped_defs = true
|
|
55
|
+
disallow_untyped_defs = true
|
|
56
|
+
no_implicit_optional = true
|
|
57
|
+
warn_redundant_casts = true
|
|
58
|
+
warn_unused_ignores = true
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""A collection of curl flag which is being used during HTTP request/inspection"""
|
|
2
|
+
|
|
3
|
+
# flags that consume the next token as their value,
|
|
4
|
+
# maps every recognize consuming flag to its semantic roles
|
|
5
|
+
CURL_VALUE_FLAGS: dict[str, str] = {
|
|
6
|
+
"-X": "method",
|
|
7
|
+
"--request": "method",
|
|
8
|
+
"-H": "header",
|
|
9
|
+
"--header": "header",
|
|
10
|
+
"-d": "data",
|
|
11
|
+
"--data": "data",
|
|
12
|
+
"--data-raw": "data", # browser DevTools "Copy as cURL" uses this
|
|
13
|
+
"--data-binary": "data", # common in API clients
|
|
14
|
+
"--data-urlencode": "data", # URL-encoded payloads
|
|
15
|
+
"-u": "user",
|
|
16
|
+
"--user": "user",
|
|
17
|
+
"--url": "url_flag", # explicit --url form
|
|
18
|
+
"-A": "user_agent",
|
|
19
|
+
"--user-agent": "user_agent",
|
|
20
|
+
"--max-time": "ignored",
|
|
21
|
+
"--connect-timeout": "ignored",
|
|
22
|
+
"-o": "ignored",
|
|
23
|
+
"--output": "ignored",
|
|
24
|
+
"--proxy": "ignored",
|
|
25
|
+
"-x": "ignored",
|
|
26
|
+
"-e": "ignored",
|
|
27
|
+
"--referer": "ignored",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# standalone flags with no following value
|
|
31
|
+
CURL_BOOLEAN_FLAGS: set[str] = {
|
|
32
|
+
"-L",
|
|
33
|
+
"--location", # follow redirects, very common in browser exports
|
|
34
|
+
"-k",
|
|
35
|
+
"--insecure", # skip TLS verification
|
|
36
|
+
"-s",
|
|
37
|
+
"--silent",
|
|
38
|
+
"-v",
|
|
39
|
+
"--verbose",
|
|
40
|
+
"-i",
|
|
41
|
+
"--include",
|
|
42
|
+
"-I",
|
|
43
|
+
"--head",
|
|
44
|
+
"--compressed", # added by Chrome DevTools
|
|
45
|
+
"--http1.1",
|
|
46
|
+
"--http2",
|
|
47
|
+
"-G",
|
|
48
|
+
"--get",
|
|
49
|
+
"--no-keepalive",
|
|
50
|
+
"--fail",
|
|
51
|
+
"-f",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# flags that we recognize but want to surface with a warning for,
|
|
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
|
+
}
|
|
@@ -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,15 @@ class Logger:
|
|
|
39
40
|
message = message % args
|
|
40
41
|
self.logger.info(message, **kwargs)
|
|
41
42
|
|
|
42
|
-
def
|
|
43
|
+
def warning(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Log a warning message with particular arguments
|
|
46
|
+
"""
|
|
47
|
+
if args:
|
|
48
|
+
message = message % args
|
|
49
|
+
self.logger.warning(message, **kwargs)
|
|
50
|
+
|
|
51
|
+
def error(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
43
52
|
"""
|
|
44
53
|
Log an error message with particular arguments
|
|
45
54
|
"""
|
|
File without changes
|
|
@@ -2,26 +2,26 @@ 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
7
|
from typing import Optional, Dict, Any, List
|
|
9
|
-
from mcp.server.
|
|
10
|
-
from
|
|
11
|
-
from
|
|
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
15
|
get_nested_fields,
|
|
16
16
|
)
|
|
17
|
-
from flags import CURL_BOOLEAN_FLAGS, CURL_VALUE_FLAGS, CURL_WARN_FLAGS
|
|
17
|
+
from .flags import CURL_BOOLEAN_FLAGS, CURL_VALUE_FLAGS, CURL_WARN_FLAGS
|
|
18
18
|
|
|
19
19
|
load_dotenv()
|
|
20
20
|
|
|
21
21
|
logger = get_logger(name="mcp-api-testing")
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
mcp =
|
|
24
|
+
mcp = MCPServer("mcp-api-test")
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def parse_status_code_range(range_str: str) -> tuple[int, int]:
|
|
@@ -80,7 +80,7 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
|
|
|
80
80
|
|
|
81
81
|
# emit warning for supported but warnin changing flags
|
|
82
82
|
if token in CURL_WARN_FLAGS:
|
|
83
|
-
logger.warning(message=f"[curl parser] {CURL_WARN_FLAGS[token]}
|
|
83
|
+
logger.warning(message=f"[curl parser] {CURL_WARN_FLAGS[token]}")
|
|
84
84
|
|
|
85
85
|
if role == "method":
|
|
86
86
|
method = value
|
|
@@ -96,11 +96,13 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
|
|
|
96
96
|
payload = json.loads(raw_data)
|
|
97
97
|
except json.JSONDecodeError:
|
|
98
98
|
logger.error(
|
|
99
|
-
f"[curl parser] payload for '{token} is not valid JSON"
|
|
99
|
+
f"[curl parser] payload for '{token}' is not valid JSON"
|
|
100
100
|
)
|
|
101
101
|
payload = raw_data
|
|
102
102
|
elif role == "url_flag":
|
|
103
103
|
url = value
|
|
104
|
+
elif role == "user_agent":
|
|
105
|
+
headers["User-Agent"] = value
|
|
104
106
|
|
|
105
107
|
# everything else is consumed silently
|
|
106
108
|
index += 2
|
|
@@ -109,7 +111,7 @@ def parse_curl_command(command: str) -> Dict[str, Any]:
|
|
|
109
111
|
# boolean standalone flags
|
|
110
112
|
if token in CURL_BOOLEAN_FLAGS:
|
|
111
113
|
if token in CURL_WARN_FLAGS:
|
|
112
|
-
logger.
|
|
114
|
+
logger.warning(message=f"[curl parser] {CURL_WARN_FLAGS[token]}")
|
|
113
115
|
index += 1
|
|
114
116
|
continue
|
|
115
117
|
|
|
@@ -160,7 +162,7 @@ async def run_test_api(
|
|
|
160
162
|
required_status_code_range: Optional[str] = None,
|
|
161
163
|
required_response_fields: Optional[List[str]] = None,
|
|
162
164
|
required_response_contains: Optional[str] = None,
|
|
163
|
-
):
|
|
165
|
+
) -> str:
|
|
164
166
|
"""
|
|
165
167
|
Test a given API services, it will supports either:
|
|
166
168
|
|
|
@@ -190,19 +192,27 @@ async def run_test_api(
|
|
|
190
192
|
full_path_url = build_fullpath_url(base_url=base_url, endpoint=endpoint)
|
|
191
193
|
method = method.upper()
|
|
192
194
|
|
|
195
|
+
content: Optional[str] = None
|
|
196
|
+
json_payload: Optional[Dict[str, Any]] = None
|
|
197
|
+
if payload is not None:
|
|
198
|
+
# cURL parser may produce a raw string for non-JSON -d payloads
|
|
199
|
+
# (example: form-encoded data). Use content= to send as-is rather
|
|
200
|
+
# than json= which would double-encode the string.
|
|
201
|
+
if isinstance(payload, str):
|
|
202
|
+
content = payload
|
|
203
|
+
else:
|
|
204
|
+
json_payload = payload
|
|
205
|
+
|
|
193
206
|
start_time = time.perf_counter()
|
|
194
207
|
try:
|
|
195
208
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
else:
|
|
204
|
-
kwargs["json"] = payload
|
|
205
|
-
response = await client.request(**kwargs)
|
|
209
|
+
response = await client.request(
|
|
210
|
+
method=method,
|
|
211
|
+
url=full_path_url,
|
|
212
|
+
headers=headers,
|
|
213
|
+
content=content,
|
|
214
|
+
json=json_payload,
|
|
215
|
+
)
|
|
206
216
|
end_time = time.perf_counter()
|
|
207
217
|
response_time_ms = (end_time - start_time) * 1000
|
|
208
218
|
except Exception as e:
|
|
@@ -221,7 +231,7 @@ async def run_test_api(
|
|
|
221
231
|
)
|
|
222
232
|
resp_body = response.text
|
|
223
233
|
|
|
224
|
-
assertions = []
|
|
234
|
+
assertions: List[Dict[str, Any]] = []
|
|
225
235
|
|
|
226
236
|
if required_status_code is not None:
|
|
227
237
|
passed = response.status_code == required_status_code
|
|
@@ -319,7 +329,7 @@ async def run_test_api(
|
|
|
319
329
|
|
|
320
330
|
|
|
321
331
|
# expose this function so it can be called in toml files
|
|
322
|
-
def main():
|
|
332
|
+
def main() -> None:
|
|
323
333
|
mcp.run()
|
|
324
334
|
|
|
325
335
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Utility functions for MCP API testing"""
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
|
-
from models import APITestResult
|
|
4
|
+
from .models import APITestResult
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def render_console_template_result(result: APITestResult) -> str:
|
|
@@ -108,7 +108,7 @@ def build_fullpath_url(base_url: str, endpoint: str) -> str:
|
|
|
108
108
|
_endpoint = endpoint.strip()
|
|
109
109
|
|
|
110
110
|
if not _endpoint:
|
|
111
|
-
|
|
111
|
+
_endpoint = "/"
|
|
112
112
|
|
|
113
113
|
if not _endpoint.startswith("/"):
|
|
114
114
|
# automaticaly prepend the '/' before raise an assertion
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-api-test
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: MCP server for testing REST API with built-in assertions
|
|
5
5
|
Author: Ryan Febriansyah
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,20 +18,21 @@ Classifier: Topic :: Software Development :: Testing
|
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: mcp
|
|
21
|
+
Requires-Dist: mcp<3.0.0,>=2.0.0
|
|
22
22
|
Requires-Dist: httpx
|
|
23
23
|
Requires-Dist: python-dotenv
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: pytest; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
27
27
|
Requires-Dist: respx; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy; extra == "dev"
|
|
28
29
|
Provides-Extra: docs
|
|
29
30
|
Requires-Dist: mkdocs-material; extra == "docs"
|
|
30
31
|
Dynamic: license-file
|
|
31
32
|
|
|
32
33
|
# mcp-api-test
|
|
33
34
|
|
|
34
|
-

|
|
35
|
+
  
|
|
35
36
|
|
|
36
37
|
An AI-native API testing engine that enables LLM agents to execute, validate, and reason about REST APIs using natural language or cURL inputs.
|
|
37
38
|
|
|
@@ -39,6 +40,8 @@ Unlike traditional testing frameworks, `mcp-api-test` exposes API testing as a *
|
|
|
39
40
|
|
|
40
41
|
It complements (rather than replaces) tools like pytest, Postman, and Playwright.
|
|
41
42
|
|
|
43
|
+
> **Breaking changes in v0.2.0.** The server now runs on `mcp` 2.x, and the package layout moved from flat top-level modules to a proper `mcp_api_test` package. If you import internals directly, you may update to `from mcp_api_test.server import ...`. MCP client configuration (OpenCode, Claude Desktop, Claude Code) is unaffected. See the [changelog](docs/changelog.md) for details.
|
|
44
|
+
|
|
42
45
|
## Use cases
|
|
43
46
|
|
|
44
47
|
- **QA & test engineering**: prompt an agent with natural language instead of crafting Postman requests
|
|
@@ -100,7 +103,7 @@ The agent invokes `run_test_api`, validates against the expected contract, and r
|
|
|
100
103
|
|
|
101
104
|
## Documentation
|
|
102
105
|
|
|
103
|
-
Full documentation is hosted on [
|
|
106
|
+
Full documentation is hosted on this [page](https://ryaneatfood.gitlab.io/mcp-api-testing/), and another way to navigate the docs:
|
|
104
107
|
|
|
105
108
|
- **[Getting Started](docs/getting-started/installation.md)**: installation and client setup
|
|
106
109
|
- **[Usage](docs/usage/request-modes.md)**: cURL mode, structured mode, assertions, response format
|
|
@@ -2,11 +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/
|
|
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
|
|
10
13
|
src/mcp_api_test.egg-info/PKG-INFO
|
|
11
14
|
src/mcp_api_test.egg-info/SOURCES.txt
|
|
12
15
|
src/mcp_api_test.egg-info/dependency_links.txt
|
|
@@ -14,5 +17,8 @@ src/mcp_api_test.egg-info/entry_points.txt
|
|
|
14
17
|
src/mcp_api_test.egg-info/requires.txt
|
|
15
18
|
src/mcp_api_test.egg-info/top_level.txt
|
|
16
19
|
tests/test_mcp_api_tools.py
|
|
20
|
+
tests/test_mcp_server_compat.py
|
|
21
|
+
tests/test_packaging.py
|
|
17
22
|
tests/test_parse_curl_command.py
|
|
18
|
-
tests/test_parse_status_code.py
|
|
23
|
+
tests/test_parse_status_code.py
|
|
24
|
+
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,49 @@
|
|
|
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()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Unit tests for the cURL command parser"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pytest
|
|
5
|
+
from mcp_api_test.server import parse_curl_command
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_basic_curl_command():
|
|
9
|
+
# no network needed, just pure logic
|
|
10
|
+
result = parse_curl_command(command="curl -X POST https://api.example.com/v1/users")
|
|
11
|
+
assert result["method"] == "POST"
|
|
12
|
+
assert result["base_url"] == "https://api.example.com"
|
|
13
|
+
assert result["endpoint"] == "/v1/users"
|
|
14
|
+
assert result["headers"] == {}
|
|
15
|
+
assert result["request_payload"] is None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_missing_url_raises():
|
|
19
|
+
with pytest.raises(ValueError, match="URL must be provided"):
|
|
20
|
+
parse_curl_command("curl -X GET -H 'content-type: application/json'")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_missing_value_raises():
|
|
24
|
+
# -X at the end with no specific HTTP method
|
|
25
|
+
with pytest.raises((ValueError, IndexError)):
|
|
26
|
+
parse_curl_command("curl https://api.example.com -X")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_warn_flag_boolean_does_not_raise():
|
|
30
|
+
# -k is a boolean warn flag: it must not raise even though
|
|
31
|
+
# the parser emits a warning via logger.warning()
|
|
32
|
+
result = parse_curl_command(command="curl -k https://api.example.com/v1/users")
|
|
33
|
+
assert result["method"] == "GET"
|
|
34
|
+
assert result["endpoint"] == "/v1/users"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_warn_flag_value_does_not_raise():
|
|
38
|
+
# -u consumes the next token as its value and emits a warning
|
|
39
|
+
result = parse_curl_command(
|
|
40
|
+
command="curl -u user:pass https://api.example.com/v1/users"
|
|
41
|
+
)
|
|
42
|
+
assert result["method"] == "GET"
|
|
43
|
+
assert result["endpoint"] == "/v1/users"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_boolean_warn_flag_logs_warning_not_info(caplog):
|
|
47
|
+
# regression: -k must be emitted at WARNING level, not INFO
|
|
48
|
+
with caplog.at_level(logging.WARNING):
|
|
49
|
+
parse_curl_command(command="curl -k https://api.example.com/v1/users")
|
|
50
|
+
|
|
51
|
+
warn_records = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
52
|
+
assert warn_records, "expected at least one WARNING record"
|
|
53
|
+
assert any("insecure" in r.getMessage() for r in warn_records)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_user_agent_flag_sets_header():
|
|
57
|
+
# regression: -A was parsed but silently dropped
|
|
58
|
+
result = parse_curl_command(
|
|
59
|
+
command='curl -A "Mozilla/5.0" https://api.example.com/v1/users'
|
|
60
|
+
)
|
|
61
|
+
assert result["headers"].get("User-Agent") == "Mozilla/5.0"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_non_json_payload_kept_as_raw_string():
|
|
65
|
+
# regression: malformed JSON body must be preserved as-is, not crash
|
|
66
|
+
result = parse_curl_command(
|
|
67
|
+
command="curl -d 'not json' https://api.example.com/v1/users"
|
|
68
|
+
)
|
|
69
|
+
assert result["request_payload"] == "not json"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Unit tests for URL building helpers"""
|
|
2
|
+
|
|
3
|
+
from mcp_api_test.utils import build_fullpath_url
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_empty_endpoint_returns_base_with_slash():
|
|
7
|
+
# regression: build_fullpath_url("https://api.example.com", "")
|
|
8
|
+
# must yield "https://api.example.com/" (dead assignment bug)
|
|
9
|
+
assert build_fullpath_url("https://api.example.com", "") == (
|
|
10
|
+
"https://api.example.com/"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_bare_endpoint_gets_leading_slash():
|
|
15
|
+
assert build_fullpath_url("https://api.example.com", "v1/users") == (
|
|
16
|
+
"https://api.example.com/v1/users"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_fullpath_does_not_double_slash():
|
|
21
|
+
assert build_fullpath_url("https://api.example.com/", "/v1/users") == (
|
|
22
|
+
"https://api.example.com/v1/users"
|
|
23
|
+
)
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from src.server import parse_curl_command
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def test_basic_curl_command():
|
|
6
|
-
# no network needed, just pure logic
|
|
7
|
-
result = parse_curl_command(command="curl -X POST https://api.example.com/v1/users")
|
|
8
|
-
assert result["method"] == "POST"
|
|
9
|
-
assert result["base_url"] == "https://api.example.com"
|
|
10
|
-
assert result["endpoint"] == "/v1/users"
|
|
11
|
-
assert result["headers"] == {}
|
|
12
|
-
assert result["request_payload"] is None
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_missing_url_raises():
|
|
16
|
-
with pytest.raises(ValueError, match="URL must be provided"):
|
|
17
|
-
parse_curl_command("curl -X GET -H 'content-type: application/json'")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_missing_value_raises():
|
|
21
|
-
# -X at the end with no specific HTTP method
|
|
22
|
-
with pytest.raises((ValueError, IndexError)):
|
|
23
|
-
parse_curl_command("curl https://api.example.com -X")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|