@geometra/mcp 1.19.7 → 1.19.8
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.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { connect, disconnect, sendListboxPick } from '../session.js';
|
|
4
|
+
describe('proxy-backed MCP actions', () => {
|
|
5
|
+
afterAll(() => {
|
|
6
|
+
disconnect();
|
|
7
|
+
});
|
|
8
|
+
it('waits for final listbox outcome instead of resolving on intermediate updates', async () => {
|
|
9
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
10
|
+
wss.on('connection', ws => {
|
|
11
|
+
ws.on('message', raw => {
|
|
12
|
+
const msg = JSON.parse(String(raw));
|
|
13
|
+
if (msg.type === 'resize') {
|
|
14
|
+
ws.send(JSON.stringify({
|
|
15
|
+
type: 'frame',
|
|
16
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
17
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
18
|
+
}));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (msg.type === 'listboxPick') {
|
|
22
|
+
ws.send(JSON.stringify({
|
|
23
|
+
type: 'frame',
|
|
24
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
25
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
26
|
+
}));
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
ws.send(JSON.stringify({
|
|
29
|
+
type: 'error',
|
|
30
|
+
requestId: msg.requestId,
|
|
31
|
+
message: 'listboxPick: no visible option matching \"Japan\"',
|
|
32
|
+
}));
|
|
33
|
+
}, 20);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
const port = await new Promise((resolve, reject) => {
|
|
38
|
+
wss.once('listening', () => {
|
|
39
|
+
const address = wss.address();
|
|
40
|
+
if (typeof address === 'object' && address)
|
|
41
|
+
resolve(address.port);
|
|
42
|
+
else
|
|
43
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
44
|
+
});
|
|
45
|
+
wss.once('error', reject);
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
49
|
+
await expect(sendListboxPick(session, 'Japan', {
|
|
50
|
+
fieldLabel: 'Country',
|
|
51
|
+
exact: true,
|
|
52
|
+
})).rejects.toThrow('listboxPick: no visible option matching "Japan"');
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
disconnect();
|
|
56
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
4
4
|
import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, } from './session.js';
|
|
5
5
|
export function createServer() {
|
|
6
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
6
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.8' }, { capabilities: { tools: {} } });
|
|
7
7
|
// ── connect ──────────────────────────────────────────────────
|
|
8
8
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
9
9
|
|
|
@@ -69,7 +69,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
// ── query ────────────────────────────────────────────────────
|
|
72
|
-
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, or text content. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, role, name, and tree path.
|
|
72
|
+
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, or text content. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, visibility / scroll-reveal hints, role, name, and tree path.
|
|
73
73
|
|
|
74
74
|
This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, {
|
|
75
75
|
id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
|
|
@@ -223,7 +223,7 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
|
|
|
223
223
|
});
|
|
224
224
|
server.tool('geometra_pick_listbox_option', `Pick an option from a custom dropdown / listbox / searchable combobox (Headless UI, React Select, Radix, Ashby-style custom selects, etc.). Requires \`@geometra/proxy\`.
|
|
225
225
|
|
|
226
|
-
Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying on coordinates. If the opened control is editable, MCP types \`query\` (or the option label by default) before selecting. Uses substring name match unless exact=true.`, {
|
|
226
|
+
Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying on coordinates. If the opened control is editable, MCP types \`query\` (or the option label by default) before selecting. Uses substring name match unless exact=true, prefers the popup nearest the opened field, and handles a few short affirmative/negative aliases such as \`Yes\` /\`No\` for consent-style copy.`, {
|
|
227
227
|
label: z.string().describe('Accessible name of the option (visible text or aria-label)'),
|
|
228
228
|
exact: z.boolean().optional().describe('Exact name match'),
|
|
229
229
|
openX: z.number().optional().describe('Click to open dropdown'),
|
|
@@ -450,12 +450,18 @@ function formatNode(node, viewport) {
|
|
|
450
450
|
const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
|
|
451
451
|
const visibleBottom = Math.min(viewport.height, node.bounds.y + node.bounds.height);
|
|
452
452
|
const hasVisibleIntersection = visibleRight > visibleLeft && visibleBottom > visibleTop;
|
|
453
|
+
const fullyVisible = node.bounds.x >= 0 &&
|
|
454
|
+
node.bounds.y >= 0 &&
|
|
455
|
+
node.bounds.x + node.bounds.width <= viewport.width &&
|
|
456
|
+
node.bounds.y + node.bounds.height <= viewport.height;
|
|
453
457
|
const centerX = hasVisibleIntersection
|
|
454
458
|
? Math.round((visibleLeft + visibleRight) / 2)
|
|
455
459
|
: Math.round(Math.min(Math.max(node.bounds.x + node.bounds.width / 2, 0), viewport.width));
|
|
456
460
|
const centerY = hasVisibleIntersection
|
|
457
461
|
? Math.round((visibleTop + visibleBottom) / 2)
|
|
458
462
|
: Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
|
|
463
|
+
const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
|
|
464
|
+
const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
|
|
459
465
|
return {
|
|
460
466
|
id: nodeIdForPath(node.path),
|
|
461
467
|
role: node.role,
|
|
@@ -471,6 +477,19 @@ function formatNode(node, viewport) {
|
|
|
471
477
|
x: centerX,
|
|
472
478
|
y: centerY,
|
|
473
479
|
},
|
|
480
|
+
visibility: {
|
|
481
|
+
intersectsViewport: hasVisibleIntersection,
|
|
482
|
+
fullyVisible,
|
|
483
|
+
offscreenAbove: node.bounds.y + node.bounds.height <= 0,
|
|
484
|
+
offscreenBelow: node.bounds.y >= viewport.height,
|
|
485
|
+
offscreenLeft: node.bounds.x + node.bounds.width <= 0,
|
|
486
|
+
offscreenRight: node.bounds.x >= viewport.width,
|
|
487
|
+
},
|
|
488
|
+
scrollHint: {
|
|
489
|
+
status: fullyVisible ? 'visible' : hasVisibleIntersection ? 'partial' : 'offscreen',
|
|
490
|
+
revealDeltaX,
|
|
491
|
+
revealDeltaY,
|
|
492
|
+
},
|
|
474
493
|
focusable: node.focusable,
|
|
475
494
|
...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
|
|
476
495
|
path: node.path,
|
package/dist/session.d.ts
CHANGED
|
@@ -223,6 +223,7 @@ export interface Session {
|
|
|
223
223
|
layout: Record<string, unknown> | null;
|
|
224
224
|
tree: Record<string, unknown> | null;
|
|
225
225
|
url: string;
|
|
226
|
+
updateRevision: number;
|
|
226
227
|
/** Present when this session owns a child geometra-proxy process (pageUrl connect). */
|
|
227
228
|
proxyChild?: ChildProcess;
|
|
228
229
|
}
|
package/dist/session.js
CHANGED
|
@@ -2,6 +2,8 @@ import WebSocket from 'ws';
|
|
|
2
2
|
import { spawnGeometraProxy } from './proxy-spawn.js';
|
|
3
3
|
let activeSession = null;
|
|
4
4
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
5
|
+
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
6
|
+
let nextRequestSequence = 0;
|
|
5
7
|
function shutdownPreviousSession() {
|
|
6
8
|
const prev = activeSession;
|
|
7
9
|
if (!prev)
|
|
@@ -30,7 +32,7 @@ export function connect(url) {
|
|
|
30
32
|
return new Promise((resolve, reject) => {
|
|
31
33
|
shutdownPreviousSession();
|
|
32
34
|
const ws = new WebSocket(url);
|
|
33
|
-
const session = { ws, layout: null, tree: null, url };
|
|
35
|
+
const session = { ws, layout: null, tree: null, url, updateRevision: 0 };
|
|
34
36
|
let resolved = false;
|
|
35
37
|
const timeout = setTimeout(() => {
|
|
36
38
|
if (!resolved) {
|
|
@@ -49,6 +51,7 @@ export function connect(url) {
|
|
|
49
51
|
if (msg.type === 'frame') {
|
|
50
52
|
session.layout = msg.layout;
|
|
51
53
|
session.tree = msg.tree;
|
|
54
|
+
session.updateRevision++;
|
|
52
55
|
if (!resolved) {
|
|
53
56
|
resolved = true;
|
|
54
57
|
clearTimeout(timeout);
|
|
@@ -58,6 +61,7 @@ export function connect(url) {
|
|
|
58
61
|
}
|
|
59
62
|
else if (msg.type === 'patch' && session.layout) {
|
|
60
63
|
applyPatches(session.layout, msg.patches);
|
|
64
|
+
session.updateRevision++;
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
catch { /* ignore malformed messages */ }
|
|
@@ -208,7 +212,7 @@ export function sendListboxPick(session, label, opts) {
|
|
|
208
212
|
payload.fieldLabel = opts.fieldLabel;
|
|
209
213
|
if (opts?.query)
|
|
210
214
|
payload.query = opts.query;
|
|
211
|
-
return sendAndWaitForUpdate(session, payload);
|
|
215
|
+
return sendAndWaitForUpdate(session, payload, LISTBOX_UPDATE_TIMEOUT_MS);
|
|
212
216
|
}
|
|
213
217
|
/** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
|
|
214
218
|
export function sendSelectOption(session, x, y, option) {
|
|
@@ -1258,40 +1262,55 @@ function applyPatches(layout, patches) {
|
|
|
1258
1262
|
node.height = patch.height;
|
|
1259
1263
|
}
|
|
1260
1264
|
}
|
|
1261
|
-
function sendAndWaitForUpdate(session, message) {
|
|
1265
|
+
function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
|
|
1262
1266
|
return new Promise((resolve, reject) => {
|
|
1263
1267
|
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
1264
1268
|
reject(new Error('Not connected'));
|
|
1265
1269
|
return;
|
|
1266
1270
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1271
|
+
const requestId = `req-${++nextRequestSequence}`;
|
|
1272
|
+
const startRevision = session.updateRevision;
|
|
1273
|
+
session.ws.send(JSON.stringify({ ...message, requestId }));
|
|
1274
|
+
waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
|
|
1269
1275
|
});
|
|
1270
1276
|
}
|
|
1271
|
-
function waitForNextUpdate(session) {
|
|
1277
|
+
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
|
|
1272
1278
|
return new Promise((resolve, reject) => {
|
|
1273
1279
|
const onMessage = (data) => {
|
|
1274
1280
|
try {
|
|
1275
1281
|
const msg = JSON.parse(String(data));
|
|
1282
|
+
const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
1283
|
+
if (requestId) {
|
|
1284
|
+
if (msg.type === 'error' && messageRequestId === requestId) {
|
|
1285
|
+
cleanup();
|
|
1286
|
+
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (msg.type === 'ack' && messageRequestId === requestId) {
|
|
1290
|
+
cleanup();
|
|
1291
|
+
resolve({
|
|
1292
|
+
status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
|
|
1293
|
+
timeoutMs,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1276
1298
|
if (msg.type === 'error') {
|
|
1277
1299
|
cleanup();
|
|
1278
1300
|
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1279
1301
|
return;
|
|
1280
1302
|
}
|
|
1281
1303
|
if (msg.type === 'frame') {
|
|
1282
|
-
session.layout = msg.layout;
|
|
1283
|
-
session.tree = msg.tree;
|
|
1284
1304
|
cleanup();
|
|
1285
|
-
resolve({ status: 'updated', timeoutMs
|
|
1305
|
+
resolve({ status: 'updated', timeoutMs });
|
|
1286
1306
|
}
|
|
1287
1307
|
else if (msg.type === 'patch' && session.layout) {
|
|
1288
|
-
applyPatches(session.layout, msg.patches);
|
|
1289
1308
|
cleanup();
|
|
1290
|
-
resolve({ status: 'updated', timeoutMs
|
|
1309
|
+
resolve({ status: 'updated', timeoutMs });
|
|
1291
1310
|
}
|
|
1292
1311
|
else if (msg.type === 'ack') {
|
|
1293
1312
|
cleanup();
|
|
1294
|
-
resolve({ status: 'acknowledged', timeoutMs
|
|
1313
|
+
resolve({ status: 'acknowledged', timeoutMs });
|
|
1295
1314
|
}
|
|
1296
1315
|
}
|
|
1297
1316
|
catch { /* ignore */ }
|
|
@@ -1299,8 +1318,8 @@ function waitForNextUpdate(session) {
|
|
|
1299
1318
|
// Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
|
|
1300
1319
|
const timeout = setTimeout(() => {
|
|
1301
1320
|
cleanup();
|
|
1302
|
-
resolve({ status: 'timed_out', timeoutMs
|
|
1303
|
-
},
|
|
1321
|
+
resolve({ status: 'timed_out', timeoutMs });
|
|
1322
|
+
}, timeoutMs);
|
|
1304
1323
|
function cleanup() {
|
|
1305
1324
|
clearTimeout(timeout);
|
|
1306
1325
|
session.ws.off('message', onMessage);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.8",
|
|
4
4
|
"description": "MCP server for Geometra — interact with running Geometra apps via the geometry protocol, no browser needed",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"ui-testing"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@geometra/proxy": "^1.19.
|
|
33
|
+
"@geometra/proxy": "^1.19.8",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|