@geometra/mcp 1.19.0 → 1.19.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @geometra/mcp
2
2
 
3
- MCP server for [Geometra](https://github.com/razroo/geometra) — interact with running Geometra apps via the geometry protocol over WebSocket. For **native** Geometra apps there is no browser in the loop. For **any existing website**, use **`geometra_connect` with `pageUrl`** — the MCP server starts [`@geometra/proxy`](../packages/proxy/README.md) for you (bundled dependency) so you do not need a separate terminal or a `ws://` URL. You can still pass `url: "ws://…"` if a proxy is already running.
3
+ MCP server for [Geometra](https://github.com/razroo/geometra) — interact with running Geometra apps via the geometry protocol over WebSocket. For **native** Geometra apps there is no browser in the loop. For **any existing website**, use **`geometra_connect` with `pageUrl`** — the MCP server starts [`@geometra/proxy`](../packages/proxy/README.md) for you (bundled dependency) so you do not need a separate terminal or a `ws://` URL. You can still pass `url: "ws://…"` if a proxy is already running, and if you accidentally pass `https://…` in `url`, MCP will treat it as `pageUrl` and auto-start the proxy.
4
4
 
5
5
  See [`AGENT_MODEL.md`](./AGENT_MODEL.md) for the MCP mental model, why token usage can be lower than large browser snapshots, and how headed vs headless proxy mode works.
6
6
 
@@ -18,7 +18,7 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
18
18
 
19
19
  | Tool | Description |
20
20
  |---|---|
21
- | `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy |
21
+ | `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; `url: "https://…"` is auto-coerced onto the proxy path |
22
22
  | `geometra_query` | Find elements by stable id, role, name, or text content |
23
23
  | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
24
24
  | `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand |
@@ -76,6 +76,8 @@ npm run server # starts on ws://localhost:3100
76
76
 
77
77
  ### Any web app (Geometra proxy)
78
78
 
79
+ **Preferred path for agents:** call `geometra_connect({ pageUrl: "https://…" })` and let MCP spawn the proxy for you on an ephemeral local port. The manual CLI below is still useful for debugging or when you want to inspect the proxy directly.
80
+
79
81
  In one terminal, serve or open a page (example uses the repo sample):
80
82
 
81
83
  ```bash
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatConnectFailureMessage, normalizeConnectTarget } from '../connect-utils.js';
3
+ import { formatProxyStartupFailure, parseProxyReadySignalLine } from '../proxy-spawn.js';
4
+ describe('normalizeConnectTarget', () => {
5
+ it('accepts explicit pageUrl for http(s) pages', () => {
6
+ const result = normalizeConnectTarget({ pageUrl: 'https://example.com/jobs/123' });
7
+ expect(result).toEqual({
8
+ ok: true,
9
+ value: {
10
+ kind: 'proxy',
11
+ pageUrl: 'https://example.com/jobs/123',
12
+ autoCoercedFromUrl: false,
13
+ },
14
+ });
15
+ });
16
+ it('rejects non-http pageUrl protocols', () => {
17
+ const result = normalizeConnectTarget({ pageUrl: 'ws://127.0.0.1:3100' });
18
+ expect(result).toEqual({
19
+ ok: false,
20
+ error: 'pageUrl must use http:// or https:// (received ws:)',
21
+ });
22
+ });
23
+ it('auto-coerces http url input onto the proxy path', () => {
24
+ const result = normalizeConnectTarget({ url: 'https://jobs.example.com/apply' });
25
+ expect(result).toEqual({
26
+ ok: true,
27
+ value: {
28
+ kind: 'proxy',
29
+ pageUrl: 'https://jobs.example.com/apply',
30
+ autoCoercedFromUrl: true,
31
+ },
32
+ });
33
+ });
34
+ it('accepts ws url input for already-running peers', () => {
35
+ const result = normalizeConnectTarget({ url: 'ws://127.0.0.1:3100' });
36
+ expect(result).toEqual({
37
+ ok: true,
38
+ value: {
39
+ kind: 'ws',
40
+ wsUrl: 'ws://127.0.0.1:3100/',
41
+ autoCoercedFromUrl: false,
42
+ },
43
+ });
44
+ });
45
+ it('rejects ambiguous and empty connect inputs', () => {
46
+ expect(normalizeConnectTarget({})).toEqual({
47
+ ok: false,
48
+ error: 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).',
49
+ });
50
+ expect(normalizeConnectTarget({ url: 'ws://127.0.0.1:3100', pageUrl: 'https://example.com' })).toEqual({
51
+ ok: false,
52
+ error: 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).',
53
+ });
54
+ });
55
+ });
56
+ describe('formatConnectFailureMessage', () => {
57
+ it('adds a targeted hint when ws connect fails for a normal webpage flow', () => {
58
+ const message = formatConnectFailureMessage(new Error('WebSocket error connecting to ws://localhost:3100: connect ECONNREFUSED'), { kind: 'ws', wsUrl: 'ws://localhost:3100', autoCoercedFromUrl: false });
59
+ expect(message).toContain('ECONNREFUSED');
60
+ expect(message).toContain('pageUrl: "https://…"');
61
+ });
62
+ });
63
+ describe('proxy ready helpers', () => {
64
+ it('parses structured proxy ready JSON', () => {
65
+ const wsUrl = parseProxyReadySignalLine('{"type":"geometra-proxy-ready","wsUrl":"ws://127.0.0.1:41237","pageUrl":"https://example.com"}');
66
+ expect(wsUrl).toBe('ws://127.0.0.1:41237');
67
+ });
68
+ it('still accepts legacy human-readable ready logs', () => {
69
+ const wsUrl = parseProxyReadySignalLine('[geometra-proxy] WebSocket listening on ws://127.0.0.1:3200');
70
+ expect(wsUrl).toBe('ws://127.0.0.1:3200');
71
+ });
72
+ it('adds install and port conflict hints to proxy startup failures', () => {
73
+ const chromiumHint = formatProxyStartupFailure("browserType.launch: Executable doesn't exist at /tmp/chromium", { pageUrl: 'https://example.com', port: 0 });
74
+ expect(chromiumHint).toContain('npx playwright install chromium');
75
+ const portHint = formatProxyStartupFailure('listen EADDRINUSE: address already in use 127.0.0.1:3337', { pageUrl: 'https://example.com', port: 3337 });
76
+ expect(portHint).toContain('Requested port 3337 is unavailable');
77
+ });
78
+ });
@@ -0,0 +1,18 @@
1
+ export interface NormalizedConnectTarget {
2
+ kind: 'proxy' | 'ws';
3
+ autoCoercedFromUrl: boolean;
4
+ pageUrl?: string;
5
+ wsUrl?: string;
6
+ }
7
+ export declare function normalizeConnectTarget(input: {
8
+ url?: string;
9
+ pageUrl?: string;
10
+ }): {
11
+ ok: true;
12
+ value: NormalizedConnectTarget;
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ };
17
+ export declare function formatConnectFailureMessage(err: unknown, target: NormalizedConnectTarget): string;
18
+ export declare function isHttpUrl(value: string): boolean;
@@ -0,0 +1,94 @@
1
+ export function normalizeConnectTarget(input) {
2
+ const rawUrl = normalizeOptional(input.url);
3
+ const rawPageUrl = normalizeOptional(input.pageUrl);
4
+ if (rawUrl && rawPageUrl) {
5
+ return { ok: false, error: 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).' };
6
+ }
7
+ if (!rawUrl && !rawPageUrl) {
8
+ return { ok: false, error: 'Provide exactly one of: url (WebSocket or webpage URL) or pageUrl (https://…).' };
9
+ }
10
+ if (rawPageUrl) {
11
+ const parsed = parseUrl(rawPageUrl);
12
+ if (!parsed) {
13
+ return { ok: false, error: `Invalid pageUrl: ${rawPageUrl}` };
14
+ }
15
+ if (!isHttpProtocol(parsed.protocol)) {
16
+ return { ok: false, error: `pageUrl must use http:// or https:// (received ${parsed.protocol})` };
17
+ }
18
+ return {
19
+ ok: true,
20
+ value: {
21
+ kind: 'proxy',
22
+ pageUrl: parsed.toString(),
23
+ autoCoercedFromUrl: false,
24
+ },
25
+ };
26
+ }
27
+ const parsed = parseUrl(rawUrl);
28
+ if (!parsed) {
29
+ return {
30
+ ok: false,
31
+ error: `Invalid url: ${rawUrl}. Use ws://... for an already-running Geometra server, or https://... for a normal webpage.`,
32
+ };
33
+ }
34
+ if (isHttpProtocol(parsed.protocol)) {
35
+ return {
36
+ ok: true,
37
+ value: {
38
+ kind: 'proxy',
39
+ pageUrl: parsed.toString(),
40
+ autoCoercedFromUrl: true,
41
+ },
42
+ };
43
+ }
44
+ if (isWsProtocol(parsed.protocol)) {
45
+ return {
46
+ ok: true,
47
+ value: {
48
+ kind: 'ws',
49
+ wsUrl: parsed.toString(),
50
+ autoCoercedFromUrl: false,
51
+ },
52
+ };
53
+ }
54
+ return {
55
+ ok: false,
56
+ error: `Unsupported url protocol ${parsed.protocol}. Use ws://... for an already-running Geometra server, or http:// / https:// for webpages.`,
57
+ };
58
+ }
59
+ export function formatConnectFailureMessage(err, target) {
60
+ const base = err instanceof Error ? err.message : String(err);
61
+ const hints = [];
62
+ if (target.kind === 'ws' &&
63
+ /ECONNREFUSED|timed out|closed before first frame|WebSocket error connecting/i.test(base)) {
64
+ hints.push('If this is a normal website, call geometra_connect with pageUrl: "https://…" so MCP can start @geometra/proxy for you.');
65
+ }
66
+ if (/Could not resolve @geometra\/proxy/i.test(base)) {
67
+ hints.push('Ensure @geometra/proxy is installed alongside @geometra/mcp.');
68
+ }
69
+ if (hints.length === 0)
70
+ return base;
71
+ return `${base}\nHint: ${hints.join(' ')}`;
72
+ }
73
+ export function isHttpUrl(value) {
74
+ const parsed = parseUrl(value);
75
+ return parsed !== null && isHttpProtocol(parsed.protocol);
76
+ }
77
+ function normalizeOptional(value) {
78
+ const trimmed = value?.trim();
79
+ return trimmed ? trimmed : undefined;
80
+ }
81
+ function parseUrl(value) {
82
+ try {
83
+ return new URL(value);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ function isHttpProtocol(protocol) {
90
+ return protocol === 'http:' || protocol === 'https:';
91
+ }
92
+ function isWsProtocol(protocol) {
93
+ return protocol === 'ws:' || protocol === 'wss:';
94
+ }
package/dist/index.js CHANGED
@@ -1,12 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { createServer } from './server.js';
4
+ import { disconnect } from './session.js';
5
+ let cleanedUp = false;
6
+ function cleanupActiveSession() {
7
+ if (cleanedUp)
8
+ return;
9
+ cleanedUp = true;
10
+ try {
11
+ disconnect();
12
+ }
13
+ catch {
14
+ /* ignore */
15
+ }
16
+ }
4
17
  async function main() {
5
18
  const server = createServer();
6
19
  const transport = new StdioServerTransport();
7
20
  await server.connect(transport);
8
21
  }
22
+ process.on('SIGINT', () => {
23
+ cleanupActiveSession();
24
+ process.exit(0);
25
+ });
26
+ process.on('SIGTERM', () => {
27
+ cleanupActiveSession();
28
+ process.exit(0);
29
+ });
30
+ process.on('SIGHUP', () => {
31
+ cleanupActiveSession();
32
+ process.exit(0);
33
+ });
34
+ process.on('exit', cleanupActiveSession);
9
35
  main().catch((err) => {
36
+ cleanupActiveSession();
10
37
  console.error('geometra-mcp: failed to start', err);
11
38
  process.exit(1);
12
39
  });
@@ -1,8 +1,6 @@
1
1
  import { type ChildProcess } from 'node:child_process';
2
2
  /** Resolve bundled @geometra/proxy CLI entry (dist/index.js). */
3
3
  export declare function resolveProxyScriptPath(): string;
4
- /** Prefer `preferred` when free; otherwise an ephemeral port on 127.0.0.1. */
5
- export declare function pickFreePort(preferred?: number): Promise<number>;
6
4
  export interface SpawnProxyParams {
7
5
  pageUrl: string;
8
6
  port: number;
@@ -11,8 +9,10 @@ export interface SpawnProxyParams {
11
9
  height?: number;
12
10
  slowMo?: number;
13
11
  }
12
+ export declare function parseProxyReadySignalLine(line: string): string | undefined;
13
+ export declare function formatProxyStartupFailure(message: string, opts: SpawnProxyParams): string;
14
14
  /**
15
- * Spawn geometra-proxy as a child process and resolve when the WebSocket is listening.
15
+ * Spawn geometra-proxy as a child process and resolve when it emits a structured ready signal.
16
16
  */
17
17
  export declare function spawnGeometraProxy(opts: SpawnProxyParams): Promise<{
18
18
  child: ChildProcess;
@@ -1,8 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
- import { createServer } from 'node:net';
4
3
  import path from 'node:path';
5
4
  const require = createRequire(import.meta.url);
5
+ const READY_SIGNAL_TYPE = 'geometra-proxy-ready';
6
+ const READY_TIMEOUT_MS = 45_000;
6
7
  /** Resolve bundled @geometra/proxy CLI entry (dist/index.js). */
7
8
  export function resolveProxyScriptPath() {
8
9
  try {
@@ -13,45 +14,40 @@ export function resolveProxyScriptPath() {
13
14
  throw new Error('Could not resolve @geometra/proxy. Install it with the MCP package: npm install @geometra/proxy');
14
15
  }
15
16
  }
16
- function canBindPort(p) {
17
- return new Promise(resolve => {
18
- const s = createServer();
19
- s.once('error', () => resolve(false));
20
- s.listen(p, '127.0.0.1', () => {
21
- s.close(() => resolve(true));
22
- });
23
- });
24
- }
25
- function getEphemeralPort() {
26
- return new Promise((resolve, reject) => {
27
- const s = createServer();
28
- s.once('error', reject);
29
- s.listen(0, '127.0.0.1', () => {
30
- const a = s.address();
31
- s.close(err => {
32
- if (err) {
33
- reject(err);
34
- return;
35
- }
36
- if (typeof a === 'object' && a !== null && 'port' in a)
37
- resolve(a.port);
38
- else
39
- reject(new Error('Could not allocate ephemeral port'));
40
- });
41
- });
42
- });
17
+ export function parseProxyReadySignalLine(line) {
18
+ const trimmed = line.trim();
19
+ if (!trimmed)
20
+ return undefined;
21
+ if (trimmed.startsWith('{')) {
22
+ try {
23
+ const parsed = JSON.parse(trimmed);
24
+ if (parsed.type === READY_SIGNAL_TYPE &&
25
+ typeof parsed.wsUrl === 'string' &&
26
+ /^ws:\/\/127\.0\.0\.1:\d+$/.test(parsed.wsUrl)) {
27
+ return parsed.wsUrl;
28
+ }
29
+ }
30
+ catch {
31
+ /* ignore non-JSON lines */
32
+ }
33
+ }
34
+ const fallback = trimmed.match(/WebSocket listening on (ws:\/\/127\.0\.0\.1:\d+)/);
35
+ return fallback?.[1];
43
36
  }
44
- /** Prefer `preferred` when free; otherwise an ephemeral port on 127.0.0.1. */
45
- export async function pickFreePort(preferred) {
46
- if (preferred != null && preferred > 0 && preferred <= 65535) {
47
- if (await canBindPort(preferred))
48
- return preferred;
37
+ export function formatProxyStartupFailure(message, opts) {
38
+ const hints = [];
39
+ if (/Executable doesn't exist|playwright install chromium|browserType\.launch/i.test(message)) {
40
+ hints.push('Install Chromium with: npx playwright install chromium');
49
41
  }
50
- return getEphemeralPort();
42
+ if (opts.port > 0 && /EADDRINUSE|address already in use/i.test(message)) {
43
+ hints.push(`Requested port ${opts.port} is unavailable. Omit the port to use an ephemeral OS-assigned port, or choose another local port.`);
44
+ }
45
+ if (hints.length === 0)
46
+ return message;
47
+ return `${message}\nHint: ${hints.join(' ')}`;
51
48
  }
52
- const LISTEN_RE = /WebSocket listening on (ws:\/\/127\.0\.0\.1:\d+)/;
53
49
  /**
54
- * Spawn geometra-proxy as a child process and resolve when the WebSocket is listening.
50
+ * Spawn geometra-proxy as a child process and resolve when it emits a structured ready signal.
55
51
  */
56
52
  export function spawnGeometraProxy(opts) {
57
53
  const script = resolveProxyScriptPath();
@@ -69,41 +65,66 @@ export function spawnGeometraProxy(opts) {
69
65
  return new Promise((resolve, reject) => {
70
66
  const child = spawn(process.execPath, args, {
71
67
  stdio: ['ignore', 'pipe', 'pipe'],
72
- env: { ...process.env },
68
+ env: { ...process.env, GEOMETRA_PROXY_READY_JSON: '1' },
73
69
  });
74
70
  let settled = false;
71
+ let stdoutBuf = '';
75
72
  let stderrBuf = '';
73
+ const cleanup = () => {
74
+ clearTimeout(deadline);
75
+ child.stdout?.removeAllListeners('data');
76
+ child.stderr?.removeAllListeners('data');
77
+ };
78
+ const tryResolveReady = (line) => {
79
+ const wsUrl = parseProxyReadySignalLine(line);
80
+ if (!wsUrl || settled)
81
+ return false;
82
+ settled = true;
83
+ cleanup();
84
+ resolve({ child, wsUrl });
85
+ return true;
86
+ };
87
+ const consumeStdout = (chunk) => {
88
+ stdoutBuf += chunk.toString();
89
+ const lines = stdoutBuf.split(/\r?\n/);
90
+ stdoutBuf = lines.pop() ?? '';
91
+ for (const line of lines) {
92
+ if (tryResolveReady(line))
93
+ return;
94
+ }
95
+ };
96
+ const consumeStderr = (chunk) => {
97
+ stderrBuf += chunk.toString();
98
+ const lines = stderrBuf.split(/\r?\n/);
99
+ stderrBuf = lines.pop() ?? '';
100
+ for (const line of lines) {
101
+ if (tryResolveReady(line))
102
+ return;
103
+ }
104
+ };
76
105
  const deadline = setTimeout(() => {
77
106
  if (!settled) {
78
107
  settled = true;
79
108
  child.kill('SIGTERM');
80
- reject(new Error('geometra-proxy did not report a listening WebSocket within 45s'));
109
+ cleanup();
110
+ reject(new Error(formatProxyStartupFailure('geometra-proxy did not emit a ready signal within 45s', opts)));
81
111
  }
82
- }, 45_000);
83
- const flushStderr = (chunk) => {
84
- stderrBuf += chunk.toString();
85
- const m = stderrBuf.match(LISTEN_RE);
86
- if (m && !settled) {
87
- settled = true;
88
- clearTimeout(deadline);
89
- child.stderr?.removeAllListeners('data');
90
- resolve({ child, wsUrl: m[1] });
91
- }
92
- };
93
- child.stderr?.on('data', flushStderr);
112
+ }, READY_TIMEOUT_MS);
113
+ child.stdout?.on('data', consumeStdout);
114
+ child.stderr?.on('data', consumeStderr);
94
115
  child.on('error', err => {
95
116
  if (!settled) {
96
117
  settled = true;
97
- clearTimeout(deadline);
98
- reject(err);
118
+ cleanup();
119
+ reject(new Error(formatProxyStartupFailure(err.message, opts)));
99
120
  }
100
121
  });
101
122
  child.on('exit', (code, sig) => {
102
123
  if (!settled) {
103
124
  settled = true;
104
- clearTimeout(deadline);
105
- const tail = stderrBuf.trim().slice(-2000);
106
- reject(new Error(`geometra-proxy exited before ready (code=${code} signal=${sig}). Stderr (tail): ${tail || '(empty)'}`));
125
+ cleanup();
126
+ const stderrTail = stderrBuf.trim().slice(-2000);
127
+ reject(new Error(formatProxyStartupFailure(`geometra-proxy exited before ready (code=${code} signal=${sig}). Stderr (tail): ${stderrTail || '(empty)'}`, opts)));
107
128
  }
108
129
  });
109
130
  });
package/dist/server.js CHANGED
@@ -1,23 +1,25 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
+ import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
3
4
  import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendListboxPick, sendSelectOption, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, } from './session.js';
4
5
  export function createServer() {
5
- const server = new McpServer({ name: 'geometra', version: '1.19.0' }, { capabilities: { tools: {} } });
6
+ const server = new McpServer({ name: 'geometra', version: '1.19.1' }, { capabilities: { tools: {} } });
6
7
  // ── connect ──────────────────────────────────────────────────
7
8
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
8
9
 
9
- **Prefer \`pageUrl\` for job sites and SPAs:** pass \`https://…\` and this server spawns geometra-proxy, picks a free port, and connects — you do **not** need a separate terminal or a \`ws://\` URL (fewer IDE approval steps for the human).
10
+ **Prefer \`pageUrl\` for job sites and SPAs:** pass \`https://…\` and this server spawns geometra-proxy on an ephemeral local port and connects — you do **not** need a separate terminal or a \`ws://\` URL (fewer IDE approval steps for the human).
10
11
 
11
- Use \`url\` (ws://…) only when a Geometra/native server or an already-running proxy is listening.
12
+ Use \`url\` (ws://…) only when a Geometra/native server or an already-running proxy is listening. If you accidentally pass \`https://…\` in \`url\`, MCP treats it like \`pageUrl\` and starts the proxy for you.
12
13
 
13
14
  Chromium opens **visible** by default unless \`headless: true\`. File upload / wheel / native \`<select>\` need the proxy path (\`pageUrl\` or ws to proxy).`, {
14
15
  url: z
15
16
  .string()
16
17
  .optional()
17
- .describe('WebSocket URL when a server is already running (e.g. ws://127.0.0.1:3200 or ws://localhost:3100). Omit if using pageUrl.'),
18
+ .describe('WebSocket URL when a server is already running (e.g. ws://127.0.0.1:3200 or ws://localhost:3100). If you pass http(s) here by mistake, MCP will treat it as a page URL and start geometra-proxy.'),
18
19
  pageUrl: z
19
20
  .string()
20
21
  .url()
22
+ .refine(isHttpUrl, 'pageUrl must use http:// or https://')
21
23
  .optional()
22
24
  .describe('HTTP(S) page to open. MCP starts geometra-proxy and connects automatically. Use this instead of url for most web apply flows.'),
23
25
  port: z
@@ -26,7 +28,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
26
28
  .positive()
27
29
  .max(65535)
28
30
  .optional()
29
- .describe('Local port for spawned proxy (default: ephemeral free port).'),
31
+ .describe('Preferred local port for spawned proxy (default: ephemeral OS-assigned port).'),
30
32
  headless: z
31
33
  .boolean()
32
34
  .optional()
@@ -40,15 +42,14 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
40
42
  .optional()
41
43
  .describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
42
44
  }, async (input) => {
45
+ const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
46
+ if (!normalized.ok)
47
+ return err(normalized.error);
48
+ const target = normalized.value;
43
49
  try {
44
- const hasUrl = typeof input.url === 'string' && input.url.length > 0;
45
- const hasPage = typeof input.pageUrl === 'string' && input.pageUrl.length > 0;
46
- if (hasUrl === hasPage) {
47
- return err('Provide exactly one of: url (WebSocket) or pageUrl (https://…).');
48
- }
49
- if (hasPage) {
50
+ if (target.kind === 'proxy') {
50
51
  const session = await connectThroughProxy({
51
- pageUrl: input.pageUrl,
52
+ pageUrl: target.pageUrl,
52
53
  port: input.port,
53
54
  headless: input.headless,
54
55
  width: input.width,
@@ -56,14 +57,15 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
56
57
  slowMo: input.slowMo,
57
58
  });
58
59
  const summary = compactSessionSummary(session);
59
- return ok(`Started geometra-proxy and connected at ${session.url} (page: ${input.pageUrl}). UI state:\n${summary}`);
60
+ const inferred = target.autoCoercedFromUrl ? ' inferred from url input' : '';
61
+ return ok(`Started geometra-proxy and connected at ${session.url} (page: ${target.pageUrl}${inferred}). UI state:\n${summary}`);
60
62
  }
61
- const session = await connect(input.url);
63
+ const session = await connect(target.wsUrl);
62
64
  const summary = compactSessionSummary(session);
63
- return ok(`Connected to ${input.url}. UI state:\n${summary}`);
65
+ return ok(`Connected to ${target.wsUrl}. UI state:\n${summary}`);
64
66
  }
65
67
  catch (e) {
66
- return err(`Failed to connect: ${e.message}`);
68
+ return err(`Failed to connect: ${formatConnectFailureMessage(e, target)}`);
67
69
  }
68
70
  });
69
71
  // ── query ────────────────────────────────────────────────────
@@ -154,8 +156,8 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
154
156
  if (!session)
155
157
  return err('Not connected. Call geometra_connect first.');
156
158
  const before = sessionA11y(session);
157
- await sendClick(session, x, y);
158
- const summary = postActionSummary(session, before);
159
+ const wait = await sendClick(session, x, y);
160
+ const summary = postActionSummary(session, before, wait);
159
161
  return ok(`Clicked at (${x}, ${y}).\n${summary}`);
160
162
  });
161
163
  // ── type ─────────────────────────────────────────────────────
@@ -168,8 +170,8 @@ Each character is sent as a key event through the geometry protocol. Returns a c
168
170
  if (!session)
169
171
  return err('Not connected. Call geometra_connect first.');
170
172
  const before = sessionA11y(session);
171
- await sendType(session, text);
172
- const summary = postActionSummary(session, before);
173
+ const wait = await sendType(session, text);
174
+ const summary = postActionSummary(session, before, wait);
173
175
  return ok(`Typed "${text}".\n${summary}`);
174
176
  });
175
177
  // ── key ──────────────────────────────────────────────────────
@@ -184,8 +186,8 @@ Each character is sent as a key event through the geometry protocol. Returns a c
184
186
  if (!session)
185
187
  return err('Not connected. Call geometra_connect first.');
186
188
  const before = sessionA11y(session);
187
- await sendKey(session, key, { shift, ctrl, meta, alt });
188
- const summary = postActionSummary(session, before);
189
+ const wait = await sendKey(session, key, { shift, ctrl, meta, alt });
190
+ const summary = postActionSummary(session, before, wait);
189
191
  return ok(`Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`);
190
192
  });
191
193
  // ── upload files (proxy) ───────────────────────────────────────
@@ -207,12 +209,12 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
207
209
  return err('Not connected. Call geometra_connect first.');
208
210
  const before = sessionA11y(session);
209
211
  try {
210
- await sendFileUpload(session, paths, {
212
+ const wait = await sendFileUpload(session, paths, {
211
213
  click: x !== undefined && y !== undefined ? { x, y } : undefined,
212
214
  strategy,
213
215
  drop: dropX !== undefined && dropY !== undefined ? { x: dropX, y: dropY } : undefined,
214
216
  });
215
- const summary = postActionSummary(session, before);
217
+ const summary = postActionSummary(session, before, wait);
216
218
  return ok(`Uploaded ${paths.length} file(s).\n${summary}`);
217
219
  }
218
220
  catch (e) {
@@ -232,11 +234,11 @@ Optional openX,openY clicks the combobox first if the list is not open. Uses sub
232
234
  return err('Not connected. Call geometra_connect first.');
233
235
  const before = sessionA11y(session);
234
236
  try {
235
- await sendListboxPick(session, label, {
237
+ const wait = await sendListboxPick(session, label, {
236
238
  exact,
237
239
  open: openX !== undefined && openY !== undefined ? { x: openX, y: openY } : undefined,
238
240
  });
239
- const summary = postActionSummary(session, before);
241
+ const summary = postActionSummary(session, before, wait);
240
242
  return ok(`Picked listbox option "${label}".\n${summary}`);
241
243
  }
242
244
  catch (e) {
@@ -261,8 +263,8 @@ Custom React/Vue dropdowns are not supported — open them with geometra_click a
261
263
  }
262
264
  const before = sessionA11y(session);
263
265
  try {
264
- await sendSelectOption(session, x, y, { value, label, index });
265
- const summary = postActionSummary(session, before);
266
+ const wait = await sendSelectOption(session, x, y, { value, label, index });
267
+ const summary = postActionSummary(session, before, wait);
266
268
  return ok(`Selected option.\n${summary}`);
267
269
  }
268
270
  catch (e) {
@@ -281,8 +283,8 @@ Custom React/Vue dropdowns are not supported — open them with geometra_click a
281
283
  return err('Not connected. Call geometra_connect first.');
282
284
  const before = sessionA11y(session);
283
285
  try {
284
- await sendWheel(session, deltaY, { deltaX, x, y });
285
- const summary = postActionSummary(session, before);
286
+ const wait = await sendWheel(session, deltaY, { deltaX, x, y });
287
+ const summary = postActionSummary(session, before, wait);
286
288
  return ok(`Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`);
287
289
  }
288
290
  catch (e) {
@@ -357,17 +359,21 @@ function sessionOverviewFromA11y(a11y) {
357
359
  const keyNodes = nodes.length > 0 ? `Key nodes:\n${summarizeCompactIndex(nodes, 18)}` : '';
358
360
  return [pageSummary, keyNodes].filter(Boolean).join('\n');
359
361
  }
360
- function postActionSummary(session, before) {
362
+ function postActionSummary(session, before, wait) {
361
363
  const after = sessionA11y(session);
364
+ const notes = [];
365
+ if (wait?.status === 'timed_out') {
366
+ notes.push(`No frame or patch arrived within ${wait.timeoutMs}ms after the action. The action may still have succeeded if it did not change geometry or semantics.`);
367
+ }
362
368
  if (!after)
363
- return 'No UI update received';
369
+ return [...notes, 'No UI update received'].filter(Boolean).join('\n');
364
370
  if (before) {
365
371
  const delta = buildUiDelta(before, after);
366
372
  if (hasUiDelta(delta)) {
367
- return `Changes:\n${summarizeUiDelta(delta)}`;
373
+ return [...notes, `Changes:\n${summarizeUiDelta(delta)}`].filter(Boolean).join('\n');
368
374
  }
369
375
  }
370
- return `Current UI:\n${sessionOverviewFromA11y(after)}`;
376
+ return [...notes, `Current UI:\n${sessionOverviewFromA11y(after)}`].filter(Boolean).join('\n');
371
377
  }
372
378
  function ok(text) {
373
379
  return { content: [{ type: 'text', text }] };
package/dist/session.d.ts CHANGED
@@ -195,6 +195,10 @@ export interface Session {
195
195
  /** Present when this session owns a child geometra-proxy process (pageUrl connect). */
196
196
  proxyChild?: ChildProcess;
197
197
  }
198
+ export interface UpdateWaitResult {
199
+ status: 'updated' | 'timed_out';
200
+ timeoutMs: number;
201
+ }
198
202
  /**
199
203
  * Connect to a running Geometra server. Waits for the first frame so that
200
204
  * layout/tree state is available immediately after connection.
@@ -217,11 +221,11 @@ export declare function disconnect(): void;
217
221
  /**
218
222
  * Send a click event at (x, y) and wait for the next frame/patch response.
219
223
  */
220
- export declare function sendClick(session: Session, x: number, y: number): Promise<void>;
224
+ export declare function sendClick(session: Session, x: number, y: number): Promise<UpdateWaitResult>;
221
225
  /**
222
226
  * Send a sequence of key events to type text into the focused element.
223
227
  */
224
- export declare function sendType(session: Session, text: string): Promise<void>;
228
+ export declare function sendType(session: Session, text: string): Promise<UpdateWaitResult>;
225
229
  /**
226
230
  * Send a special key (Enter, Tab, Escape, etc.)
227
231
  */
@@ -230,7 +234,7 @@ export declare function sendKey(session: Session, key: string, modifiers?: {
230
234
  ctrl?: boolean;
231
235
  meta?: boolean;
232
236
  alt?: boolean;
233
- }): Promise<void>;
237
+ }): Promise<UpdateWaitResult>;
234
238
  /**
235
239
  * Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
236
240
  * Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
@@ -245,7 +249,7 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
245
249
  x: number;
246
250
  y: number;
247
251
  };
248
- }): Promise<void>;
252
+ }): Promise<UpdateWaitResult>;
249
253
  /** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
250
254
  export declare function sendListboxPick(session: Session, label: string, opts?: {
251
255
  exact?: boolean;
@@ -253,19 +257,19 @@ export declare function sendListboxPick(session: Session, label: string, opts?:
253
257
  x: number;
254
258
  y: number;
255
259
  };
256
- }): Promise<void>;
260
+ }): Promise<UpdateWaitResult>;
257
261
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
258
262
  export declare function sendSelectOption(session: Session, x: number, y: number, option: {
259
263
  value?: string;
260
264
  label?: string;
261
265
  index?: number;
262
- }): Promise<void>;
266
+ }): Promise<UpdateWaitResult>;
263
267
  /** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
264
268
  export declare function sendWheel(session: Session, deltaY: number, opts?: {
265
269
  deltaX?: number;
266
270
  x?: number;
267
271
  y?: number;
268
- }): Promise<void>;
272
+ }): Promise<UpdateWaitResult>;
269
273
  /**
270
274
  * Build a flat accessibility tree from the raw UI tree + layout.
271
275
  * This is a standalone reimplementation that works with raw JSON —
package/dist/session.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import WebSocket from 'ws';
2
- import { pickFreePort, spawnGeometraProxy } from './proxy-spawn.js';
2
+ import { spawnGeometraProxy } from './proxy-spawn.js';
3
3
  let activeSession = null;
4
+ const ACTION_UPDATE_TIMEOUT_MS = 2000;
4
5
  function shutdownPreviousSession() {
5
6
  const prev = activeSession;
6
7
  if (!prev)
@@ -93,10 +94,9 @@ export function connect(url) {
93
94
  * process to the session so disconnect / reconnect can clean it up.
94
95
  */
95
96
  export async function connectThroughProxy(options) {
96
- const port = await pickFreePort(options.port);
97
97
  const { child, wsUrl } = await spawnGeometraProxy({
98
98
  pageUrl: options.pageUrl,
99
- port,
99
+ port: options.port ?? 0,
100
100
  headless: options.headless,
101
101
  width: options.width,
102
102
  height: options.height,
@@ -1098,18 +1098,21 @@ function waitForNextUpdate(session) {
1098
1098
  session.layout = msg.layout;
1099
1099
  session.tree = msg.tree;
1100
1100
  cleanup();
1101
- resolve();
1101
+ resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1102
1102
  }
1103
1103
  else if (msg.type === 'patch' && session.layout) {
1104
1104
  applyPatches(session.layout, msg.patches);
1105
1105
  cleanup();
1106
- resolve();
1106
+ resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1107
1107
  }
1108
1108
  }
1109
1109
  catch { /* ignore */ }
1110
1110
  };
1111
- // Resolve after timeout even if no update comes (action may not change layout)
1112
- const timeout = setTimeout(() => { cleanup(); resolve(); }, 2000);
1111
+ // Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
1112
+ const timeout = setTimeout(() => {
1113
+ cleanup();
1114
+ resolve({ status: 'timed_out', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1115
+ }, ACTION_UPDATE_TIMEOUT_MS);
1113
1116
  function cleanup() {
1114
1117
  clearTimeout(timeout);
1115
1118
  session.ws.off('message', onMessage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
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.0",
33
+ "@geometra/proxy": "1.19.1",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"