@geometra/mcp 1.63.0 → 1.64.0

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
@@ -37,6 +37,7 @@ const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
37
37
  const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
38
38
  const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
39
39
  const SESSION_RECONNECT_TIMEOUT_MS = 5_000;
40
+ const PROXY_PROTOCOL_VERSION = 2;
40
41
  let nextRequestSequence = 0;
41
42
  function invalidateSessionCaches(session) {
42
43
  session.cachedA11y = null;
@@ -854,7 +855,7 @@ export function connect(url, opts) {
854
855
  if (!opts?.skipInitialResize) {
855
856
  const width = opts?.width ?? 1024;
856
857
  const height = opts?.height ?? 768;
857
- ws.send(JSON.stringify({ type: 'resize', width, height }));
858
+ ws.send(JSON.stringify({ type: 'resize', width, height, protocolVersion: PROXY_PROTOCOL_VERSION }));
858
859
  }
859
860
  if (opts?.awaitInitialFrame === false && !resolved) {
860
861
  resolved = true;
@@ -1279,7 +1280,7 @@ async function ensureSessionConnected(session) {
1279
1280
  async function sendResizeAndWaitForUpdate(session, width, height, timeoutMs = 5_000) {
1280
1281
  await ensureSessionConnected(session);
1281
1282
  const startRevision = session.updateRevision;
1282
- session.ws.send(JSON.stringify({ type: 'resize', width, height }));
1283
+ session.ws.send(JSON.stringify({ type: 'resize', width, height, protocolVersion: PROXY_PROTOCOL_VERSION }));
1283
1284
  return await waitForNextUpdate(session, timeoutMs, undefined, startRevision);
1284
1285
  }
1285
1286
  /**
@@ -1303,6 +1304,7 @@ export function sendType(session, text, timeoutMs) {
1303
1304
  for (const char of text) {
1304
1305
  const keyEvent = {
1305
1306
  type: 'key',
1307
+ protocolVersion: PROXY_PROTOCOL_VERSION,
1306
1308
  eventType: 'onKeyDown',
1307
1309
  key: char,
1308
1310
  code: `Key${char.toUpperCase()}`,
@@ -1345,6 +1347,10 @@ export function sendFileUpload(session, paths, opts, timeoutMs) {
1345
1347
  }
1346
1348
  if (opts?.fieldLabel)
1347
1349
  payload.fieldLabel = opts.fieldLabel;
1350
+ if (opts?.fieldId)
1351
+ payload.fieldId = opts.fieldId;
1352
+ if (opts?.fieldKey)
1353
+ payload.fieldKey = opts.fieldKey;
1348
1354
  if (opts?.exact !== undefined)
1349
1355
  payload.exact = opts.exact;
1350
1356
  if (opts?.strategy)
@@ -1366,6 +1372,8 @@ export function sendFieldText(session, fieldLabel, value, opts, timeoutMs) {
1366
1372
  payload.exact = opts.exact;
1367
1373
  if (opts?.fieldId)
1368
1374
  payload.fieldId = opts.fieldId;
1375
+ if (opts?.fieldKey)
1376
+ payload.fieldKey = opts.fieldKey;
1369
1377
  if (opts?.typingDelayMs !== undefined)
1370
1378
  payload.typingDelayMs = opts.typingDelayMs;
1371
1379
  if (opts?.imeFriendly !== undefined)
@@ -1387,6 +1395,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
1387
1395
  payload.choiceType = opts.choiceType;
1388
1396
  if (opts?.fieldId)
1389
1397
  payload.fieldId = opts.fieldId;
1398
+ if (opts?.fieldKey)
1399
+ payload.fieldKey = opts.fieldKey;
1400
+ if (opts?.optionIndex !== undefined)
1401
+ payload.optionIndex = opts.optionIndex;
1390
1402
  return sendAndWaitForUpdate(session, payload, timeoutMs);
1391
1403
  }
1392
1404
  /** Fill several semantic form fields in one proxy-side batch. */
@@ -1425,6 +1437,10 @@ export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE
1425
1437
  payload.fieldLabel = opts.fieldLabel;
1426
1438
  if (opts?.query)
1427
1439
  payload.query = opts.query;
1440
+ if (opts?.fieldId)
1441
+ payload.fieldId = opts.fieldId;
1442
+ if (opts?.fieldKey)
1443
+ payload.fieldKey = opts.fieldKey;
1428
1444
  return sendAndWaitForUpdate(session, payload, timeoutMs);
1429
1445
  }
1430
1446
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
@@ -1445,6 +1461,12 @@ export function sendSetChecked(session, label, opts, timeoutMs) {
1445
1461
  payload.exact = opts.exact;
1446
1462
  if (opts?.controlType)
1447
1463
  payload.controlType = opts.controlType;
1464
+ if (opts?.fieldKey)
1465
+ payload.fieldKey = opts.fieldKey;
1466
+ if (opts?.contextText)
1467
+ payload.contextText = opts.contextText;
1468
+ if (opts?.sectionText)
1469
+ payload.sectionText = opts.sectionText;
1448
1470
  return sendAndWaitForUpdate(session, payload, timeoutMs);
1449
1471
  }
1450
1472
  /** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
@@ -2223,6 +2245,26 @@ function buildFieldFormat(node) {
2223
2245
  format.autocomplete = m.autocomplete;
2224
2246
  return Object.keys(format).length > 0 ? format : undefined;
2225
2247
  }
2248
+ function nativeSchemaOptions(node, fieldIdentity) {
2249
+ const options = node.meta?.options;
2250
+ if (!options)
2251
+ return undefined;
2252
+ return options.map(option => ({
2253
+ id: `${fieldIdentity}:option:${option.index}`,
2254
+ value: option.value,
2255
+ label: option.label,
2256
+ index: option.index,
2257
+ ...(option.disabled ? { disabled: true } : {}),
2258
+ ...(option.selected ? { selected: true } : {}),
2259
+ }));
2260
+ }
2261
+ function uniqueSchemaFieldKey(root, node) {
2262
+ const fieldKey = node.meta?.controlKey;
2263
+ if (!fieldKey)
2264
+ return undefined;
2265
+ const matches = collectDescendants(root, candidate => candidate.meta?.controlKey === fieldKey);
2266
+ return matches.length === 1 ? fieldKey : undefined;
2267
+ }
2226
2268
  function simpleSchemaField(root, node) {
2227
2269
  const context = nodeContextForNode(root, node);
2228
2270
  const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
@@ -2251,15 +2293,25 @@ function simpleSchemaField(root, node) {
2251
2293
  ? 'select'
2252
2294
  : 'listbox'
2253
2295
  : undefined;
2296
+ const fieldKey = uniqueSchemaFieldKey(root, node);
2297
+ const id = fieldKey ?? formFieldIdForPath(node.path);
2254
2298
  const format = buildFieldFormat(node);
2299
+ const optionDetails = classifiedChoiceType === 'select' ? nativeSchemaOptions(node, id) : undefined;
2300
+ const enabledOptions = optionDetails
2301
+ ? dedupeStrings(optionDetails.filter(option => !option.disabled).map(option => option.label), 64)
2302
+ : undefined;
2255
2303
  return {
2256
- id: formFieldIdForPath(node.path),
2304
+ id,
2305
+ ...(fieldKey ? { fieldKey } : {}),
2257
2306
  kind: classifiedRole === 'combobox' ? 'choice' : 'text',
2258
2307
  label,
2259
2308
  ...(classifiedChoiceType ? { choiceType: classifiedChoiceType } : {}),
2260
2309
  ...(node.state?.required ? { required: true } : {}),
2261
2310
  ...(node.state?.invalid ? { invalid: true } : {}),
2262
2311
  ...compactSchemaValue(node.value, 72),
2312
+ ...(optionDetails ? { optionCount: optionDetails.length, optionDetails } : {}),
2313
+ ...(enabledOptions && enabledOptions.length > 0 ? { options: enabledOptions } : {}),
2314
+ ...(enabledOptions && computeOptionAliases(enabledOptions) ? { aliases: computeOptionAliases(enabledOptions) } : {}),
2263
2315
  ...(format ? { format } : {}),
2264
2316
  ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
2265
2317
  };
@@ -2304,8 +2356,10 @@ function toggleSchemaField(root, node) {
2304
2356
  return null;
2305
2357
  const context = nodeContextForNode(root, node);
2306
2358
  const controlType = node.role === 'radio' ? 'radio' : 'checkbox';
2359
+ const fieldKey = uniqueSchemaFieldKey(root, node);
2307
2360
  return {
2308
- id: formFieldIdForPath(node.path),
2361
+ id: fieldKey ?? formFieldIdForPath(node.path),
2362
+ ...(fieldKey ? { fieldKey } : {}),
2309
2363
  kind: 'toggle',
2310
2364
  label,
2311
2365
  controlType,
@@ -2330,7 +2384,10 @@ function detectFormSections(formNode, fields) {
2330
2384
  return [];
2331
2385
  const fieldIdToPath = new Map();
2332
2386
  for (const field of fields) {
2333
- const parsed = parseFormFieldId(field.id);
2387
+ const authoredMatches = field.fieldKey
2388
+ ? collectDescendants(formNode, node => node.meta?.controlKey === field.fieldKey)
2389
+ : [];
2390
+ const parsed = authoredMatches.length === 1 ? authoredMatches[0].path : parseFormFieldId(field.id);
2334
2391
  if (parsed)
2335
2392
  fieldIdToPath.set(field.id, parsed);
2336
2393
  }
@@ -2428,6 +2485,7 @@ function presentFormSchemaFields(fields, options) {
2428
2485
  next.booleanChoice = true;
2429
2486
  if (!includeOptions) {
2430
2487
  delete next.options;
2488
+ delete next.optionDetails;
2431
2489
  delete next.aliases;
2432
2490
  }
2433
2491
  if (includeContext === 'none') {
@@ -2761,6 +2819,135 @@ export function buildFormSchemas(root, options) {
2761
2819
  .filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
2762
2820
  .map(form => buildFormSchemaForNode(root, form, options));
2763
2821
  }
2822
+ export function buildFormGraphs(root, options) {
2823
+ const pageUrl = typeof root.meta?.pageUrl === 'string' && root.meta.pageUrl.length > 0 ? root.meta.pageUrl : undefined;
2824
+ return buildFormSchemas(root, {
2825
+ ...options,
2826
+ includeOptions: true,
2827
+ }).map(schema => formSchemaToFormGraph(schema, pageUrl));
2828
+ }
2829
+ export function formSchemaToFormGraph(schema, pageUrl) {
2830
+ const sourceId = 'geometra-page';
2831
+ const formSlug = slugPathSegment(schema.name ?? schema.formId);
2832
+ const pathCounts = new Map();
2833
+ return {
2834
+ formgraph: '0.1',
2835
+ id: `geometra:${schema.formId}`,
2836
+ title: schema.name ?? `Geometra form ${schema.formId}`,
2837
+ description: 'FormGraph-compatible projection of a Geometra form schema.',
2838
+ sources: [
2839
+ {
2840
+ id: sourceId,
2841
+ kind: 'html',
2842
+ title: schema.name ?? 'Geometra-discovered web form',
2843
+ ...(pageUrl ? { url: pageUrl } : {}),
2844
+ },
2845
+ ],
2846
+ fields: schema.fields.map(field => formSchemaFieldToFormGraphField(field, {
2847
+ formSlug,
2848
+ sourceId,
2849
+ pathCounts,
2850
+ })),
2851
+ evidence: [],
2852
+ dependencies: [],
2853
+ review: {
2854
+ autoSubmitAllowed: false,
2855
+ requiredBeforeSubmit: true,
2856
+ },
2857
+ metadata: {
2858
+ producer: 'geometra',
2859
+ geometra: {
2860
+ formId: schema.formId,
2861
+ fieldCount: schema.fieldCount,
2862
+ requiredCount: schema.requiredCount,
2863
+ invalidCount: schema.invalidCount,
2864
+ ...(schema.sections ? { sections: schema.sections } : {}),
2865
+ },
2866
+ },
2867
+ };
2868
+ }
2869
+ function formSchemaFieldToFormGraphField(field, opts) {
2870
+ const basePath = `web.forms.${opts.formSlug}.${slugPathSegment(field.label || field.id)}`;
2871
+ const path = uniquePath(basePath, opts.pathCounts);
2872
+ const aliases = fieldAliases(field);
2873
+ const options = field.optionDetails
2874
+ ?.filter(option => !option.disabled)
2875
+ .map(option => ({ value: option.value, label: option.label }))
2876
+ ?? field.options?.map(option => ({ value: option, label: option }));
2877
+ const inputType = field.format?.inputType?.toLowerCase();
2878
+ const metadata = {
2879
+ geometra: {
2880
+ fieldId: field.id,
2881
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
2882
+ kind: field.kind,
2883
+ ...(field.choiceType ? { choiceType: field.choiceType } : {}),
2884
+ ...(field.controlType ? { controlType: field.controlType } : {}),
2885
+ ...(field.booleanChoice ? { booleanChoice: true } : {}),
2886
+ ...(field.invalid ? { invalid: true } : {}),
2887
+ ...(field.context ? { context: field.context } : {}),
2888
+ ...(field.optionDetails ? { optionDetails: field.optionDetails } : {}),
2889
+ },
2890
+ };
2891
+ return {
2892
+ id: field.fieldKey ?? field.id,
2893
+ path,
2894
+ label: field.label,
2895
+ kind: formGraphFieldKind(field),
2896
+ ...(field.required ? { required: true } : {}),
2897
+ ...(field.invalid ? { reviewRequired: true } : {}),
2898
+ ...(aliases.length > 0 ? { aliases } : {}),
2899
+ ...(options && options.length > 0 ? { options } : {}),
2900
+ ...(field.format?.pattern ? { constraints: { pattern: field.format.pattern } } : {}),
2901
+ sourceAnchors: [
2902
+ {
2903
+ sourceId: opts.sourceId,
2904
+ kind: 'html',
2905
+ fieldName: field.fieldKey ?? field.id,
2906
+ pointer: `geometra:${field.fieldKey ?? field.id}`,
2907
+ },
2908
+ ],
2909
+ ...(inputType ? { metadata: { ...metadata, inputType } } : { metadata }),
2910
+ };
2911
+ }
2912
+ function formGraphFieldKind(field) {
2913
+ if (field.kind === 'toggle' || field.booleanChoice)
2914
+ return 'boolean';
2915
+ if (field.kind === 'choice' || field.kind === 'multi_choice')
2916
+ return 'enum';
2917
+ const inputType = field.format?.inputType?.toLowerCase();
2918
+ if (inputType === 'email')
2919
+ return 'email';
2920
+ if (inputType === 'tel' || inputType === 'phone')
2921
+ return 'phone';
2922
+ if (inputType === 'date')
2923
+ return 'date';
2924
+ if (inputType === 'number')
2925
+ return 'number';
2926
+ if (field.valueLength !== undefined && field.valueLength > 120)
2927
+ return 'textarea';
2928
+ return 'text';
2929
+ }
2930
+ function fieldAliases(field) {
2931
+ const values = new Set();
2932
+ if (field.format?.placeholder)
2933
+ values.add(field.format.placeholder);
2934
+ if (field.context?.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
2935
+ values.add(field.context.prompt);
2936
+ }
2937
+ return [...values].filter(value => value.trim().length > 0);
2938
+ }
2939
+ function uniquePath(basePath, seen) {
2940
+ const count = seen.get(basePath) ?? 0;
2941
+ seen.set(basePath, count + 1);
2942
+ return count === 0 ? basePath : `${basePath}.${count + 1}`;
2943
+ }
2944
+ function slugPathSegment(value) {
2945
+ const normalized = normalizeUiText(value)
2946
+ .toLowerCase()
2947
+ .replace(/[^a-z0-9]+/g, '.')
2948
+ .replace(/^\.+|\.+$/g, '');
2949
+ return normalized || 'field';
2950
+ }
2764
2951
  /**
2765
2952
  * Required-field snapshot for automation: every required field in a form, including
2766
2953
  * offscreen entries, annotated with visibility and scroll hints so agents do not
@@ -2779,7 +2966,10 @@ export function buildFormRequiredSnapshot(root, options) {
2779
2966
  const formNode = parsedForm ? findNodeByPath(root, parsedForm.path) : null;
2780
2967
  const fields = schema.fields
2781
2968
  .map(field => {
2782
- const fieldPath = parseFormFieldId(field.id);
2969
+ const authoredMatches = field.fieldKey && formNode
2970
+ ? collectDescendants(formNode, node => node.meta?.controlKey === field.fieldKey)
2971
+ : [];
2972
+ const fieldPath = authoredMatches.length === 1 ? authoredMatches[0].path : parseFormFieldId(field.id);
2783
2973
  const target = fieldPath ? findNodeByPath(root, fieldPath) ?? formNode : formNode;
2784
2974
  if (!target)
2785
2975
  return null;
@@ -3183,6 +3373,32 @@ function normalizeCheckedState(value) {
3183
3373
  return false;
3184
3374
  return undefined;
3185
3375
  }
3376
+ function normalizeSemanticOptions(value) {
3377
+ if (!Array.isArray(value))
3378
+ return undefined;
3379
+ const options = [];
3380
+ const seenIndices = new Set();
3381
+ for (const entry of value.slice(0, 500)) {
3382
+ if (!entry || typeof entry !== 'object')
3383
+ continue;
3384
+ const record = entry;
3385
+ if (typeof record.value !== 'string' || typeof record.label !== 'string')
3386
+ continue;
3387
+ if (typeof record.index !== 'number' || !Number.isInteger(record.index) || record.index < 0)
3388
+ continue;
3389
+ if (seenIndices.has(record.index))
3390
+ continue;
3391
+ seenIndices.add(record.index);
3392
+ options.push({
3393
+ value: record.value,
3394
+ label: record.label,
3395
+ disabled: record.disabled === true,
3396
+ selected: record.selected === true,
3397
+ index: record.index,
3398
+ });
3399
+ }
3400
+ return options;
3401
+ }
3186
3402
  function normalizeA11yRoleHint(value) {
3187
3403
  if (typeof value !== 'string')
3188
3404
  return undefined;
@@ -3239,6 +3455,15 @@ function walkNode(element, layout, path) {
3239
3455
  meta.scrollY = semantic.scrollY;
3240
3456
  if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
3241
3457
  meta.controlTag = semantic.tag;
3458
+ if (typeof semantic?.controlKey === 'string' && semantic.controlKey.trim().length > 0)
3459
+ meta.controlKey = semantic.controlKey.trim();
3460
+ if (typeof semantic?.controlId === 'string' && semantic.controlId.trim().length > 0)
3461
+ meta.controlId = semantic.controlId.trim();
3462
+ if (typeof semantic?.controlName === 'string' && semantic.controlName.trim().length > 0)
3463
+ meta.controlName = semantic.controlName.trim();
3464
+ const semanticOptions = normalizeSemanticOptions(semantic?.options);
3465
+ if (semanticOptions)
3466
+ meta.options = semanticOptions;
3242
3467
  if (typeof semantic?.placeholder === 'string')
3243
3468
  meta.placeholder = semantic.placeholder;
3244
3469
  if (typeof semantic?.inputPattern === 'string')
@@ -3352,8 +3577,14 @@ async function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_
3352
3577
  await ensureSessionConnected(session);
3353
3578
  const requestId = `req-${++nextRequestSequence}`;
3354
3579
  const startRevision = session.updateRevision;
3355
- session.ws.send(JSON.stringify({ ...message, requestId }));
3356
- return await waitForNextUpdate(session, timeoutMs, requestId, startRevision, opts);
3580
+ session.ws.send(JSON.stringify({ ...message, requestId, protocolVersion: PROXY_PROTOCOL_VERSION }));
3581
+ const requiresExactFieldIdentity = typeof message.fieldKey === 'string' || (message.type === 'fillFields' &&
3582
+ Array.isArray(message.fields) &&
3583
+ message.fields.some(field => typeof field === 'object' && field !== null && typeof field.fieldKey === 'string'));
3584
+ return await waitForNextUpdate(session, timeoutMs, requestId, startRevision, {
3585
+ ...opts,
3586
+ ...(requiresExactFieldIdentity ? { requiredProtocolVersion: PROXY_PROTOCOL_VERSION } : {}),
3587
+ });
3357
3588
  }
3358
3589
  function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision, opts) {
3359
3590
  return new Promise((resolve, reject) => {
@@ -3380,6 +3611,12 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
3380
3611
  return;
3381
3612
  }
3382
3613
  if (msg.type === 'ack' && messageRequestId === requestId) {
3614
+ const peerProtocolVersion = typeof msg.protocolVersion === 'number' ? msg.protocolVersion : undefined;
3615
+ if (opts?.requiredProtocolVersion !== undefined && (peerProtocolVersion === undefined || peerProtocolVersion < opts.requiredProtocolVersion)) {
3616
+ cleanup();
3617
+ reject(new Error(`Proxy protocol ${peerProtocolVersion ?? 'unknown'} cannot guarantee exact field identity; protocol ${opts.requiredProtocolVersion}+ is required. Update and reconnect the Geometra proxy.`));
3618
+ return;
3619
+ }
3383
3620
  ackSeen = true;
3384
3621
  ackResult = msg.result;
3385
3622
  if (!opts?.requireUpdateOnAck || session.updateRevision > startRevision) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.63.0",
3
+ "version": "1.64.0",
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",
@@ -32,7 +32,7 @@
32
32
  "ui-testing"
33
33
  ],
34
34
  "dependencies": {
35
- "@geometra/proxy": "^1.63.0",
35
+ "@geometra/proxy": "^1.64.0",
36
36
  "@modelcontextprotocol/sdk": "^1.12.1",
37
37
  "@razroo/parallel-mcp": "^0.1.0",
38
38
  "ws": "^8.18.0",