@geometra/mcp 1.19.12 → 1.19.14
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/README.md +37 -21
- package/dist/__tests__/connect-utils.test.js +44 -2
- package/dist/__tests__/proxy-session-actions.test.js +78 -1
- package/dist/__tests__/server-batch-results.test.js +195 -2
- package/dist/__tests__/session-model.test.js +130 -1
- package/dist/proxy-spawn.js +80 -4
- package/dist/server.js +369 -8
- package/dist/session.d.ts +61 -1
- package/dist/session.js +262 -13
- package/package.json +2 -2
package/dist/session.js
CHANGED
|
@@ -3,6 +3,12 @@ import { spawnGeometraProxy } from './proxy-spawn.js';
|
|
|
3
3
|
let activeSession = null;
|
|
4
4
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
5
5
|
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
6
|
+
const FILL_BATCH_BASE_TIMEOUT_MS = 2500;
|
|
7
|
+
const FILL_BATCH_TEXT_FIELD_TIMEOUT_MS = 250;
|
|
8
|
+
const FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS = 450;
|
|
9
|
+
const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 200;
|
|
10
|
+
const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
|
|
11
|
+
const FILL_BATCH_MAX_TIMEOUT_MS = 30_000;
|
|
6
12
|
let nextRequestSequence = 0;
|
|
7
13
|
function shutdownPreviousSession() {
|
|
8
14
|
const prev = activeSession;
|
|
@@ -28,7 +34,7 @@ function shutdownPreviousSession() {
|
|
|
28
34
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
29
35
|
* layout/tree state is available immediately after connection.
|
|
30
36
|
*/
|
|
31
|
-
export function connect(url) {
|
|
37
|
+
export function connect(url, opts) {
|
|
32
38
|
return new Promise((resolve, reject) => {
|
|
33
39
|
shutdownPreviousSession();
|
|
34
40
|
const ws = new WebSocket(url);
|
|
@@ -42,8 +48,11 @@ export function connect(url) {
|
|
|
42
48
|
}
|
|
43
49
|
}, 10_000);
|
|
44
50
|
ws.on('open', () => {
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
if (opts?.skipInitialResize)
|
|
52
|
+
return;
|
|
53
|
+
const width = opts?.width ?? 1024;
|
|
54
|
+
const height = opts?.height ?? 768;
|
|
55
|
+
ws.send(JSON.stringify({ type: 'resize', width, height }));
|
|
47
56
|
});
|
|
48
57
|
ws.on('message', (data) => {
|
|
49
58
|
try {
|
|
@@ -107,7 +116,7 @@ export async function connectThroughProxy(options) {
|
|
|
107
116
|
slowMo: options.slowMo,
|
|
108
117
|
});
|
|
109
118
|
try {
|
|
110
|
-
const session = await connect(wsUrl);
|
|
119
|
+
const session = await connect(wsUrl, { skipInitialResize: true });
|
|
111
120
|
session.proxyChild = child;
|
|
112
121
|
return session;
|
|
113
122
|
}
|
|
@@ -127,6 +136,26 @@ export function getSession() {
|
|
|
127
136
|
export function disconnect() {
|
|
128
137
|
shutdownPreviousSession();
|
|
129
138
|
}
|
|
139
|
+
function estimateFillBatchTimeout(fields) {
|
|
140
|
+
let total = FILL_BATCH_BASE_TIMEOUT_MS;
|
|
141
|
+
for (const field of fields) {
|
|
142
|
+
switch (field.kind) {
|
|
143
|
+
case 'text':
|
|
144
|
+
total += FILL_BATCH_TEXT_FIELD_TIMEOUT_MS;
|
|
145
|
+
break;
|
|
146
|
+
case 'choice':
|
|
147
|
+
total += FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
148
|
+
break;
|
|
149
|
+
case 'toggle':
|
|
150
|
+
total += FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS;
|
|
151
|
+
break;
|
|
152
|
+
case 'file':
|
|
153
|
+
total += FILL_BATCH_FILE_FIELD_TIMEOUT_MS;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return Math.min(total, FILL_BATCH_MAX_TIMEOUT_MS);
|
|
158
|
+
}
|
|
130
159
|
export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
131
160
|
return new Promise((resolve) => {
|
|
132
161
|
const check = () => {
|
|
@@ -263,6 +292,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
|
|
|
263
292
|
payload.query = opts.query;
|
|
264
293
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
265
294
|
}
|
|
295
|
+
/** Fill several semantic form fields in one proxy-side batch. */
|
|
296
|
+
export function sendFillFields(session, fields, timeoutMs = estimateFillBatchTimeout(fields)) {
|
|
297
|
+
return sendAndWaitForUpdate(session, { type: 'fillFields', fields }, timeoutMs);
|
|
298
|
+
}
|
|
266
299
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
267
300
|
export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
|
|
268
301
|
const payload = { type: 'listboxPick', label };
|
|
@@ -387,6 +420,9 @@ function decodePath(encoded) {
|
|
|
387
420
|
export function nodeIdForPath(path) {
|
|
388
421
|
return `n:${encodePath(path)}`;
|
|
389
422
|
}
|
|
423
|
+
function formFieldIdForPath(path) {
|
|
424
|
+
return `ff:${encodePath(path)}`;
|
|
425
|
+
}
|
|
390
426
|
function sectionPrefix(kind) {
|
|
391
427
|
if (kind === 'landmark')
|
|
392
428
|
return 'lm';
|
|
@@ -800,7 +836,9 @@ function nearestPromptText(container, target) {
|
|
|
800
836
|
const dy = Math.max(0, target.bounds.y - candidate.bounds.y);
|
|
801
837
|
const dx = Math.abs(target.bounds.x - candidate.bounds.x);
|
|
802
838
|
const headingBonus = candidate.role === 'heading' ? -32 : 0;
|
|
803
|
-
|
|
839
|
+
const questionBonus = /\?\s*$/.test(text) ? -160 : 0;
|
|
840
|
+
const lengthPenalty = text.length > 90 ? 80 : text.length > 60 ? 40 : text.length > 45 ? 20 : 0;
|
|
841
|
+
return { text, score: dy * 4 + dx + headingBonus + questionBonus + lengthPenalty };
|
|
804
842
|
})
|
|
805
843
|
.filter((candidate) => !!candidate)
|
|
806
844
|
.sort((a, b) => a.score - b.score)[0];
|
|
@@ -809,13 +847,19 @@ function nearestPromptText(container, target) {
|
|
|
809
847
|
function nodeContext(root, node) {
|
|
810
848
|
const ancestors = ancestorNodes(root, node.path);
|
|
811
849
|
let prompt;
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
850
|
+
const promptEligibleNode = node.role === 'radio' || node.role === 'button';
|
|
851
|
+
if (promptEligibleNode) {
|
|
852
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
853
|
+
const ancestor = ancestors[index];
|
|
854
|
+
const grouped = countGroupedChoiceControls(ancestor) >= 2;
|
|
855
|
+
const eligiblePromptContainer = (ancestor.role === 'group' && ancestor.path.length > 0) ||
|
|
856
|
+
ancestor.role === 'dialog' ||
|
|
857
|
+
(ancestor.role === 'form' && grouped);
|
|
858
|
+
if (eligiblePromptContainer) {
|
|
859
|
+
prompt = nearestPromptText(ancestor, node);
|
|
860
|
+
if (prompt)
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
819
863
|
}
|
|
820
864
|
}
|
|
821
865
|
let section;
|
|
@@ -868,6 +912,197 @@ function toActionModel(root, node, includeBounds = true) {
|
|
|
868
912
|
...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
|
|
869
913
|
};
|
|
870
914
|
}
|
|
915
|
+
function compactSchemaContext(context, label) {
|
|
916
|
+
if (!context)
|
|
917
|
+
return undefined;
|
|
918
|
+
const out = {};
|
|
919
|
+
if (context.prompt && normalizeUiText(context.prompt) !== normalizeUiText(label))
|
|
920
|
+
out.prompt = context.prompt;
|
|
921
|
+
if (context.section)
|
|
922
|
+
out.section = context.section;
|
|
923
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
924
|
+
}
|
|
925
|
+
function compactSchemaValue(value, inlineLimit = 80) {
|
|
926
|
+
const normalized = sanitizeInlineName(value, Math.max(120, inlineLimit + 32));
|
|
927
|
+
if (!normalized)
|
|
928
|
+
return {};
|
|
929
|
+
return normalized.length <= inlineLimit
|
|
930
|
+
? { value: normalized }
|
|
931
|
+
: { valueLength: normalized.length };
|
|
932
|
+
}
|
|
933
|
+
function schemaOptionLabel(node) {
|
|
934
|
+
return sanitizeFieldName(node.name, 80) ?? sanitizeInlineName(node.name, 80);
|
|
935
|
+
}
|
|
936
|
+
function isGroupedChoiceControl(node) {
|
|
937
|
+
return node.role === 'radio' || node.role === 'checkbox' || (node.role === 'button' && node.focusable);
|
|
938
|
+
}
|
|
939
|
+
function groupedChoiceForNode(root, formNode, seed) {
|
|
940
|
+
const context = nodeContext(root, seed);
|
|
941
|
+
const prompt = context?.prompt;
|
|
942
|
+
if (!prompt)
|
|
943
|
+
return null;
|
|
944
|
+
const matchesPrompt = (candidate) => {
|
|
945
|
+
if (!isGroupedChoiceControl(candidate))
|
|
946
|
+
return false;
|
|
947
|
+
return nodeContext(root, candidate)?.prompt === prompt;
|
|
948
|
+
};
|
|
949
|
+
const ancestors = ancestorNodes(root, seed.path);
|
|
950
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
951
|
+
const ancestor = ancestors[index];
|
|
952
|
+
if (ancestor.role === 'form')
|
|
953
|
+
continue;
|
|
954
|
+
const controls = sortByBounds(collectDescendants(ancestor, matchesPrompt));
|
|
955
|
+
if (controls.length >= 2) {
|
|
956
|
+
return { container: ancestor, prompt, controls };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (seed.role !== 'radio' && seed.role !== 'button')
|
|
960
|
+
return null;
|
|
961
|
+
const controls = sortByBounds(collectDescendants(formNode, matchesPrompt));
|
|
962
|
+
return controls.length >= 2 ? { container: formNode, prompt, controls } : null;
|
|
963
|
+
}
|
|
964
|
+
function simpleSchemaField(root, node) {
|
|
965
|
+
const context = nodeContext(root, node);
|
|
966
|
+
const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
|
|
967
|
+
if (!label)
|
|
968
|
+
return null;
|
|
969
|
+
return {
|
|
970
|
+
id: formFieldIdForPath(node.path),
|
|
971
|
+
kind: node.role === 'combobox' ? 'choice' : 'text',
|
|
972
|
+
label,
|
|
973
|
+
...(node.state?.required ? { required: true } : {}),
|
|
974
|
+
...(node.state?.invalid ? { invalid: true } : {}),
|
|
975
|
+
...compactSchemaValue(node.value, 72),
|
|
976
|
+
...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function groupedSchemaField(root, grouped) {
|
|
980
|
+
const optionEntries = grouped.controls
|
|
981
|
+
.map(control => ({
|
|
982
|
+
label: schemaOptionLabel(control),
|
|
983
|
+
selected: control.state?.checked === true || control.state?.selected === true,
|
|
984
|
+
role: control.role,
|
|
985
|
+
}))
|
|
986
|
+
.filter((entry) => !!entry.label);
|
|
987
|
+
if (optionEntries.length < 2)
|
|
988
|
+
return null;
|
|
989
|
+
const options = dedupeStrings(optionEntries.map(entry => entry.label), 16);
|
|
990
|
+
const selectedOptions = dedupeStrings(optionEntries.filter(entry => entry.selected).map(entry => entry.label), 16);
|
|
991
|
+
const radioLike = optionEntries.every(entry => entry.role === 'radio' || entry.role === 'button');
|
|
992
|
+
const context = nodeContext(root, grouped.controls[0]);
|
|
993
|
+
return {
|
|
994
|
+
id: formFieldIdForPath(grouped.container.path),
|
|
995
|
+
kind: radioLike ? 'choice' : 'multi_choice',
|
|
996
|
+
label: grouped.prompt,
|
|
997
|
+
...(grouped.controls.some(control => control.state?.required) ? { required: true } : {}),
|
|
998
|
+
...(grouped.controls.some(control => control.state?.invalid) ? { invalid: true } : {}),
|
|
999
|
+
...(radioLike
|
|
1000
|
+
? {
|
|
1001
|
+
...(selectedOptions[0] ? { value: selectedOptions[0] } : {}),
|
|
1002
|
+
}
|
|
1003
|
+
: {
|
|
1004
|
+
...(selectedOptions.length > 0 ? { values: selectedOptions } : {}),
|
|
1005
|
+
}),
|
|
1006
|
+
optionCount: options.length,
|
|
1007
|
+
options,
|
|
1008
|
+
...(compactSchemaContext(context, grouped.prompt) ? { context: compactSchemaContext(context, grouped.prompt) } : {}),
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
function toggleSchemaField(root, node) {
|
|
1012
|
+
const label = schemaOptionLabel(node);
|
|
1013
|
+
if (!label)
|
|
1014
|
+
return null;
|
|
1015
|
+
const context = nodeContext(root, node);
|
|
1016
|
+
const controlType = node.role === 'radio' ? 'radio' : 'checkbox';
|
|
1017
|
+
return {
|
|
1018
|
+
id: formFieldIdForPath(node.path),
|
|
1019
|
+
kind: 'toggle',
|
|
1020
|
+
label,
|
|
1021
|
+
controlType,
|
|
1022
|
+
...(node.state?.required ? { required: true } : {}),
|
|
1023
|
+
...(node.state?.invalid ? { invalid: true } : {}),
|
|
1024
|
+
...(node.state?.checked !== undefined ? { checked: node.state.checked === true } : {}),
|
|
1025
|
+
...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
function buildFormSchemaForNode(root, formNode, options) {
|
|
1029
|
+
const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.role === 'textbox' ||
|
|
1030
|
+
candidate.role === 'combobox' ||
|
|
1031
|
+
candidate.role === 'checkbox' ||
|
|
1032
|
+
candidate.role === 'radio' ||
|
|
1033
|
+
(candidate.role === 'button' && candidate.focusable)));
|
|
1034
|
+
const consumed = new Set();
|
|
1035
|
+
const fields = [];
|
|
1036
|
+
for (const candidate of candidates) {
|
|
1037
|
+
const candidateKey = pathKey(candidate.path);
|
|
1038
|
+
if (consumed.has(candidateKey))
|
|
1039
|
+
continue;
|
|
1040
|
+
if (candidate.role === 'textbox' || candidate.role === 'combobox') {
|
|
1041
|
+
const field = simpleSchemaField(root, candidate);
|
|
1042
|
+
if (field)
|
|
1043
|
+
fields.push(field);
|
|
1044
|
+
consumed.add(candidateKey);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const grouped = groupedChoiceForNode(root, formNode, candidate);
|
|
1048
|
+
if (grouped && grouped.controls.some(control => pathKey(control.path) === candidateKey)) {
|
|
1049
|
+
const field = groupedSchemaField(root, grouped);
|
|
1050
|
+
for (const control of grouped.controls)
|
|
1051
|
+
consumed.add(pathKey(control.path));
|
|
1052
|
+
if (field)
|
|
1053
|
+
fields.push(field);
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
if (candidate.role === 'checkbox' || candidate.role === 'radio') {
|
|
1057
|
+
const field = toggleSchemaField(root, candidate);
|
|
1058
|
+
if (field)
|
|
1059
|
+
fields.push(field);
|
|
1060
|
+
consumed.add(candidateKey);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
const compactFields = trimSchemaFieldContexts(fields);
|
|
1064
|
+
const filteredFields = compactFields.filter(field => {
|
|
1065
|
+
if (options?.onlyRequiredFields && !field.required)
|
|
1066
|
+
return false;
|
|
1067
|
+
if (options?.onlyInvalidFields && !field.invalid)
|
|
1068
|
+
return false;
|
|
1069
|
+
return true;
|
|
1070
|
+
});
|
|
1071
|
+
const maxFields = options?.maxFields ?? filteredFields.length;
|
|
1072
|
+
const pageFields = filteredFields.slice(0, maxFields);
|
|
1073
|
+
const name = sectionDisplayName(formNode, 'form');
|
|
1074
|
+
return {
|
|
1075
|
+
formId: sectionIdForPath('form', formNode.path),
|
|
1076
|
+
...(name ? { name } : {}),
|
|
1077
|
+
fieldCount: compactFields.length,
|
|
1078
|
+
requiredCount: compactFields.filter(field => field.required).length,
|
|
1079
|
+
invalidCount: compactFields.filter(field => field.invalid).length,
|
|
1080
|
+
fields: pageFields,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
function trimSchemaFieldContexts(fields) {
|
|
1084
|
+
const labelCounts = new Map();
|
|
1085
|
+
for (const field of fields) {
|
|
1086
|
+
const key = normalizeUiText(field.label);
|
|
1087
|
+
labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
|
|
1088
|
+
}
|
|
1089
|
+
return fields.map(field => {
|
|
1090
|
+
if (!field.context)
|
|
1091
|
+
return field;
|
|
1092
|
+
const trimmed = {};
|
|
1093
|
+
if (field.context.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
|
|
1094
|
+
trimmed.prompt = field.context.prompt;
|
|
1095
|
+
}
|
|
1096
|
+
if ((labelCounts.get(normalizeUiText(field.label)) ?? 0) > 1 && field.context.section) {
|
|
1097
|
+
trimmed.section = field.context.section;
|
|
1098
|
+
}
|
|
1099
|
+
if (Object.keys(trimmed).length === 0) {
|
|
1100
|
+
const { context: _context, ...rest } = field;
|
|
1101
|
+
return rest;
|
|
1102
|
+
}
|
|
1103
|
+
return { ...field, context: trimmed };
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
871
1106
|
function toLandmarkModel(node) {
|
|
872
1107
|
const name = sectionDisplayName(node, 'landmark');
|
|
873
1108
|
return {
|
|
@@ -991,6 +1226,15 @@ export function buildPageModel(root, options) {
|
|
|
991
1226
|
archetypes: inferPageArchetypes(baseModel),
|
|
992
1227
|
};
|
|
993
1228
|
}
|
|
1229
|
+
export function buildFormSchemas(root, options) {
|
|
1230
|
+
const forms = sortByBounds([
|
|
1231
|
+
...(root.role === 'form' ? [root] : []),
|
|
1232
|
+
...collectDescendants(root, candidate => candidate.role === 'form'),
|
|
1233
|
+
]);
|
|
1234
|
+
return forms
|
|
1235
|
+
.filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
|
|
1236
|
+
.map(form => buildFormSchemaForNode(root, form, options));
|
|
1237
|
+
}
|
|
994
1238
|
function headingModels(node, maxHeadings, includeBounds) {
|
|
995
1239
|
const headings = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
|
|
996
1240
|
return headings.slice(0, maxHeadings).map(heading => ({
|
|
@@ -1566,6 +1810,7 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1566
1810
|
resolve({
|
|
1567
1811
|
status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
|
|
1568
1812
|
timeoutMs,
|
|
1813
|
+
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
1569
1814
|
});
|
|
1570
1815
|
}
|
|
1571
1816
|
return;
|
|
@@ -1585,7 +1830,11 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1585
1830
|
}
|
|
1586
1831
|
else if (msg.type === 'ack') {
|
|
1587
1832
|
cleanup();
|
|
1588
|
-
resolve({
|
|
1833
|
+
resolve({
|
|
1834
|
+
status: 'acknowledged',
|
|
1835
|
+
timeoutMs,
|
|
1836
|
+
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
1837
|
+
});
|
|
1589
1838
|
}
|
|
1590
1839
|
}
|
|
1591
1840
|
catch { /* ignore */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.14",
|
|
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.14",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|