@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/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
- // Send initial resize so server computes layout
46
- ws.send(JSON.stringify({ type: 'resize', width: 1024, height: 768 }));
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
- return { text, score: dy * 4 + dx + headingBonus };
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
- for (let index = ancestors.length - 1; index >= 0; index--) {
813
- const ancestor = ancestors[index];
814
- const grouped = countGroupedChoiceControls(ancestor) >= 2;
815
- if (grouped || ancestor.role === 'group' || ancestor.role === 'form' || ancestor.role === 'dialog') {
816
- prompt = nearestPromptText(ancestor, node);
817
- if (prompt)
818
- break;
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({ status: 'acknowledged', timeoutMs });
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.12",
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.12",
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"