@suiflex/suitest-mcp 0.1.1 → 0.1.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suiflex/suitest-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -414,10 +414,7 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
414
414
  token = _os.environ.get("SUITEST_API_KEY", "")
415
415
  if not api_url or not token:
416
416
  return _envelope(False, "SUITEST_API_URL / SUITEST_API_KEY not set")
417
- try:
418
- from suitest_sdk import SuitestAPIError, SuitestClient
419
- except ImportError:
420
- return _envelope(False, "suiflex-suitest-sdk not installed")
417
+ from suitest_lifecycle.http_client import SuitestAPIError, SuitestClient
421
418
 
422
419
  plan_path = paths.test_plan_json
423
420
  if not plan_path.is_file():
@@ -0,0 +1,207 @@
1
+ """Stdlib-only Suitest API client for the bundled lifecycle.
2
+
3
+ Mirrors the slice of the ``suiflex-suitest-sdk`` surface the lifecycle needs
4
+ (binding resolve, bulk import, run ingest, artifact upload, LLM proxy) using
5
+ only ``urllib`` — so ``npx @suiflex/suitest-mcp`` publishes out of the box,
6
+ with zero ``pip install`` on the host. The pip SDK remains the richer client
7
+ for external integrations; this one exists so a brand-new QA user's first run
8
+ lands in the web UI instead of silently skipping publish.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import mimetypes
15
+ import os
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ import uuid
20
+ from typing import Any
21
+
22
+ JSONDict = dict[str, Any]
23
+
24
+
25
+ class SuitestAPIError(RuntimeError):
26
+ """Raised on a non-2xx API response. Carries status + parsed body."""
27
+
28
+ def __init__(self, status_code: int, body: object) -> None:
29
+ super().__init__(f"Suitest API error {status_code}: {body}")
30
+ self.status_code = status_code
31
+ self.body = body
32
+
33
+
34
+ class SuitestClient:
35
+ """Drop-in for the SDK client's lifecycle-facing methods (sync, stdlib)."""
36
+
37
+ def __init__(
38
+ self,
39
+ base_url: str,
40
+ *,
41
+ token: str | None = None,
42
+ workspace_id: str | None = None,
43
+ timeout: float = 30.0,
44
+ ) -> None:
45
+ self._base = base_url.rstrip("/")
46
+ self._timeout = timeout
47
+ self._headers: dict[str, str] = {"Accept": "application/json"}
48
+ if token:
49
+ self._headers["Authorization"] = f"Bearer {token}"
50
+ if workspace_id:
51
+ self._headers["X-Workspace-Id"] = workspace_id
52
+
53
+ def __enter__(self) -> SuitestClient:
54
+ return self
55
+
56
+ def __exit__(self, *exc: object) -> None:
57
+ self.close()
58
+
59
+ def close(self) -> None: # urllib is connectionless — kept for SDK parity
60
+ return None
61
+
62
+ # -- low level ----------------------------------------------------------
63
+ def _request(
64
+ self,
65
+ method: str,
66
+ path: str,
67
+ *,
68
+ params: dict[str, object] | None = None,
69
+ json_body: JSONDict | None = None,
70
+ data: bytes | None = None,
71
+ headers: dict[str, str] | None = None,
72
+ ) -> object:
73
+ url = self._base + path
74
+ if params:
75
+ url += "?" + urllib.parse.urlencode({k: str(v) for k, v in params.items()})
76
+ hdrs = dict(self._headers)
77
+ body: bytes | None = data
78
+ if json_body is not None:
79
+ body = json.dumps(json_body).encode("utf-8")
80
+ hdrs["Content-Type"] = "application/json"
81
+ if headers:
82
+ hdrs.update(headers)
83
+ req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
84
+ try:
85
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
86
+ raw = resp.read()
87
+ if resp.status == 204 or not raw:
88
+ return None
89
+ return json.loads(raw)
90
+ except urllib.error.HTTPError as exc:
91
+ raw = exc.read()
92
+ try:
93
+ parsed: object = json.loads(raw)
94
+ except ValueError:
95
+ parsed = raw.decode("utf-8", errors="replace")
96
+ raise SuitestAPIError(exc.code, parsed) from exc
97
+
98
+ # -- LLM proxy ------------------------------------------------------------
99
+ def llm_complete(
100
+ self,
101
+ prompt: str,
102
+ *,
103
+ system: str | None = None,
104
+ max_tokens: int = 4096,
105
+ temperature: float = 0.2,
106
+ ) -> str:
107
+ body: JSONDict = {"prompt": prompt, "maxTokens": max_tokens, "temperature": temperature}
108
+ if system is not None:
109
+ body["system"] = system
110
+ result = self._request("POST", "/api/v1/llm/complete", json_body=body)
111
+ return str(result.get("content", "")) if isinstance(result, dict) else ""
112
+
113
+ # -- lifecycle ingest -----------------------------------------------------
114
+ def resolve_project(
115
+ self, *, project_id: str = "", project_slug: str = "", project_name: str = ""
116
+ ) -> JSONDict:
117
+ result = self._request(
118
+ "POST",
119
+ "/api/v1/ingest/resolve-project",
120
+ json_body={
121
+ "projectId": project_id,
122
+ "projectSlug": project_slug,
123
+ "projectName": project_name,
124
+ },
125
+ )
126
+ return result if isinstance(result, dict) else {}
127
+
128
+ def bulk_import_cases(
129
+ self,
130
+ *,
131
+ project_id: str = "",
132
+ suite_name: str,
133
+ mode: str,
134
+ cases: list[JSONDict],
135
+ project_slug: str = "",
136
+ project_name: str = "",
137
+ mark_stale: bool = False,
138
+ ) -> JSONDict:
139
+ result = self._request(
140
+ "POST",
141
+ "/api/v1/test-cases/bulk-import",
142
+ json_body={
143
+ "projectId": project_id,
144
+ "projectSlug": project_slug,
145
+ "projectName": project_name,
146
+ "suiteName": suite_name,
147
+ "mode": mode,
148
+ "cases": cases,
149
+ "markStale": mark_stale,
150
+ },
151
+ )
152
+ return result if isinstance(result, dict) else {}
153
+
154
+ def ingest_run(
155
+ self,
156
+ *,
157
+ project_id: str = "",
158
+ suite_name: str,
159
+ name: str,
160
+ results: list[JSONDict],
161
+ env: str = "staging",
162
+ branch: str | None = None,
163
+ commit_sha: str | None = None,
164
+ project_slug: str = "",
165
+ project_name: str = "",
166
+ ) -> JSONDict:
167
+ body: JSONDict = {
168
+ "projectId": project_id,
169
+ "projectSlug": project_slug,
170
+ "projectName": project_name,
171
+ "suiteName": suite_name,
172
+ "name": name,
173
+ "env": env,
174
+ "results": results,
175
+ }
176
+ if branch is not None:
177
+ body["branch"] = branch
178
+ if commit_sha is not None:
179
+ body["commitSha"] = commit_sha
180
+ result = self._request("POST", "/api/v1/runs/ingest", json_body=body)
181
+ return result if isinstance(result, dict) else {}
182
+
183
+ def upload_file(self, path: str, *, content_type: str | None = None) -> str:
184
+ """Multipart upload to /api/v1/files; returns the durable s3:// URL.
185
+
186
+ The whole file is buffered in memory — run videos are a few MB, fine.
187
+ """
188
+ ct = content_type or mimetypes.guess_type(path)[0] or "application/octet-stream"
189
+ boundary = uuid.uuid4().hex
190
+ name = os.path.basename(path)
191
+ with open(path, "rb") as fh:
192
+ payload = fh.read()
193
+ head = (
194
+ f"--{boundary}\r\n"
195
+ f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
196
+ f"Content-Type: {ct}\r\n\r\n"
197
+ ).encode()
198
+ tail = f"\r\n--{boundary}--\r\n".encode()
199
+ result = self._request(
200
+ "POST",
201
+ "/api/v1/files",
202
+ data=head + payload + tail,
203
+ headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
204
+ )
205
+ if not isinstance(result, dict):
206
+ raise SuitestAPIError(0, "unexpected upload response")
207
+ return str(result["url"])
@@ -124,11 +124,8 @@ class RemoteLlmClient:
124
124
  def _complete(self, prompt: str, *, system: str | None = None, max_tokens: int = 4096) -> str:
125
125
  if self._disabled:
126
126
  return ""
127
- try:
128
- from suitest_sdk import SuitestAPIError, SuitestClient
129
- except ImportError:
130
- self._disabled = True
131
- return ""
127
+ from suitest_lifecycle.http_client import SuitestAPIError, SuitestClient
128
+
132
129
  try:
133
130
  with SuitestClient(
134
131
  self._api_url,
@@ -12,7 +12,10 @@ Every tool takes a single ``config_path`` argument and returns the structured
12
12
  from __future__ import annotations
13
13
 
14
14
  import json
15
+ import os
15
16
  import sys
17
+ import urllib.error
18
+ import urllib.request
16
19
  from typing import TYPE_CHECKING, TextIO
17
20
 
18
21
  from suitest_lifecycle.tools import KWARG_TOOLS, TOOLS
@@ -147,7 +150,7 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
147
150
  {
148
151
  "protocolVersion": PROTOCOL_VERSION,
149
152
  "capabilities": {"tools": {}},
150
- "serverInfo": {"name": "suitest-lifecycle", "version": "0.1.1"},
153
+ "serverInfo": {"name": "suitest-lifecycle", "version": "0.1.2"},
151
154
  },
152
155
  )
153
156
  if method in ("notifications/initialized", "initialized"):
