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/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)