@gleanwork/mcp-server-tester 2.0.0-beta.2 → 2.0.0-beta.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": "@gleanwork/mcp-server-tester",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.3",
4
4
  "description": "Playwright-based testing and evaluation framework for MCP servers",
5
5
  "keywords": [
6
6
  "playwright",
@@ -29,6 +29,7 @@
29
29
  "require": "./dist/index.cjs"
30
30
  },
31
31
  "./cowork-runtime": "./scripts/cowork_computer_use.py",
32
+ "./cowork-linux-runtime": "./scripts/cowork_linux.py",
32
33
  "./cowork-requirements": "./scripts/cowork-requirements.txt",
33
34
  "./types": {
34
35
  "types": "./dist/types/index.d.ts",
@@ -58,10 +59,12 @@
58
59
  "files": [
59
60
  "dist",
60
61
  "scripts/cowork_computer_use.py",
62
+ "scripts/cowork_linux.py",
61
63
  "scripts/cowork-requirements.txt"
62
64
  ],
63
65
  "scripts": {
64
66
  "build": "npm run build:ui && tsup && npm run build:copy-ui",
67
+ "prepare": "npm run build",
65
68
  "build:copy-ui": "cp -r src/reporters/ui-dist dist/reporters/",
66
69
  "build:ui": "node --import tsx src/reporters/build-ui.ts",
67
70
  "dev": "tsup --watch",
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """Bounded AT-SPI actions against an already prepared Linux Cowork desktop.
3
+
4
+ This module does not provision, authenticate, launch a desktop session, or collect
5
+ answers. The MST host binds and reads native sessions. JSON input arrives on stdin;
6
+ stdout contains only an allowlisted receipt, never prompt or accessibility text.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from urllib.parse import quote
17
+
18
+
19
+ class DriverFailure(RuntimeError):
20
+ pass
21
+
22
+
23
+ class Desktop:
24
+ def __init__(self) -> None:
25
+ import gi
26
+ gi.require_version("Atspi", "2.0")
27
+ from gi.repository import Atspi
28
+ self.api = Atspi
29
+
30
+ def application(self):
31
+ root = self.api.get_desktop(0)
32
+ matches = []
33
+ for index in range(root.get_child_count()):
34
+ node = root.get_child_at_index(index)
35
+ if node and (node.get_name() or "").casefold() in {"claude", "claude-desktop"}:
36
+ matches.append(node)
37
+ if len(matches) != 1:
38
+ raise DriverFailure("desktop_missing_or_ambiguous")
39
+ return matches[0]
40
+
41
+ def controls(self, names: set[str], roles: set[str], *, require_enabled: bool = True):
42
+ pending = [self.application()]
43
+ found = []
44
+ seen = 0
45
+ while pending:
46
+ node = pending.pop()
47
+ seen += 1
48
+ if seen > 5000:
49
+ raise DriverFailure("accessibility_tree_budget")
50
+ try:
51
+ states = node.get_state_set()
52
+ if (node.get_role_name() in roles and node.get_name() in names
53
+ and states.contains(self.api.StateType.VISIBLE)
54
+ and states.contains(self.api.StateType.SHOWING)
55
+ and (not require_enabled or (
56
+ states.contains(self.api.StateType.ENABLED)
57
+ and states.contains(self.api.StateType.SENSITIVE)))):
58
+ found.append(node)
59
+ pending.extend(node.get_child_at_index(i) for i in range(node.get_child_count()))
60
+ except DriverFailure:
61
+ raise
62
+ except Exception:
63
+ continue
64
+ return found
65
+
66
+ def selected(self, node) -> bool:
67
+ states = node.get_state_set()
68
+ return states.contains(self.api.StateType.CHECKED) or states.contains(self.api.StateType.SELECTED)
69
+
70
+ def activate(self, node) -> None:
71
+ if not node.is_action() or not node.do_action(0):
72
+ # Even a failed action acknowledgement may have had an effect.
73
+ raise DriverFailure("action_acknowledgement_uncertain")
74
+
75
+ def open_prompt(self, prompt: str, timeout: float) -> None:
76
+ configured_opener = os.environ.get("MST_COWORK_URL_OPENER")
77
+ if configured_opener is not None and not os.path.isabs(configured_opener):
78
+ raise DriverFailure("invalid_url_opener")
79
+ subprocess.run(
80
+ [configured_opener or "xdg-open", "claude://claude.ai/new?q=" + quote(prompt, safe="")],
81
+ check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=min(timeout, 15),
82
+ )
83
+
84
+
85
+ class Driver:
86
+ def __init__(self, desktop, timeout_ms: int, max_actions: int) -> None:
87
+ self.desktop = desktop
88
+ self.started = time.monotonic()
89
+ self.deadline = self.started + timeout_ms / 1000
90
+ self.max_actions = max_actions
91
+ self.actions = 0
92
+
93
+ def remaining(self) -> float:
94
+ remaining = self.deadline - time.monotonic()
95
+ if remaining <= 0:
96
+ raise DriverFailure("deadline_exceeded")
97
+ return remaining
98
+
99
+ def action(self, operation) -> None:
100
+ self.remaining()
101
+ if self.actions >= self.max_actions:
102
+ raise DriverFailure("action_budget_exhausted")
103
+ self.actions += 1
104
+ operation()
105
+
106
+ def receipt(self, status: str) -> dict:
107
+ return {"status": status, "action_count": self.actions,
108
+ "duration_ms": (time.monotonic() - self.started) * 1000}
109
+
110
+ def probe(self) -> dict:
111
+ controls = self.desktop.controls({"Cowork", "Start task"}, {"button", "radio button"})
112
+ if not controls:
113
+ raise DriverFailure("cowork_not_ready")
114
+ return self.receipt("ready")
115
+
116
+ def select_cowork(self) -> None:
117
+ requested = set()
118
+ while True:
119
+ self.remaining()
120
+ # The empty Cowork composer disables Start task until text is inserted.
121
+ # Its visible presence proves the mode, not permission to submit.
122
+ starts = self.desktop.controls({"Start task"}, {"button"}, require_enabled=False)
123
+ if len(starts) > 1:
124
+ raise DriverFailure("submission_control_ambiguous")
125
+ if starts:
126
+ return
127
+ radios = self.desktop.controls({"Cowork"}, {"radio button"})
128
+ if len(radios) > 1:
129
+ raise DriverFailure("cowork_control_ambiguous")
130
+ if radios:
131
+ if self.desktop.selected(radios[0]):
132
+ return
133
+ if "radio" not in requested:
134
+ requested.add("radio")
135
+ self.action(lambda: self.desktop.activate(radios[0]))
136
+ else:
137
+ tabs = self.desktop.controls({"Cowork"}, {"button"})
138
+ if len(tabs) > 1:
139
+ raise DriverFailure("cowork_control_ambiguous")
140
+ if tabs and "tab" not in requested:
141
+ requested.add("tab")
142
+ self.action(lambda: self.desktop.activate(tabs[0]))
143
+ time.sleep(min(0.1, self.remaining()))
144
+
145
+ def submit(self, prompt: str) -> dict:
146
+ if not isinstance(prompt, str) or not prompt.strip():
147
+ raise DriverFailure("invalid_prompt")
148
+ self.select_cowork()
149
+ self.action(lambda: self.desktop.open_prompt(prompt, self.remaining()))
150
+ while True:
151
+ self.remaining()
152
+ starts = self.desktop.controls({"Start task"}, {"button"})
153
+ if len(starts) > 1:
154
+ raise DriverFailure("submission_control_ambiguous")
155
+ if starts:
156
+ # Exactly one submit attempt. No key fallback or action retry.
157
+ self.action(lambda: self.desktop.activate(starts[0]))
158
+ return self.receipt("submitted")
159
+ time.sleep(min(0.1, self.remaining()))
160
+
161
+ def hitl(self, approve_writes: bool) -> dict:
162
+ names = {"Allow once", "Allow"}
163
+ if approve_writes:
164
+ names |= {"Always allow", "Allow always", "Full access", "Allow full access"}
165
+ controls = self.desktop.controls(names, {"button"})
166
+ if not controls:
167
+ return self.receipt("hitl_checked")
168
+ # Do not choose among unrelated prompts or continue arbitrary onboarding.
169
+ once = [node for node in controls if node.get_name() == "Allow once"]
170
+ selected = once if len(once) == 1 else controls
171
+ if len(selected) != 1:
172
+ raise DriverFailure("approval_control_ambiguous")
173
+ if not approve_writes:
174
+ # A generic UI Allow button gives no reliable read/write classification.
175
+ raise DriverFailure("approval_requires_explicit_write_policy")
176
+ self.action(lambda: self.desktop.activate(selected[0]))
177
+ return self.receipt("hitl_checked")
178
+
179
+
180
+ def main() -> int:
181
+ parser = argparse.ArgumentParser()
182
+ parser.add_argument("--mode", choices=["probe", "submit", "hitl"], required=True)
183
+ parser.add_argument("--timeout-ms", type=int, required=True)
184
+ parser.add_argument("--max-actions", type=int, default=24)
185
+ args = parser.parse_args()
186
+ driver = None
187
+ try:
188
+ if args.timeout_ms <= 0 or not 1 <= args.max_actions <= 64:
189
+ raise DriverFailure("invalid_budget")
190
+ payload = json.loads(sys.stdin.read(2 * 1024 * 1024))
191
+ if not isinstance(payload, dict):
192
+ raise DriverFailure("invalid_input")
193
+ driver = Driver(Desktop(), args.timeout_ms, args.max_actions)
194
+ if args.mode == "submit":
195
+ result = driver.submit(payload.get("prompt"))
196
+ elif args.mode == "hitl":
197
+ result = driver.hitl(payload.get("approveWriteTools") is True)
198
+ else:
199
+ result = driver.probe()
200
+ print(json.dumps(result))
201
+ return 0
202
+ except Exception as error:
203
+ result = driver.receipt("failed") if driver else {"status": "failed", "action_count": 0, "duration_ms": 0}
204
+ result["error"] = str(error) if isinstance(error, DriverFailure) else "desktop_driver_failed"
205
+ print(json.dumps(result))
206
+ return 1
207
+
208
+
209
+ if __name__ == "__main__":
210
+ raise SystemExit(main())