@@ -194,7 +197,39 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
194
197
  return None
195
198
 
196
199
 
200
+ def verify_credentials() -> str | None:
201
+ """Check SUITEST_API_URL + SUITEST_API_KEY; return an error string if unusable.
202
+
203
+ Both must be set, and the key must authenticate against the URL
204
+ (``GET /api/v1/api-keys/whoami`` — the key pins the workspace/project every
205
+ tool publishes into). Any failure must abort the connection: a server that
206
+ accepts empty or mismatched credentials silently drops all publishes.
207
+ """
208
+ api_url = os.environ.get("SUITEST_API_URL", "").strip().rstrip("/")
209
+ api_key = os.environ.get("SUITEST_API_KEY", "").strip()
210
+ if not api_url or not api_key:
211
+ return (
212
+ "SUITEST_API_URL and SUITEST_API_KEY are both required "
213
+ "(set them in the mcpServers env block); refusing to start"
214
+ )
215
+ req = urllib.request.Request(
216
+ f"{api_url}/api/v1/api-keys/whoami",
217
+ headers={"Authorization": f"Bearer {api_key}"},
218
+ )
219
+ try:
220
+ with urllib.request.urlopen(req, timeout=10):
221
+ return None
222
+ except urllib.error.HTTPError as exc:
223
+ return f"SUITEST_API_KEY rejected by {api_url} (HTTP {exc.code}); refusing to start"
224
+ except (urllib.error.URLError, OSError) as exc:
225
+ return f"SUITEST_API_URL {api_url} unreachable ({exc}); refusing to start"
226
+
227
+
197
228
  def serve(stdin: TextIO = sys.stdin, stdout: TextIO = sys.stdout) -> None:
229
+ error = verify_credentials()
230
+ if error is not None:
231
+ sys.stderr.write(f"suitest-mcp: {error}\n")
232
+ raise SystemExit(1)
198
233
  for line in stdin:
199
234
  line = line.strip()
200
235
  if not line:
@@ -142,6 +142,9 @@ class TestResult:
142
142
  error: str = ""
143
143
  automation_file: str = ""
144
144
  artifacts: list[str] = field(default_factory=list)
145
+ # Captured subprocess output (tail-bounded) — published as run log lines.
146
+ stdout: str = ""
147
+ stderr: str = ""
145
148
  # Phase 2 — rich recording (collected from each test's sidecar JSON).
146
149
  steps: list[StepResult] = field(default_factory=list)
147
150
  video_path: str = ""
@@ -2,9 +2,10 @@
2
2
 
3
3
  Builds the bulk-import (cases + steps + source code) and run-ingest (completed
4
4
  run + per-step outcomes + video/screenshot artifacts) payloads, then sends them
