@suiflex/suitest-mcp 0.1.0 → 0.1.2
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 +1 -1
- package/python/suitest_lifecycle/blackbox/mcp.py +90 -8
- package/python/suitest_lifecycle/cli.py +10 -1
- package/python/suitest_lifecycle/config.py +12 -0
- package/python/suitest_lifecycle/mcp_server.py +71 -8
- package/python/suitest_lifecycle/orchestrator.py +270 -3
- package/python/suitest_lifecycle/publish.py +56 -8
- package/python/suitest_lifecycle/retest.py +547 -0
- package/python/suitest_lifecycle/tools.py +43 -23
package/package.json
CHANGED
|
@@ -388,15 +388,22 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
|
|
|
388
388
|
import os as _os
|
|
389
389
|
import re as _re
|
|
390
390
|
|
|
391
|
+
from suitest_lifecycle.retest import rewrite_project_id
|
|
392
|
+
|
|
391
393
|
project_id = str(kwargs.pop("project_id", "") or "")
|
|
392
394
|
suite_name = str(kwargs.pop("suite_name", "") or "")
|
|
395
|
+
# EXPLICIT recreate opt-in — mirrors the lifecycle run tools. Without it a
|
|
396
|
+
# stale binding fails this publish instead of minting a fresh project.
|
|
397
|
+
recreate = bool(kwargs.pop("recreate_project", False))
|
|
393
398
|
config_path = str(kwargs.get("config_path", "") or "")
|
|
394
399
|
ui, paths = _resolve(**kwargs)
|
|
395
|
-
if config_path
|
|
400
|
+
if config_path:
|
|
396
401
|
from suitest_lifecycle.config import load_config
|
|
397
402
|
|
|
398
403
|
cfg = load_config(config_path)
|
|
399
|
-
|
|
404
|
+
if not project_id:
|
|
405
|
+
project_id = cfg.publish.project_id
|
|
406
|
+
recreate = recreate or cfg.publish.recreate
|
|
400
407
|
# No project configured → the server finds-or-creates one by a slug derived
|
|
401
408
|
# from the target host. Publishing is MANDATORY in the blackbox pipeline;
|
|
402
409
|
# "no project yet" is not an excuse to keep results local.
|
|
@@ -424,6 +431,66 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
|
|
|
424
431
|
|
|
425
432
|
try:
|
|
426
433
|
with SuitestClient(api_url, token=token, timeout=180.0) as client:
|
|
434
|
+
# --- project binding gate (before any upload/insert) ----------- #
|
|
435
|
+
binding: dict[str, Any] = {"status": "first_setup", "action": "will_create_by_slug"}
|
|
436
|
+
if project_id:
|
|
437
|
+
binding = {
|
|
438
|
+
"status": "unverified",
|
|
439
|
+
"action": "server_unreachable",
|
|
440
|
+
"projectId": project_id,
|
|
441
|
+
}
|
|
442
|
+
try:
|
|
443
|
+
resolved = client.resolve_project(
|
|
444
|
+
project_id=project_id,
|
|
445
|
+
project_slug=project_slug,
|
|
446
|
+
project_name=project_name,
|
|
447
|
+
)
|
|
448
|
+
except Exception:
|
|
449
|
+
# Resolve endpoint unreachable/older server: proceed — the
|
|
450
|
+
# publish itself still 404s a stale id without inserting.
|
|
451
|
+
resolved = None
|
|
452
|
+
if resolved is not None:
|
|
453
|
+
status = str(resolved.get("status", "missing"))
|
|
454
|
+
if status == "valid":
|
|
455
|
+
binding = {
|
|
456
|
+
"status": "valid",
|
|
457
|
+
"action": "reused_existing_project",
|
|
458
|
+
"projectId": project_id,
|
|
459
|
+
}
|
|
460
|
+
elif status == "repaired":
|
|
461
|
+
project_id = str(resolved.get("projectId", ""))
|
|
462
|
+
binding = {
|
|
463
|
+
"status": "repaired",
|
|
464
|
+
"action": "rebound_by_" + str(resolved.get("matchedBy", "match")),
|
|
465
|
+
"projectId": project_id,
|
|
466
|
+
}
|
|
467
|
+
if config_path:
|
|
468
|
+
rewrite_project_id(Path(config_path), project_id)
|
|
469
|
+
elif recreate:
|
|
470
|
+
binding = {
|
|
471
|
+
"status": "recreate_requested",
|
|
472
|
+
"action": "will_recreate_by_slug",
|
|
473
|
+
}
|
|
474
|
+
project_id = "" # server find-or-creates by slug below
|
|
475
|
+
else:
|
|
476
|
+
return _envelope(
|
|
477
|
+
False,
|
|
478
|
+
f"stale project binding: projectId '{project_id}' not found in "
|
|
479
|
+
"the workspace and no unambiguous project matched — nothing "
|
|
480
|
+
"was published",
|
|
481
|
+
data={
|
|
482
|
+
"projectBinding": {
|
|
483
|
+
"status": "missing",
|
|
484
|
+
"action": "fail",
|
|
485
|
+
"projectId": project_id,
|
|
486
|
+
"candidates": resolved.get("candidates", []),
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
errors=[
|
|
490
|
+
"fix publish.projectId (or the project_id argument), or "
|
|
491
|
+
"re-run with recreate_project=true to create a new project"
|
|
492
|
+
],
|
|
493
|
+
)
|
|
427
494
|
|
|
428
495
|
def _up(path: str, mime: str) -> str:
|
|
429
496
|
try:
|
|
@@ -505,26 +572,41 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
|
|
|
505
572
|
)
|
|
506
573
|
imported = client.bulk_import_cases(
|
|
507
574
|
project_id=project_id,
|
|
508
|
-
|
|
509
|
-
|
|
575
|
+
# Slug fallback ONLY when no validated id — an explicit id must
|
|
576
|
+
# never silently degrade into a find-or-create.
|
|
577
|
+
project_slug="" if project_id else project_slug,
|
|
578
|
+
project_name="" if project_id else project_name,
|
|
510
579
|
suite_name=suite,
|
|
511
580
|
mode="frontend",
|
|
512
581
|
cases=cases_payload,
|
|
582
|
+
# Current generation = the suite's alive set; an empty payload
|
|
583
|
+
# (nothing generated) must not stale the whole suite.
|
|
584
|
+
mark_stale=bool(cases_payload),
|
|
513
585
|
)
|
|
586
|
+
resolved_id = str(imported.get("projectId", "") or "") or project_id
|
|
514
587
|
run = client.ingest_run(
|
|
515
|
-
project_id=
|
|
516
|
-
project_slug=project_slug,
|
|
517
|
-
project_name=project_name,
|
|
588
|
+
project_id=resolved_id,
|
|
589
|
+
project_slug="" if resolved_id else project_slug,
|
|
590
|
+
project_name="" if resolved_id else project_name,
|
|
518
591
|
suite_name=suite,
|
|
519
592
|
name=f"{suite} run",
|
|
520
593
|
results=results_payload,
|
|
521
594
|
)
|
|
522
595
|
except SuitestAPIError as exc:
|
|
523
596
|
return _envelope(False, f"publish failed: {exc}", errors=[str(exc)])
|
|
597
|
+
# Slug-based publish minted (or found) the project — pin its id so the next
|
|
598
|
+
# blackbox publish is an explicit-id retest, never a re-create.
|
|
599
|
+
if config_path and resolved_id and binding["status"] in ("first_setup", "recreate_requested"):
|
|
600
|
+
rewrite_project_id(Path(config_path), resolved_id)
|
|
524
601
|
return _envelope(
|
|
525
602
|
True,
|
|
526
603
|
f"published: {len(imported.get('imported', []))} case(s), run {run.get('runId')}",
|
|
527
|
-
data={
|
|
604
|
+
data={
|
|
605
|
+
"imported": imported,
|
|
606
|
+
"run": run,
|
|
607
|
+
"projectBinding": {**binding, "projectId": resolved_id},
|
|
608
|
+
"staleCases": imported.get("stale", []),
|
|
609
|
+
},
|
|
528
610
|
)
|
|
529
611
|
|
|
530
612
|
|
|
@@ -92,6 +92,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
92
92
|
|
|
93
93
|
test = sub.add_parser("test", help="Run the full config-driven lifecycle")
|
|
94
94
|
test.add_argument("--config", default="suitest.config.json")
|
|
95
|
+
test.add_argument(
|
|
96
|
+
"--recreate-project",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help="EXPLICITLY recreate the Suitest project when publish.projectId is "
|
|
99
|
+
"stale and repair finds no match (otherwise a stale binding fails the run)",
|
|
100
|
+
)
|
|
95
101
|
|
|
96
102
|
sub.add_parser("mcp", help="Serve the stdio MCP server")
|
|
97
103
|
|
|
@@ -109,7 +115,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
109
115
|
from suitest_lifecycle.config import load_config
|
|
110
116
|
from suitest_lifecycle.orchestrator import run_lifecycle
|
|
111
117
|
|
|
112
|
-
|
|
118
|
+
cfg = load_config(args.config)
|
|
119
|
+
if args.recreate_project:
|
|
120
|
+
cfg.publish.recreate = True
|
|
121
|
+
res = run_lifecycle(cfg)
|
|
113
122
|
print(res.summary)
|
|
114
123
|
for step in res.steps:
|
|
115
124
|
print(f" - {step}")
|
|
@@ -76,6 +76,11 @@ class PublishConfig:
|
|
|
76
76
|
workspace_id: str = ""
|
|
77
77
|
project_id: str = ""
|
|
78
78
|
suite_name: str = ""
|
|
79
|
+
# EXPLICIT recreate opt-in: when the configured projectId no longer exists
|
|
80
|
+
# and repair finds no match, a fresh project is created ONLY if this is set
|
|
81
|
+
# (config keys ``recreateProject``/``resetProjectBinding``, env
|
|
82
|
+
# SUITEST_RECREATE_PROJECT=1, or the MCP tool arg ``recreate_project``).
|
|
83
|
+
recreate: bool = False
|
|
79
84
|
|
|
80
85
|
|
|
81
86
|
@dataclass
|
|
@@ -264,6 +269,8 @@ def load_config(path: str | Path) -> Config:
|
|
|
264
269
|
publish = PublishConfig()
|
|
265
270
|
pub_raw = raw.get("publish", {})
|
|
266
271
|
if isinstance(pub_raw, dict):
|
|
272
|
+
import os
|
|
273
|
+
|
|
267
274
|
publish = PublishConfig(
|
|
268
275
|
enabled=bool(pub_raw.get("enabled", False)),
|
|
269
276
|
api_url=str(pub_raw.get("apiUrl", "http://localhost:4000")).rstrip("/"),
|
|
@@ -271,6 +278,11 @@ def load_config(path: str | Path) -> Config:
|
|
|
271
278
|
workspace_id=str(pub_raw.get("workspaceId", "")),
|
|
272
279
|
project_id=str(pub_raw.get("projectId", "")),
|
|
273
280
|
suite_name=str(pub_raw.get("suiteName", "")),
|
|
281
|
+
recreate=bool(
|
|
282
|
+
pub_raw.get("recreateProject", False)
|
|
283
|
+
or pub_raw.get("resetProjectBinding", False)
|
|
284
|
+
or os.environ.get("SUITEST_RECREATE_PROJECT", "") in ("1", "true")
|
|
285
|
+
),
|
|
274
286
|
)
|
|
275
287
|
|
|
276
288
|
ids_raw = raw.get("testIds", [])
|
|
@@ -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
|
|
@@ -22,6 +25,10 @@ if TYPE_CHECKING:
|
|
|
22
25
|
|
|
23
26
|
PROTOCOL_VERSION = "2024-11-05"
|
|
24
27
|
|
|
28
|
+
# Run tools accept the explicit recreate opt-in (goal: recreate NEVER happens
|
|
29
|
+
# implicitly — only via this flag or the publish.recreateProject config key).
|
|
30
|
+
RECREATE_TOOLS = frozenset({"run_tests", "run_backend_tests", "run_frontend_tests"})
|
|
31
|
+
|
|
25
32
|
_TOOL_DESCRIPTIONS = {
|
|
26
33
|
"analyze_project": "Static-analyze the target project; list endpoints (backend) or pages (frontend).",
|
|
27
34
|
"generate_test_cases": "Analyze, build a PRD + test plan, and export runnable test files.",
|
|
@@ -74,6 +81,14 @@ _BLACKBOX_INPUT_SCHEMA: dict[str, object] = {
|
|
|
74
81
|
"type": "string",
|
|
75
82
|
"description": "Suitest project id to publish into (blackbox_publish_results)",
|
|
76
83
|
},
|
|
84
|
+
"recreate_project": {
|
|
85
|
+
"type": "boolean",
|
|
86
|
+
"description": (
|
|
87
|
+
"EXPLICIT opt-in: recreate the project when the configured/passed "
|
|
88
|
+
"project id no longer exists and repair finds no match "
|
|
89
|
+
"(blackbox_publish_results). Without it a stale binding fails the publish."
|
|
90
|
+
),
|
|
91
|
+
},
|
|
77
92
|
"prd_file": {
|
|
78
93
|
"type": "string",
|
|
79
94
|
"description": "Markdown PRD path — PRD-driven semantic plan via the workspace LLM (blackbox_generate_playwright_tests)",
|
|
@@ -90,18 +105,29 @@ def _tool_schema(name: str) -> dict[str, object]:
|
|
|
90
105
|
"description": _TOOL_DESCRIPTIONS.get(name, name),
|
|
91
106
|
"inputSchema": _BLACKBOX_INPUT_SCHEMA,
|
|
92
107
|
}
|
|
108
|
+
properties: dict[str, object] = {
|
|
109
|
+
"config_path": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"description": "Path to suitest.config.json",
|
|
112
|
+
"default": "suitest.config.json",
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if name in RECREATE_TOOLS:
|
|
116
|
+
properties["recreate_project"] = {
|
|
117
|
+
"type": "boolean",
|
|
118
|
+
"description": (
|
|
119
|
+
"EXPLICIT opt-in: recreate the project when the configured "
|
|
120
|
+
"publish.projectId no longer exists and repair finds no match. "
|
|
121
|
+
"Without this flag a stale binding FAILS the run (nothing is inserted)."
|
|
122
|
+
),
|
|
123
|
+
"default": False,
|
|
124
|
+
}
|
|
93
125
|
return {
|
|
94
126
|
"name": name,
|
|
95
127
|
"description": _TOOL_DESCRIPTIONS.get(name, name),
|
|
96
128
|
"inputSchema": {
|
|
97
129
|
"type": "object",
|
|
98
|
-
"properties":
|
|
99
|
-
"config_path": {
|
|
100
|
-
"type": "string",
|
|
101
|
-
"description": "Path to suitest.config.json",
|
|
102
|
-
"default": "suitest.config.json",
|
|
103
|
-
}
|
|
104
|
-
},
|
|
130
|
+
"properties": properties,
|
|
105
131
|
"required": ["config_path"],
|
|
106
132
|
},
|
|
107
133
|
}
|
|
@@ -124,7 +150,7 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
|
|
|
124
150
|
{
|
|
125
151
|
"protocolVersion": PROTOCOL_VERSION,
|
|
126
152
|
"capabilities": {"tools": {}},
|
|
127
|
-
"serverInfo": {"name": "suitest-lifecycle", "version": "0.1.
|
|
153
|
+
"serverInfo": {"name": "suitest-lifecycle", "version": "0.1.2"},
|
|
128
154
|
},
|
|
129
155
|
)
|
|
130
156
|
if method in ("notifications/initialized", "initialized"):
|
|
@@ -144,6 +170,11 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
|
|
|
144
170
|
try:
|
|
145
171
|
if str(name) in KWARG_TOOLS:
|
|
146
172
|
envelope = tool(**arguments)
|
|
173
|
+
elif str(name) in RECREATE_TOOLS:
|
|
174
|
+
envelope = tool(
|
|
175
|
+
str(arguments.get("config_path", "suitest.config.json")),
|
|
176
|
+
bool(arguments.get("recreate_project", False)),
|
|
177
|
+
)
|
|
147
178
|
else:
|
|
148
179
|
envelope = tool(str(arguments.get("config_path", "suitest.config.json")))
|
|
149
180
|
except Exception as exc: # defensive: never crash the server on a tool bug
|
|
@@ -166,7 +197,39 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
|
|
|
166
197
|
return None
|
|
167
198
|
|
|
168
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
|
+
|
|
169
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)
|
|
170
233
|
for line in stdin:
|
|
171
234
|
line = line.strip()
|
|
172
235
|
if not line:
|