pyhttp-app 2.0.1__py3-none-any.whl
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.
- pyhttp/__init__.py +9 -0
- pyhttp/__main__.py +222 -0
- pyhttp/app.py +1333 -0
- pyhttp/assertions.py +289 -0
- pyhttp/cli.py +364 -0
- pyhttp/console.py +82 -0
- pyhttp/curl.py +151 -0
- pyhttp/db.py +1483 -0
- pyhttp/jsengine.py +224 -0
- pyhttp/jsonc.py +54 -0
- pyhttp/mcp.py +316 -0
- pyhttp/mock.py +204 -0
- pyhttp/monitors.py +116 -0
- pyhttp/oauth2.py +153 -0
- pyhttp/openapi.py +365 -0
- pyhttp/pm.js +496 -0
- pyhttp/postman.py +412 -0
- pyhttp/runner.py +1089 -0
- pyhttp/scripts.py +352 -0
- pyhttp/static/app.js +2739 -0
- pyhttp/static/codegen.js +134 -0
- pyhttp/static/editor.js +421 -0
- pyhttp/static/features.css +630 -0
- pyhttp/static/features.js +2396 -0
- pyhttp/static/index.html +372 -0
- pyhttp/static/logo.svg +13 -0
- pyhttp/static/style.css +585 -0
- pyhttp/static/vendor/codemirror.js +28 -0
- pyhttp/static/vendor/fonts/Inter-OFL.txt +93 -0
- pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
- pyhttp/static/viewer.js +434 -0
- pyhttp/templating.py +155 -0
- pyhttp/updater.py +387 -0
- pyhttp_app-2.0.1.dist-info/METADATA +654 -0
- pyhttp_app-2.0.1.dist-info/RECORD +40 -0
- pyhttp_app-2.0.1.dist-info/WHEEL +5 -0
- pyhttp_app-2.0.1.dist-info/entry_points.txt +2 -0
- pyhttp_app-2.0.1.dist-info/licenses/LICENSE.md +121 -0
- pyhttp_app-2.0.1.dist-info/top_level.txt +1 -0
pyhttp/jsengine.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""JavaScript scripts (Postman-compatible `pm` API) in an embedded engine.
|
|
2
|
+
|
|
3
|
+
Engines, first one found wins:
|
|
4
|
+
- quickjs (python-quickjs, ~1 MB; used in the packaged builds)
|
|
5
|
+
- mini_racer (V8, ~15 MB; wheels for every Python version, handy for development)
|
|
6
|
+
|
|
7
|
+
The engine gets a JSON state (request, response, variables), the `pm` runtime
|
|
8
|
+
from pm.js, then the user's script. Results come back as JSON: tests, logs,
|
|
9
|
+
variable changes, the edited request (pre-request scripts) and any error.
|
|
10
|
+
No host callbacks are needed, so both engines work the same way.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
SCRIPT_TIMEOUT = 15.0
|
|
22
|
+
MEMORY_LIMIT = 256 * 1024 * 1024
|
|
23
|
+
_PRELUDE_PATH = Path(__file__).with_name("pm.js")
|
|
24
|
+
_prelude_cache: str | None = None
|
|
25
|
+
_USER_LINE_RE = re.compile(r"user\.js:(\d+)")
|
|
26
|
+
_ANY_LINE_RE = re.compile(r"(?:<input>|<anonymous>|<eval>|user\.js):(\d+)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EngineMissing(RuntimeError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _prelude() -> str:
|
|
34
|
+
global _prelude_cache
|
|
35
|
+
if _prelude_cache is None:
|
|
36
|
+
_prelude_cache = _PRELUDE_PATH.read_text(encoding="utf-8")
|
|
37
|
+
return _prelude_cache
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _js_json(value: Any) -> str:
|
|
41
|
+
# JSON is valid JS except for the two line separators
|
|
42
|
+
return json.dumps(value).replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ engines
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _QuickJS:
|
|
49
|
+
name = "quickjs"
|
|
50
|
+
|
|
51
|
+
def __init__(self, timeout: float) -> None:
|
|
52
|
+
import quickjs # type: ignore
|
|
53
|
+
|
|
54
|
+
self._mod = quickjs
|
|
55
|
+
self.ctx = quickjs.Context()
|
|
56
|
+
self.ctx.set_time_limit(timeout)
|
|
57
|
+
self.ctx.set_memory_limit(MEMORY_LIMIT)
|
|
58
|
+
self.ctx.set_max_stack_size(4 * 1024 * 1024)
|
|
59
|
+
|
|
60
|
+
def eval(self, code: str) -> Any:
|
|
61
|
+
try:
|
|
62
|
+
return self.ctx.eval(code)
|
|
63
|
+
except self._mod.JSException as exc: # pragma: no cover - engine specific
|
|
64
|
+
raise JSError(str(exc)) from exc
|
|
65
|
+
|
|
66
|
+
def run_jobs(self) -> None:
|
|
67
|
+
for _ in range(1000):
|
|
68
|
+
try:
|
|
69
|
+
if not self.ctx.execute_pending_job():
|
|
70
|
+
break
|
|
71
|
+
except Exception as exc: # noqa: BLE001
|
|
72
|
+
raise JSError(str(exc)) from exc
|
|
73
|
+
|
|
74
|
+
def is_timeout(self, exc: Exception) -> bool:
|
|
75
|
+
return "interrupted" in str(exc).lower() or "time limit" in str(exc).lower()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _MiniRacer:
|
|
79
|
+
name = "v8"
|
|
80
|
+
|
|
81
|
+
def __init__(self, timeout: float) -> None:
|
|
82
|
+
import py_mini_racer # type: ignore
|
|
83
|
+
|
|
84
|
+
self._mod = py_mini_racer
|
|
85
|
+
self.ctx = py_mini_racer.MiniRacer()
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
try:
|
|
88
|
+
self.ctx.set_hard_memory_limit(MEMORY_LIMIT)
|
|
89
|
+
except Exception: # noqa: BLE001 - older versions
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
def eval(self, code: str) -> Any:
|
|
93
|
+
try:
|
|
94
|
+
return self.ctx.eval(code, timeout_sec=self.timeout)
|
|
95
|
+
except self._mod.JSTimeoutException as exc:
|
|
96
|
+
raise JSTimeout(str(exc)) from exc
|
|
97
|
+
except self._mod.JSEvalException as exc:
|
|
98
|
+
raise JSError(str(exc)) from exc
|
|
99
|
+
except Exception as exc: # noqa: BLE001 - JSParseException etc.
|
|
100
|
+
raise JSError(str(exc)) from exc
|
|
101
|
+
|
|
102
|
+
def run_jobs(self) -> None: # V8 runs microtasks at the end of each eval
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def is_timeout(self, exc: Exception) -> bool:
|
|
106
|
+
return isinstance(exc, JSTimeout)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class JSError(Exception):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class JSTimeout(JSError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _engine(timeout: float) -> _QuickJS | _MiniRacer:
|
|
118
|
+
errors = []
|
|
119
|
+
for cls in (_QuickJS, _MiniRacer):
|
|
120
|
+
try:
|
|
121
|
+
return cls(timeout)
|
|
122
|
+
except ImportError as exc:
|
|
123
|
+
errors.append(str(exc))
|
|
124
|
+
raise EngineMissing("No JavaScript engine installed. Run: pip install quickjs (or: pip install mini-racer). "
|
|
125
|
+
"Python scripts work without one.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def engine_name() -> str | None:
|
|
129
|
+
try:
|
|
130
|
+
return _engine(1.0).name
|
|
131
|
+
except EngineMissing:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------ run
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _clean_error(text: str, offset: int) -> tuple[str, int | None]:
|
|
139
|
+
"""Strip engine noise from an error string and translate the line number back to the script."""
|
|
140
|
+
text = text.strip()
|
|
141
|
+
line = None
|
|
142
|
+
# V8 names the user's eval "user.js" (sourceURL). Otherwise the outermost frame
|
|
143
|
+
# (last in the stack) is the user's script: frames above it belong to pm.js.
|
|
144
|
+
found = _USER_LINE_RE.findall(text) or _ANY_LINE_RE.findall(text)
|
|
145
|
+
if found:
|
|
146
|
+
n = int(found[0] if _USER_LINE_RE.search(text) else found[-1]) - offset
|
|
147
|
+
if n >= 1:
|
|
148
|
+
line = n
|
|
149
|
+
# first line of the stack is the message; keep "at ..." frames out of the note
|
|
150
|
+
first = text.splitlines()[0] if text else "error"
|
|
151
|
+
first = re.sub(r"^<anonymous>:\d+:\s*", "", first)
|
|
152
|
+
first = re.sub(r"^\s*at\s.*$", "", first).strip() or "error"
|
|
153
|
+
if line:
|
|
154
|
+
first += f" (line {line})"
|
|
155
|
+
return first, line
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run(code: str, state: dict[str, Any], *, event: str, timeout: float = SCRIPT_TIMEOUT) -> dict[str, Any]:
|
|
159
|
+
"""Run a user script. Never raises for script problems; returns
|
|
160
|
+
{ok, error, line, logs, tests, vars, unset, request, next_request, timed_out, engine}.
|
|
161
|
+
|
|
162
|
+
The engine runs in a worker thread: the caller is usually inside the asyncio loop,
|
|
163
|
+
where V8's synchronous timeout is refused, and a long script must not block the server.
|
|
164
|
+
"""
|
|
165
|
+
if not (code or "").strip():
|
|
166
|
+
return _empty()
|
|
167
|
+
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="pyhttp-js") as pool:
|
|
168
|
+
return pool.submit(_run_inner, code, state, event, timeout).result()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _empty() -> dict[str, Any]:
|
|
172
|
+
return {"ok": True, "error": None, "line": None, "logs": [], "tests": [], "vars": {},
|
|
173
|
+
"unset": {}, "request": None, "next_request": None, "timed_out": False, "engine": None}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _run_inner(code: str, state: dict[str, Any], event: str, timeout: float) -> dict[str, Any]:
|
|
177
|
+
out = _empty()
|
|
178
|
+
try:
|
|
179
|
+
eng = _engine(timeout)
|
|
180
|
+
except EngineMissing as exc:
|
|
181
|
+
out.update({"ok": False, "error": str(exc)})
|
|
182
|
+
return out
|
|
183
|
+
out["engine"] = eng.name
|
|
184
|
+
state = dict(state)
|
|
185
|
+
state["eventName"] = event
|
|
186
|
+
started = time.perf_counter()
|
|
187
|
+
try:
|
|
188
|
+
eng.eval(f"var __STATE = {_js_json(state)};\n" + _prelude())
|
|
189
|
+
except JSError as exc:
|
|
190
|
+
out.update({"ok": False, "error": f"pm runtime failed to load: {exc}"})
|
|
191
|
+
return out
|
|
192
|
+
user = code.replace("\r\n", "\n")
|
|
193
|
+
wrapped = "try {\n" + user + "\n} catch (__e) { __R.error = __formatError(__e); }\n//# sourceURL=user.js"
|
|
194
|
+
try:
|
|
195
|
+
eng.eval(wrapped)
|
|
196
|
+
eng.run_jobs()
|
|
197
|
+
eng.eval("__settle();")
|
|
198
|
+
eng.run_jobs()
|
|
199
|
+
raw = eng.eval("__result();")
|
|
200
|
+
except JSError as exc:
|
|
201
|
+
if eng.is_timeout(exc):
|
|
202
|
+
out.update({"ok": False, "timed_out": True,
|
|
203
|
+
"error": f"script still running after {timeout:g}s; stopped"})
|
|
204
|
+
return out
|
|
205
|
+
msg, line = _clean_error(str(exc), 1)
|
|
206
|
+
out.update({"ok": False, "error": msg, "line": line})
|
|
207
|
+
return out
|
|
208
|
+
try:
|
|
209
|
+
res = json.loads(raw if isinstance(raw, str) else str(raw))
|
|
210
|
+
except (TypeError, ValueError):
|
|
211
|
+
out.update({"ok": False, "error": "script produced an unreadable result"})
|
|
212
|
+
return out
|
|
213
|
+
out["logs"] = [str(x) for x in res.get("logs") or []]
|
|
214
|
+
out["tests"] = [{"name": str(t.get("name", "")), "passed": bool(t.get("passed")), "note": str(t.get("note") or "")}
|
|
215
|
+
for t in res.get("tests") or []]
|
|
216
|
+
out["vars"] = res.get("vars") or {}
|
|
217
|
+
out["unset"] = res.get("unset") or {}
|
|
218
|
+
out["request"] = res.get("request")
|
|
219
|
+
out["next_request"] = res.get("nextRequest")
|
|
220
|
+
out["duration_ms"] = (time.perf_counter() - started) * 1000
|
|
221
|
+
if res.get("error"):
|
|
222
|
+
msg, line = _clean_error(str(res["error"]), 1)
|
|
223
|
+
out.update({"ok": False, "error": msg, "line": line})
|
|
224
|
+
return out
|
pyhttp/jsonc.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""JSON with comments ("JSONC"), as used by the body editor.
|
|
2
|
+
|
|
3
|
+
The editor lets you comment out lines with `//` (Ctrl+/) or blocks with
|
|
4
|
+
`/* ... */`, like Postman. Comments are removed here before a JSON body is
|
|
5
|
+
sent. Everything inside strings is left untouched, so a URL such as
|
|
6
|
+
"http://x" inside a value is never mistaken for a comment.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def strip_comments(text: str) -> str:
|
|
12
|
+
"""Remove `//` line comments and `/* */` block comments outside strings.
|
|
13
|
+
|
|
14
|
+
Line breaks inside block comments are kept so line numbers stay stable.
|
|
15
|
+
"""
|
|
16
|
+
if not text or ("//" not in text and "/*" not in text):
|
|
17
|
+
return text or ""
|
|
18
|
+
out: list[str] = []
|
|
19
|
+
i, n = 0, len(text)
|
|
20
|
+
in_string = False
|
|
21
|
+
while i < n:
|
|
22
|
+
c = text[i]
|
|
23
|
+
if in_string:
|
|
24
|
+
out.append(c)
|
|
25
|
+
if c == "\\" and i + 1 < n:
|
|
26
|
+
out.append(text[i + 1])
|
|
27
|
+
i += 2
|
|
28
|
+
continue
|
|
29
|
+
if c == '"':
|
|
30
|
+
in_string = False
|
|
31
|
+
i += 1
|
|
32
|
+
continue
|
|
33
|
+
if c == '"':
|
|
34
|
+
in_string = True
|
|
35
|
+
out.append(c)
|
|
36
|
+
i += 1
|
|
37
|
+
continue
|
|
38
|
+
if c == "/" and i + 1 < n and text[i + 1] == "/":
|
|
39
|
+
j = text.find("\n", i)
|
|
40
|
+
i = n if j == -1 else j # keep the newline itself
|
|
41
|
+
continue
|
|
42
|
+
if c == "/" and i + 1 < n and text[i + 1] == "*":
|
|
43
|
+
j = text.find("*/", i + 2)
|
|
44
|
+
end = n if j == -1 else j + 2
|
|
45
|
+
out.extend(ch for ch in text[i:end] if ch == "\n")
|
|
46
|
+
i = end
|
|
47
|
+
continue
|
|
48
|
+
out.append(c)
|
|
49
|
+
i += 1
|
|
50
|
+
return "".join(out)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def has_comments(text: str) -> bool:
|
|
54
|
+
return strip_comments(text) != (text or "")
|
pyhttp/mcp.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""MCP server: the collection, sending and running, exposed to AI tools.
|
|
2
|
+
|
|
3
|
+
python -m pyhttp mcp # stdio transport (Claude Code, Claude Desktop, Cursor)
|
|
4
|
+
python -m pyhttp mcp --data-dir PATH # another data folder
|
|
5
|
+
|
|
6
|
+
Every tool goes through the same REST API the UI uses (in process, no network),
|
|
7
|
+
so behavior, history and validation are identical to the app. The data folder is
|
|
8
|
+
shared with the app (SQLite in WAL mode), so both can be open at the same time.
|
|
9
|
+
|
|
10
|
+
Claude Code: claude mcp add pyhttp -- python -m pyhttp mcp
|
|
11
|
+
Claude Desktop: {"mcpServers": {"pyhttp": {"command": "python", "args": ["-m", "pyhttp", "mcp"]}}}
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from mcp.server.mcpserver import MCPServer
|
|
23
|
+
|
|
24
|
+
BODY_LIMIT = 40_000 # characters of a response body per tool result
|
|
25
|
+
|
|
26
|
+
server = MCPServer(
|
|
27
|
+
"pyhttp",
|
|
28
|
+
instructions="A local HTTP client (Postman-like). Requests live in folders, can hold several named "
|
|
29
|
+
"bodies, tests and scripts, and use {{variables}} from the active environment and globals. "
|
|
30
|
+
"send_request/send return the response; run executes many requests in order and reports.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_client: httpx.AsyncClient | None = None
|
|
34
|
+
_data_dir: Path | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure(data_dir: Path) -> None:
|
|
38
|
+
"""Pick the data folder; the app (and its database) starts on first use."""
|
|
39
|
+
global _data_dir, _client
|
|
40
|
+
_data_dir = data_dir
|
|
41
|
+
_client = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_client() -> httpx.AsyncClient:
|
|
45
|
+
global _client
|
|
46
|
+
if _client is None:
|
|
47
|
+
if _data_dir is None:
|
|
48
|
+
raise RuntimeError("configure(data_dir) was not called")
|
|
49
|
+
from .app import create_app # imported late: starting the app opens the database
|
|
50
|
+
|
|
51
|
+
_client = httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(_data_dir)), base_url="http://pyhttp")
|
|
52
|
+
return _client
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _api(method: str, path: str, *, json_body: Any = None, params: dict[str, Any] | None = None) -> Any:
|
|
56
|
+
r = await _get_client().request(method, "/api" + path, json=json_body, params=params)
|
|
57
|
+
if r.status_code >= 400:
|
|
58
|
+
try:
|
|
59
|
+
detail = r.json().get("detail")
|
|
60
|
+
except Exception: # noqa: BLE001
|
|
61
|
+
detail = r.text
|
|
62
|
+
raise RuntimeError(f"pyhttp API {r.status_code}: {detail}")
|
|
63
|
+
return r.json() if "application/json" in (r.headers.get("content-type") or "") else r.text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------- helpers
|
|
67
|
+
def _request_summary(r: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
return {"id": r["id"], "name": r["name"], "method": r["method"], "url": r["url"],
|
|
69
|
+
"tags": r.get("tags") or [], "folder_id": r.get("folder_id"),
|
|
70
|
+
"bodies": [b.get("name") for b in r.get("bodies") or []]}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _result_summary(res: dict[str, Any]) -> dict[str, Any]:
|
|
74
|
+
resp = res.get("response") or {}
|
|
75
|
+
body = resp.get("body_text")
|
|
76
|
+
if isinstance(body, str) and len(body) > BODY_LIMIT:
|
|
77
|
+
body = body[:BODY_LIMIT] + f"\n… truncated ({resp.get('size')} bytes in total)"
|
|
78
|
+
out = {"status": res.get("status"), "status_code": res.get("status_code"),
|
|
79
|
+
"duration_ms": res.get("duration_ms"), "url": (res.get("request") or {}).get("url"),
|
|
80
|
+
"content_type": resp.get("content_type"), "size": resp.get("size"),
|
|
81
|
+
"headers": dict(resp.get("headers") or []), "body": body}
|
|
82
|
+
if res.get("error"):
|
|
83
|
+
out["error"] = res["error"]
|
|
84
|
+
if res.get("tests"):
|
|
85
|
+
out["tests"] = [{"name": t.get("name"), "passed": t.get("passed"), "note": t.get("note", "")}
|
|
86
|
+
for t in res["tests"]]
|
|
87
|
+
if res.get("extracted"):
|
|
88
|
+
out["extracted_variables"] = res["extracted"]
|
|
89
|
+
if res.get("body_name"):
|
|
90
|
+
out["body_name"] = res["body_name"]
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _folder_id(folder: str | None, create: bool = False) -> str | None:
|
|
95
|
+
"""A folder given as id or name -> id (optionally created at the root)."""
|
|
96
|
+
if not folder:
|
|
97
|
+
return None
|
|
98
|
+
folders = await _api("GET", "/folders")
|
|
99
|
+
for f in folders:
|
|
100
|
+
if f["id"] == folder or f["name"].lower() == folder.lower():
|
|
101
|
+
return f["id"]
|
|
102
|
+
if create:
|
|
103
|
+
made = await _api("POST", "/folders", json_body={"name": folder})
|
|
104
|
+
return made["id"]
|
|
105
|
+
raise RuntimeError(f"Folder not found: {folder!r} (list_folders shows what exists)")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------ tools
|
|
109
|
+
@server.tool()
|
|
110
|
+
async def list_requests(query: str = "", tag: str = "") -> list[dict[str, Any]]:
|
|
111
|
+
"""List saved requests (id, name, method, url, tags, folder, body names). query matches name, url,
|
|
112
|
+
tags and description."""
|
|
113
|
+
rows = await _api("GET", "/requests", params={"q": query, "tag": tag})
|
|
114
|
+
return [_request_summary(r) for r in rows]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@server.tool()
|
|
118
|
+
async def get_request(request_id: str) -> dict[str, Any]:
|
|
119
|
+
"""The full definition of one request: params, headers, auth, bodies, tests, extractions, scripts."""
|
|
120
|
+
return await _api("GET", f"/requests/{request_id}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@server.tool()
|
|
124
|
+
async def create_request(name: str, url: str, method: str = "GET", folder: str | None = None,
|
|
125
|
+
params: list[dict[str, Any]] | None = None, headers: list[dict[str, Any]] | None = None,
|
|
126
|
+
body_type: str = "none", body: str = "", tags: list[str] | None = None,
|
|
127
|
+
description: str = "", tests: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
128
|
+
"""Create a request. params/headers rows are {"key", "value", "enabled"}. body_type: none, json, text,
|
|
129
|
+
xml, html, javascript, form, multipart, binary or graphql; a non-empty body becomes the first named body.
|
|
130
|
+
folder may be a folder name (created if missing) or id. tests rows are code-free assertions, e.g.
|
|
131
|
+
{"type": "status", "op": "equals", "value": "200"} or {"type": "json", "target": "user.id", "op": "exists"}
|
|
132
|
+
(types: status, time, header, body, json; target is the header name or JSON path)."""
|
|
133
|
+
folder_id = await _folder_id(folder, create=True)
|
|
134
|
+
payload: dict[str, Any] = {"name": name, "url": url, "method": method.upper(), "folder_id": folder_id,
|
|
135
|
+
"params": params or [], "headers": headers or [], "tags": tags or [],
|
|
136
|
+
"description": description, "body_type": body_type, "tests": tests or []}
|
|
137
|
+
if body and body_type != "none":
|
|
138
|
+
payload["bodies"] = [{"name": "Body 1", "body": body, "enabled": True}]
|
|
139
|
+
made = await _api("POST", "/requests", json_body=payload)
|
|
140
|
+
return _request_summary(made)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@server.tool()
|
|
144
|
+
async def update_request(request_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
"""Change fields of a request. patch holds only what changes, with the same shapes get_request returns
|
|
146
|
+
(e.g. {"url": "...", "method": "POST", "headers": [...], "bodies": [...], "tests": [...]})."""
|
|
147
|
+
current = await _api("GET", f"/requests/{request_id}")
|
|
148
|
+
current.update(patch)
|
|
149
|
+
updated = await _api("PUT", f"/requests/{request_id}", json_body=current)
|
|
150
|
+
return _request_summary(updated)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@server.tool()
|
|
154
|
+
async def delete_request(request_id: str) -> dict[str, Any]:
|
|
155
|
+
"""Delete a request permanently."""
|
|
156
|
+
return await _api("DELETE", f"/requests/{request_id}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@server.tool()
|
|
160
|
+
async def send_request(request_id: str, body_name: str | None = None) -> dict[str, Any]:
|
|
161
|
+
"""Send a saved request and return the response (status, headers, body, test results). Variables,
|
|
162
|
+
auth, scripts and dependencies apply exactly as in the app. A request with several enabled bodies
|
|
163
|
+
is sent with its first enabled body unless body_name picks one."""
|
|
164
|
+
req = await _api("GET", f"/requests/{request_id}")
|
|
165
|
+
if body_name is not None:
|
|
166
|
+
names = [b.get("name") for b in req.get("bodies") or []]
|
|
167
|
+
if body_name not in names:
|
|
168
|
+
raise RuntimeError(f"No body named {body_name!r}; this request has {names}")
|
|
169
|
+
req["bodies"] = [{**b, "enabled": b.get("name") == body_name} for b in req["bodies"]]
|
|
170
|
+
elif req.get("bodies"):
|
|
171
|
+
enabled = [b for b in req["bodies"] if b.get("enabled")]
|
|
172
|
+
first = (enabled or req["bodies"])[:1]
|
|
173
|
+
req["bodies"] = [{**b, "enabled": b is first[0]} for b in req["bodies"]]
|
|
174
|
+
res = await _api("POST", "/send", json_body=req)
|
|
175
|
+
return _result_summary(res)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@server.tool()
|
|
179
|
+
async def send(url: str, method: str = "GET", headers: list[dict[str, str]] | None = None,
|
|
180
|
+
body: str = "", body_type: str = "json") -> dict[str, Any]:
|
|
181
|
+
"""Send an ad-hoc request without saving it. headers rows are {"key", "value"}. {{variables}} from
|
|
182
|
+
the active environment work here too. The send is recorded in the app's history."""
|
|
183
|
+
payload: dict[str, Any] = {"name": f"{method.upper()} {url}", "url": url, "method": method.upper(),
|
|
184
|
+
"headers": [{**h, "enabled": True} for h in headers or []]}
|
|
185
|
+
if body:
|
|
186
|
+
payload["body_type"] = body_type
|
|
187
|
+
payload["bodies"] = [{"name": "Body", "body": body, "enabled": True}]
|
|
188
|
+
res = await _api("POST", "/send", json_body=payload)
|
|
189
|
+
return _result_summary(res)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@server.tool()
|
|
193
|
+
async def run(request_ids: list[str] | None = None, folder: str | None = None, tag: str = "",
|
|
194
|
+
name: str = "", iterations: int = 1, parallel: bool = False,
|
|
195
|
+
stop_on_error: bool = False) -> dict[str, Any]:
|
|
196
|
+
"""Run several requests in order (or in parallel) like the app's collection runner, wait for the end
|
|
197
|
+
and return the report: per-request status codes, durations and test results. Pick the requests by ids,
|
|
198
|
+
by folder (name or id), or by tag."""
|
|
199
|
+
ids = list(request_ids or [])
|
|
200
|
+
if folder or tag:
|
|
201
|
+
rows = await _api("GET", "/requests", params={"q": "", "tag": tag})
|
|
202
|
+
fid = await _folder_id(folder) if folder else None
|
|
203
|
+
ids += [r["id"] for r in rows if (fid is None or r.get("folder_id") == fid) and r["id"] not in ids]
|
|
204
|
+
if not ids:
|
|
205
|
+
raise RuntimeError("Nothing to run: pass request_ids, folder or tag")
|
|
206
|
+
started = await _api("POST", "/runs", json_body={"request_ids": ids, "name": name,
|
|
207
|
+
"iterations": max(1, iterations), "parallel": parallel,
|
|
208
|
+
"stop_on_error": stop_on_error})
|
|
209
|
+
run_id = started["id"]
|
|
210
|
+
for _ in range(2000): # runs can be long; poll until the status settles
|
|
211
|
+
state = await _api("GET", f"/runs/{run_id}")
|
|
212
|
+
if state.get("status") != "running":
|
|
213
|
+
results = [{"name": r.get("name"), "body_name": r.get("body_name"), "iteration": r.get("iteration"),
|
|
214
|
+
"status": r.get("status"), "status_code": r.get("status_code"),
|
|
215
|
+
"duration_ms": r.get("duration_ms"), "tests_failed": r.get("tests_failed", 0),
|
|
216
|
+
"error": r.get("error")} for r in state.get("results") or []]
|
|
217
|
+
return {"run_id": run_id, "status": state.get("status"), "total": state.get("total"),
|
|
218
|
+
"completed": state.get("completed"), "failed": state.get("failed"),
|
|
219
|
+
"output_dir": state.get("output_dir"), "results": results}
|
|
220
|
+
await asyncio.sleep(0.25)
|
|
221
|
+
raise RuntimeError(f"Run {run_id} is still going; check it in the app")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@server.tool()
|
|
225
|
+
async def get_history(limit: int = 20, query: str = "", status: str = "") -> list[dict[str, Any]]:
|
|
226
|
+
"""Recent sends, newest first (id, request name, method, url, status code, duration). status filters:
|
|
227
|
+
2xx, 3xx, 4xx, 5xx or error. Use get_history_entry for a full response."""
|
|
228
|
+
rows = await _api("GET", "/history", params={"limit": limit, "q": query, "status": status})
|
|
229
|
+
return [{"id": h["id"], "request_name": h.get("request_name"), "method": h.get("method"),
|
|
230
|
+
"url": h.get("url"), "status_code": h.get("status_code"), "duration_ms": h.get("duration_ms"),
|
|
231
|
+
"ts": h.get("ts"), "body_name": h.get("body_name")} for h in rows[:limit]]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@server.tool()
|
|
235
|
+
async def get_history_entry(history_id: str) -> dict[str, Any]:
|
|
236
|
+
"""One history entry in full: the request as sent and the whole response."""
|
|
237
|
+
h = await _api("GET", f"/history/{history_id}")
|
|
238
|
+
res = h.get("response") or {}
|
|
239
|
+
if isinstance(res.get("body"), str) and len(res["body"]) > BODY_LIMIT:
|
|
240
|
+
res["body"] = res["body"][:BODY_LIMIT] + "… (truncated)"
|
|
241
|
+
return h
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@server.tool()
|
|
245
|
+
async def list_folders() -> list[dict[str, Any]]:
|
|
246
|
+
"""All folders (id, name, parent_id)."""
|
|
247
|
+
rows = await _api("GET", "/folders")
|
|
248
|
+
return [{"id": f["id"], "name": f["name"], "parent_id": f.get("parent_id")} for f in rows]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@server.tool()
|
|
252
|
+
async def create_folder(name: str, parent: str | None = None) -> dict[str, Any]:
|
|
253
|
+
"""Create a folder, optionally inside another folder (name or id)."""
|
|
254
|
+
parent_id = await _folder_id(parent)
|
|
255
|
+
return await _api("POST", "/folders", json_body={"name": name, "parent_id": parent_id})
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@server.tool()
|
|
259
|
+
async def list_environments() -> dict[str, Any]:
|
|
260
|
+
"""All environments with their variables, and which one is active."""
|
|
261
|
+
return await _api("GET", "/environments")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@server.tool()
|
|
265
|
+
async def set_active_environment(environment: str | None) -> dict[str, Any]:
|
|
266
|
+
"""Switch the active environment by name or id; null/empty switches to no environment."""
|
|
267
|
+
env_id = None
|
|
268
|
+
if environment:
|
|
269
|
+
envs = (await _api("GET", "/environments")).get("environments") or []
|
|
270
|
+
match = next((e for e in envs if e["id"] == environment or e["name"].lower() == environment.lower()), None)
|
|
271
|
+
if not match:
|
|
272
|
+
raise RuntimeError(f"Environment not found: {environment!r}")
|
|
273
|
+
env_id = match["id"]
|
|
274
|
+
return await _api("PUT", "/environments/active", json_body={"id": env_id})
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@server.tool()
|
|
278
|
+
async def get_variables() -> dict[str, Any]:
|
|
279
|
+
"""The variables a send would use right now: globals overridden by the active environment."""
|
|
280
|
+
return await _api("GET", "/variables/effective")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@server.tool()
|
|
284
|
+
async def set_global_variable(key: str, value: str) -> dict[str, Any]:
|
|
285
|
+
"""Set (or overwrite) one global variable. Environment variables are edited with their environment
|
|
286
|
+
(list_environments shows the shapes)."""
|
|
287
|
+
current = await _api("GET", "/variables")
|
|
288
|
+
current[key] = value
|
|
289
|
+
return await _api("PUT", "/variables", json_body={"variables": current})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@server.tool()
|
|
293
|
+
async def import_curl(command: str, folder: str | None = None) -> dict[str, Any]:
|
|
294
|
+
"""Turn a cURL command line into a saved request (method, URL, headers, body are parsed).
|
|
295
|
+
folder may be a folder name (created if missing) or id."""
|
|
296
|
+
folder_id = await _folder_id(folder, create=True)
|
|
297
|
+
made = await _api("POST", "/import/curl", json_body={"command": command, "save": True, "folder_id": folder_id})
|
|
298
|
+
return _request_summary(made)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main(argv: list[str], default_data_dir: Path) -> None:
|
|
302
|
+
p = argparse.ArgumentParser(prog="pyhttp mcp", description="Serve the pyhttp MCP server on stdio.")
|
|
303
|
+
p.add_argument("--data-dir", type=Path, default=default_data_dir,
|
|
304
|
+
help=f"pyhttp data folder (default: {default_data_dir})")
|
|
305
|
+
args = p.parse_args(argv)
|
|
306
|
+
import logging
|
|
307
|
+
|
|
308
|
+
logging.getLogger("httpx").setLevel(logging.WARNING) # keep tool-call noise out of MCP logs
|
|
309
|
+
configure(args.data_dir)
|
|
310
|
+
server.run("stdio")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__": # pragma: no cover - `python -m pyhttp mcp` is the entry point
|
|
314
|
+
from .__main__ import DEFAULT_DATA_DIR
|
|
315
|
+
|
|
316
|
+
main([], DEFAULT_DATA_DIR)
|