5
- via the Suitest SDK. The SDK is imported lazily so the lifecycle core stays
6
- stdlib-only; if it (or the server) is unavailable, publishing degrades to a
7
- clean ``{"published": False, "reason": ...}`` instead of failing the run.
5
+ via the bundled stdlib client (:mod:`suitest_lifecycle.http_client`) no pip
6
+ install needed on the host, so ``npx @suiflex/suitest-mcp`` publishes out of
7
+ the box. If the server is unavailable, publishing degrades to a clean
8
+ ``{"published": False, "reason": ...}`` instead of failing the run.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -149,6 +150,8 @@ def _result_payloads(
149
150
  "durationMs": r.duration_ms,
150
151
  "error": r.error,
151
152
  "failureKind": kinds.get(r.test_id, ""),
153
+ "stdout": r.stdout,
154
+ "stderr": r.stderr,
152
155
  "steps": [
153
156
  {
154
157
  "order": s.index,
@@ -187,10 +190,9 @@ def publish_results(
187
190
  # No binding info (legacy caller) + no id → refuse rather than guess.
188
191
  if binding is None and not config.publish.project_id:
189
192
  return {"published": False, "reason": "publish.projectId not set"}
190
- try:
191
- from suitest_sdk import SuitestAPIError, SuitestClient
192
- except ImportError:
193
- return {"published": False, "reason": "suiflex-suitest-sdk not installed"}
193
+ # Bundled stdlib client — publish works out of the box under
194
+ # `npx @suiflex/suitest-mcp`, no pip install required on the host.
195
+ from suitest_lifecycle.http_client import SuitestAPIError, SuitestClient
194
196
 
195
197
  # first_setup / explicit recreate publish by slug (server find-or-creates);
196
198
  # valid / repaired / unverified publish by explicit id (server 404s stale ids
@@ -95,16 +95,14 @@ def rewrite_project_id(config_path: Path, project_id: str) -> bool:
95
95
  return False
96
96
 
97
97
 
98
- def _make_client(config: Config) -> BindingClient | None:
98
+ def _make_client(config: Config) -> BindingClient:
99
99
  import os
100
100
 
101
- try:
102
- from suitest_sdk import SuitestClient
103
- except ImportError:
104
- return None
101
+ from suitest_lifecycle.http_client import SuitestClient
102
+
105
103
  api_url = config.publish.api_url or os.environ.get("SUITEST_API_URL", "")
106
104
  token = config.publish.token or os.environ.get("SUITEST_API_KEY") or None
107
- return SuitestClient( # type: ignore[no-any-return]
105
+ return SuitestClient(
108
106
  api_url, token=token, workspace_id=config.publish.workspace_id or None, timeout=30.0
109
107
  )
110
108
 
@@ -117,10 +115,6 @@ def resolve_binding(
117
115
  return BindingResult("local_only", "publish_disabled")
118
116
  if client is None:
119
117
  client = _make_client(config)
120
- if client is None:
121
- return BindingResult(
122
- "local_only", "sdk_not_installed", detail="suiflex-suitest-sdk not installed"
123
- )
124
118
 
125
119
  slug = project_slug(config.project_name)
126
120
  if not config.publish.project_id:
@@ -62,7 +62,15 @@ def _as_outcome(value: str) -> TestOutcome:
62
62
  return TestOutcome.PASSED
63
63
 
64
64
 
65
- def _run_one(file_path: Path, python: str, timeout_sec: int) -> tuple[TestOutcome, int, str]:
65
+ def _tail(text: str | None, lines: int = 500) -> str:
66
+ # ponytail: 500-line tail bound keeps the ingest payload sane for chatty tests.
67
+ return "\n".join((text or "").strip().splitlines()[-lines:])
68
+
69
+
70
+ def _run_one(
71
+ file_path: Path, python: str, timeout_sec: int
72
+ ) -> tuple[TestOutcome, int, str, str, str]:
73
+ """Returns (outcome, duration_ms, error, stdout_tail, stderr_tail)."""
66
74
  start = time.monotonic()
67
75
  try:
68
76
  proc = subprocess.run(
@@ -72,19 +80,25 @@ def _run_one(file_path: Path, python: str, timeout_sec: int) -> tuple[TestOutcom
72
80
  timeout=timeout_sec,
73
81
  cwd=str(file_path.parent),
74
82
  )
75
- except subprocess.TimeoutExpired:
83
+ except subprocess.TimeoutExpired as exc:
84
+ out = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
85
+ err = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
76
86
  return (
77
87
  TestOutcome.ERROR,
78
88
  int((time.monotonic() - start) * 1000),
79
89
  f"timeout after {timeout_sec}s",
90
+ _tail(out),
91
+ _tail(err),
80
92
  )
81
93
  duration_ms = int((time.monotonic() - start) * 1000)
94
+ out_tail = _tail(proc.stdout)
95
+ err_tail_full = _tail(proc.stderr)
82
96
  if proc.returncode == 0:
83
- return TestOutcome.PASSED, duration_ms, ""
97
+ return TestOutcome.PASSED, duration_ms, "", out_tail, err_tail_full
84
98
  err = (proc.stderr or "").strip() or (proc.stdout or "").strip() or f"exit {proc.returncode}"
85
99
  # Keep the last ~30 lines — enough to see the assertion without flooding the report.
86
100
  err_tail = "\n".join(err.splitlines()[-30:])
87
- return TestOutcome.FAILED, duration_ms, err_tail
101
+ return TestOutcome.FAILED, duration_ms, err_tail, out_tail, err_tail_full
88
102
 
89
103
 
90
104
  def run_tests(
@@ -114,7 +128,9 @@ def run_tests(
114
128
  )
115
129
  continue
116
130
  file_path = test_dir / case.automation_file
117
- outcome, duration_ms, error = _run_one(file_path, interpreter, timeout_sec)
131
+ outcome, duration_ms, error, out_tail, err_tail = _run_one(
132
+ file_path, interpreter, timeout_sec
133
+ )
118
134
  steps, video, screenshot = _collect_steps(case, test_dir, outcome)
119
135
  artifacts = [p for p in (video, screenshot) if p]
120
136
  results.append(
@@ -126,6 +142,8 @@ def run_tests(
126
142
  duration_ms=duration_ms,
127
143
  error=error,
128
144
  automation_file=case.automation_file,
145
+ stdout=out_tail,
146
+ stderr=err_tail,
129
147
  steps=steps,
130
148
  video_path=video,
131
149
  screenshot_path=screenshot,