@geometra/mcp 1.43.0 → 1.45.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/server.js CHANGED
@@ -3546,6 +3546,62 @@ function suggestRecovery(field, error) {
3546
3546
  }
3547
3547
  return undefined;
3548
3548
  }
3549
+ // Normalize a value for verifyFills comparison so caller-friendly inputs
3550
+ // match site-formatted readbacks. The legacy comparison was strict lowercase
3551
+ // only — which broke every form that auto-formats phone numbers, dates, or
3552
+ // currency fields. Greenhouse turns "+1-929-608-1737" into "(929) 608-1737";
3553
+ // Workday turns "$160000" into "$160,000.00"; Lever turns "2026-01-01" into
3554
+ // "01/01/2026". The lowercase comparator flagged all of these as mismatches
3555
+ // even though the field state was correct, forcing every caller to either
3556
+ // disable verifyFills or wrap it in defensive try/catch.
3557
+ //
3558
+ // The fix: detect phone-like and number-like values and compare on the
3559
+ // canonical digit sequence. Plain text and short strings still go through
3560
+ // the strict lowercase comparator so unrelated content can't accidentally
3561
+ // match (e.g. "1234 Main St" vs "12-34 Main").
3562
+ function looksLikePhoneNumber(value) {
3563
+ // Heuristic: at least 7 digits, and after stripping whitespace + the
3564
+ // common phone separator characters (+ - . ( ) space ext) only digits
3565
+ // and a single optional leading + remain. Catches international and
3566
+ // domestic formats without false-matching addresses or IDs.
3567
+ const stripped = value.replace(/[\s().\-+x]|ext\.?/gi, '');
3568
+ if (stripped.length < 7)
3569
+ return false;
3570
+ if (!/^\d+$/.test(stripped))
3571
+ return false;
3572
+ return /\d.*[\s().\-+]|^\+\d/.test(value) || /^\d{7,}$/.test(value);
3573
+ }
3574
+ function looksLikeFormattedNumber(value) {
3575
+ // Catches "$160,000.00" / "1,000,000" / "1.5e6" style readbacks where the
3576
+ // site adds thousands separators or currency prefixes. Requires at least
3577
+ // one comma OR currency prefix to avoid matching plain text.
3578
+ if (!/[$€£¥,]/.test(value))
3579
+ return false;
3580
+ return /\d/.test(value);
3581
+ }
3582
+ function digitSignature(value) {
3583
+ return value.replace(/\D+/g, '');
3584
+ }
3585
+ function valuesEquivalent(expected, actual) {
3586
+ if (expected.toLowerCase() === actual.toLowerCase())
3587
+ return true;
3588
+ // Both look like phones → compare digit signatures only
3589
+ if (looksLikePhoneNumber(expected) && looksLikePhoneNumber(actual)) {
3590
+ return digitSignature(expected) === digitSignature(actual);
3591
+ }
3592
+ // Both look like formatted numbers → compare digit signatures only
3593
+ if (looksLikeFormattedNumber(expected) || looksLikeFormattedNumber(actual)) {
3594
+ const eDigits = digitSignature(expected);
3595
+ const aDigits = digitSignature(actual);
3596
+ if (eDigits && aDigits && eDigits === aDigits)
3597
+ return true;
3598
+ }
3599
+ // Whitespace normalization for everything else (handles "Charlie Greenman"
3600
+ // vs "Charlie Greenman" auto-collapsed by ATS forms)
3601
+ const eNorm = expected.replace(/\s+/g, ' ').trim().toLowerCase();
3602
+ const aNorm = actual.replace(/\s+/g, ' ').trim().toLowerCase();
3603
+ return eNorm === aNorm;
3604
+ }
3549
3605
  function verifyFormFills(session, planned) {
3550
3606
  const a11y = sessionA11y(session);
3551
3607
  if (!a11y)
@@ -3566,7 +3622,7 @@ function verifyFormFills(session, planned) {
3566
3622
  if (!actual || !expected) {
3567
3623
  mismatches.push({ fieldLabel: label, expected, actual, ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}) });
3568
3624
  }
3569
- else if (actual.toLowerCase() !== expected.toLowerCase()) {
3625
+ else if (!valuesEquivalent(expected, actual)) {
3570
3626
  mismatches.push({ fieldLabel: label, expected, actual, ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}) });
