@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suiflex/suitest-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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 and not project_id:
400
+ if config_path:
396
401
  from suitest_lifecycle.config import load_config
397
402
 
398
403
  cfg = load_config(config_path)
399
- project_id = cfg.publish.project_id
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
- project_slug=project_slug,
509
- project_name=project_name,
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=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={"imported": imported, "run": run},
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
- res = run_lifecycle(load_config(args.config))
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.0"},
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: