@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/dist/cli/index.js +371 -63
- package/dist/fixtures/mcp.js +1 -1
- package/dist/fixtures/mcp.js.map +1 -1
- package/dist/index.cjs +7794 -7025
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -1
- package/dist/index.d.ts +71 -1
- package/dist/index.js +7801 -7033
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/scripts/cowork_linux.py +210 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleanwork/mcp-server-tester",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
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())
|