3571
3627
  }
3572
3628
  else {
package/dist/session.js CHANGED
@@ -645,19 +645,46 @@ export function connect(url, opts) {
645
645
  let resolved = false;
646
646
  let lastMessageAt = Date.now();
647
647
  let heartbeatInterval = null;
648
+ let pendingPongBy = null;
649
+ // Heartbeat: send a real WS-level ping every 15s and only tear the socket
650
+ // down if the peer fails to respond to two consecutive pings (i.e. ~45s of
651
+ // true unresponsiveness). Previous versions used a dumb idle timer that
652
+ // closed the socket after 30s of no inbound frames — which killed sessions
653
+ // during normal form-submission flows where the DOM is legitimately idle
654
+ // for 20-30+ seconds while the backend processes (Greenhouse submit →
655
+ // security-code dialog is the canonical repro). A real ping/pong cycle
656
+ // distinguishes a silent-but-healthy session from a dead one.
648
657
  function startHeartbeat() {
649
658
  if (heartbeatInterval)
650
659
  return;
651
660
  heartbeatInterval = setInterval(() => {
652
- if (Date.now() - lastMessageAt > 30_000) {
661
+ // If we're waiting on a pong and it's overdue, the peer is dead.
662
+ if (pendingPongBy !== null && Date.now() > pendingPongBy) {
653
663
  try {
654
664
  ws.close();
655
665
  }
656
666
  catch { /* ignore */ }
667
+ return;
668
+ }
669
+ // Only send a new ping if we haven't heard anything for a while,
670
+ // to avoid spamming a chatty session.
671
+ if (Date.now() - lastMessageAt > 10_000) {
672
+ try {
673
+ ws.ping();
674
+ // Allow 30s for the pong before declaring the peer dead.
675
+ pendingPongBy = Date.now() + 30_000;
676
+ }
677
+ catch {
678
+ /* if ping throws, the socket is already gone — let 'close' handle */
679
+ }
657
680
  }
658
- }, 30_000);
681
+ }, 15_000);
659
682
  heartbeatInterval.unref();
660
683
  }
684
+ ws.on('pong', () => {
685
+ lastMessageAt = Date.now();
686
+ pendingPongBy = null;
687
+ });
661
688
  const timeout = setTimeout(() => {
662
689
  if (!resolved) {
663
690
  resolved = true;
@@ -1782,12 +1809,26 @@ function compactSchemaContext(context, label) {
1782
1809
  return Object.keys(out).length > 0 ? out : undefined;
1783
1810
  }
1784
1811
  function compactSchemaValue(value, inlineLimit = 80) {
1785
- const normalized = sanitizeInlineName(value, Math.max(120, inlineLimit + 32));
1786
- if (!normalized)
1812
+ // Measure the length of the FULL whitespace-normalized value first, before
1813
+ // any inline truncation. The previous implementation called sanitizeInlineName
1814
+ // with max=120 and then read normalized.length, which capped reported length
1815
+ // at 120 even when the actual filled content was 1000+ characters. That made
1816
+ // form-required snapshots look like long-textarea fills had only landed
1817
+ // ~120 chars, when in reality the field was correctly filled — agents then
1818
+ // re-typed the same content thinking they had a partial fill, doubling the
1819
+ // value or hitting the textarea length cap.
1820
+ if (!value)
1821
+ return {};
1822
+ const fullNormalized = normalizeUiText(value);
1823
+ if (!fullNormalized)
1787
1824
  return {};
1788
- return normalized.length <= inlineLimit
1789
- ? { value: normalized }
1790
- : { valueLength: normalized.length };
1825
+ const fullLength = fullNormalized.length;
1826
+ const inlineNormalized = sanitizeInlineName(value, Math.max(120, inlineLimit + 32));
1827
+ if (!inlineNormalized)
1828
+ return { valueLength: fullLength };
1829
+ return fullLength <= inlineLimit
1830
+ ? { value: inlineNormalized }
1831
+ : { valueLength: fullLength };
1791
1832
  }
1792
1833
  function schemaOptionLabel(node) {
1793
1834
  return sanitizeFieldName(node.name, 80) ?? sanitizeInlineName(node.name, 80);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.43.0",
3
+ "version": "1.45.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",