@geometra/mcp 1.18.1 → 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,8 @@
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**, pair this MCP server with [`@geometra/proxy`](../packages/proxy/README.md) (headless Chromium) so the same tools speak the same GEOM v1 wire format.
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
+
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.
4
6
 
5
7
  ## What this does
6
8
 
@@ -9,16 +11,17 @@ Connects Claude Code, Codex, or any MCP-compatible AI agent to a WebSocket endpo
9
11
  ```
10
12
  Playwright + vision: screenshot → model → guess coordinates → click → repeat
11
13
  Native Geometra: WebSocket → JSON geometry (no browser on the agent path)
12
- Geometra proxy: Headless Chromium → DOM geometry → same WebSocket as native → MCP tools unchanged
14
+ Geometra proxy: Chromium → DOM geometry → same WebSocket as native → MCP tools unchanged (often started via `pageUrl`, no manual CLI)
13
15
  ```
14
16
 
15
17
  ## Tools
16
18
 
17
19
  | Tool | Description |
18
20
  |---|---|
19
- | `geometra_connect` | Connect to a running Geometra server |
20
- | `geometra_query` | Find elements by role, name, or text content |
21
- | `geometra_page_model` | Higher-level webpage model: landmarks, forms, dialogs, lists, short previews |
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
+ | `geometra_query` | Find elements by stable id, role, name, or text content |
23
+ | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
24
+ | `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand |
22
25
  | `geometra_click` | Click an element by coordinates |
23
26
  | `geometra_type` | Type text into the focused element |
24
27
  | `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
@@ -73,6 +76,8 @@ npm run server # starts on ws://localhost:3100
73
76
 
74
77
  ### Any web app (Geometra proxy)
75
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
+
76
81
  In one terminal, serve or open a page (example uses the repo sample):
77
82
 
78
83
  ```bash
@@ -87,6 +92,8 @@ npx geometra-proxy http://localhost:8080 --port 3200
87
92
  # Requires Chromium: npx playwright install chromium
88
93
  ```
89
94
 
95
+ `geometra-proxy` opens a **visible Chromium window by default**. For servers or CI, pass **`--headless`** or set **`GEOMETRA_HEADLESS=1`**. Optional **`--slow-mo <ms>`** slows Playwright actions so they are easier to watch. Headed vs headless usually does **not** materially change token usage, since token usage is driven by MCP tool output rather than whether Chromium is visible.
96
+
90
97
  Point MCP at `ws://127.0.0.1:3200` instead of a native Geometra server. The proxy translates clicks and keyboard messages into Playwright actions and streams updated geometry.
91
98
 
92
99
  Then in Claude Code (either backend):
@@ -94,7 +101,7 @@ Then in Claude Code (either backend):
94
101
  ```
95
102
  > Connect to my Geometra app at ws://localhost:3100 and tell me what's on screen
96
103
 
97
- > Give me the page model first, then find the main form
104
+ > Give me the page model first, then expand the main form
98
105
 
99
106
  > Click the "Submit" button
100
107
 
@@ -136,7 +143,10 @@ Agent: geometra_connect({ url: "ws://127.0.0.1:3200" })
136
143
  → Connected. UI includes textbox "Email", button "Save", …
137
144
 
138
145
  Agent: geometra_page_model({})
139
- → {"viewport":{"width":1024,"height":768},"landmarks":[...],"forms":[...],"dialogs":[],"lists":[]}
146
+ → {"viewport":{"width":1024,"height":768},"archetypes":["shell","form"],"summary":{...},"forms":[{"id":"fm:1.0","fieldCount":3,"actionCount":1}], ...}
147
+
148
+ Agent: geometra_expand_section({ id: "fm:1.0" })
149
+ → {"id":"fm:1.0","kind":"form","fields":[{"id":"n:1.0.0","name":"Email"}, ...], "actions":[...]}
140
150
 
141
151
  Agent: geometra_query({ role: "textbox", name: "Email" })
142
152
  → bounds for the email field (viewport coordinates)
@@ -156,9 +166,10 @@ Agent: geometra_query({ role: "button", name: "Save" })
156
166
  2. It receives the computed layout (`{ x, y, width, height }` for every node) and the UI tree (`kind`, `semantic`, `props`, `handlers`, `children`).
157
167
  3. It builds an accessibility tree from that data — roles, names, focusable state, bounds.
158
168
  4. **`geometra_snapshot`** defaults to a **compact** flat list of viewport-visible actionable nodes (minified JSON) to reduce LLM tokens; use `view: "full"` for the complete nested tree.
159
- 5. **`geometra_page_model`** extracts higher-level webpage structure (landmarks, forms, dialogs, lists) so agents can reason about normal HTML pages without pulling a full tree first.
160
- 6. After interactions, action tools return a **semantic delta** when possible (dialogs opened/closed, forms appeared/removed, list counts changed, named/focusable nodes added/removed/updated). If nothing meaningful changed, they fall back to a short current-UI overview.
161
- 7. Tools expose query, click, type, snapshot, and page-model operations over this structured data.
162
- 8. After each interaction, the peer sends updated geometry (full `frame` or `patch`) the MCP tools interpret that into compact summaries.
169
+ 5. **`geometra_page_model`** is summary-first: page archetypes, stable section ids, counts, top-level landmarks/forms/dialogs/lists, and a few primary actions. It is designed to be cheaper than dumping full previews for every section.
170
+ 6. **`geometra_expand_section`** fetches richer details only for the section you care about (fields, actions, headings, nested lists, list items, text preview).
171
+ 7. After interactions, action tools return a **semantic delta** when possible (dialogs opened/closed, forms appeared/removed, list counts changed, named/focusable nodes added/removed/updated). If nothing meaningful changed, they fall back to a short current-UI overview.
172
+ 8. Tools expose query, click, type, snapshot, page-model, and section-expansion operations over this structured data.
173
+ 9. After each interaction, the peer sends updated geometry (full `frame` or `patch`) — the MCP tools interpret that into compact summaries.
163
174
 
164
175
  With a **native** Geometra server, layout comes from Textura/Yoga. With **`@geometra/proxy`**, layout comes from the browser’s computed DOM geometry; the MCP layer is the same.
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { buildPageModel, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
2
+ import { buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
3
3
  function node(role, name, bounds, options) {
4
4
  return {
5
5
  role,
@@ -12,7 +12,7 @@ function node(role, name, bounds, options) {
12
12
  };
13
13
  }
14
14
  describe('buildPageModel', () => {
15
- it('extracts landmarks, forms, and lists from a typical webpage tree', () => {
15
+ it('builds a summary-first page model with stable section ids', () => {
16
16
  const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
17
17
  children: [
18
18
  node('navigation', 'Primary nav', { x: 0, y: 0, width: 220, height: 80 }, { path: [0] }),
@@ -43,20 +43,82 @@ describe('buildPageModel', () => {
43
43
  });
44
44
  const model = buildPageModel(tree);
45
45
  expect(model.viewport).toEqual({ width: 1024, height: 768 });
46
- expect(model.landmarks.map(item => item.role)).toEqual(['navigation', 'main', 'form']);
46
+ expect(model.archetypes).toEqual(expect.arrayContaining(['shell', 'form', 'results']));
47
+ expect(model.summary).toEqual({
48
+ landmarkCount: 3,
49
+ formCount: 1,
50
+ dialogCount: 0,
51
+ listCount: 1,
52
+ focusableCount: 1,
53
+ });
54
+ expect(model.landmarks.map(item => item.id)).toEqual(['lm:0', 'lm:1', 'lm:1.0']);
47
55
  expect(model.forms).toHaveLength(1);
48
56
  expect(model.forms[0]).toMatchObject({
57
+ id: 'fm:1.0',
49
58
  name: 'Job application',
50
59
  fieldCount: 2,
51
60
  actionCount: 1,
52
61
  });
53
- expect(model.forms[0]?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
54
- expect(model.forms[0]?.actions.map(action => action.name)).toEqual(['Submit application']);
55
62
  expect(model.lists[0]).toMatchObject({
63
+ id: 'ls:1.1',
56
64
  name: 'Open roles',
57
65
  itemCount: 2,
58
- itemsPreview: ['Designer', 'Engineer'],
59
66
  });
67
+ expect(model.primaryActions).toEqual([
68
+ expect.objectContaining({
69
+ id: 'n:1.0.2',
70
+ role: 'button',
71
+ name: 'Submit application',
72
+ }),
73
+ ]);
74
+ });
75
+ it('expands a section by id on demand', () => {
76
+ const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
77
+ children: [
78
+ node('main', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
79
+ path: [0],
80
+ children: [
81
+ node('form', 'Job application', { x: 40, y: 120, width: 520, height: 280 }, {
82
+ path: [0, 0],
83
+ children: [
84
+ node('heading', 'Application', { x: 60, y: 132, width: 200, height: 24 }, { path: [0, 0, 0] }),
85
+ node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, { path: [0, 0, 1] }),
86
+ node('textbox', 'Email:', { x: 60, y: 208, width: 300, height: 36 }, { path: [0, 0, 2] }),
87
+ node('button', 'Submit application', { x: 60, y: 264, width: 180, height: 40 }, {
88
+ path: [0, 0, 3],
89
+ focusable: true,
90
+ }),
91
+ ],
92
+ }),
93
+ ],
94
+ }),
95
+ ],
96
+ });
97
+ const detail = expandPageSection(tree, 'fm:0.0');
98
+ expect(detail).toMatchObject({
99
+ id: 'fm:0.0',
100
+ kind: 'form',
101
+ role: 'form',
102
+ name: 'Application',
103
+ summary: {
104
+ headingCount: 1,
105
+ fieldCount: 2,
106
+ actionCount: 1,
107
+ },
108
+ });
109
+ expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
110
+ expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
111
+ expect(detail?.fields[0]).not.toHaveProperty('bounds');
112
+ });
113
+ it('drops noisy container names and falls back to unnamed summaries', () => {
114
+ const tree = node('group', undefined, { x: 0, y: 0, width: 800, height: 600 }, {
115
+ children: [
116
+ node('form', 'First Name* Last Name* Email* Phone* Country* Location* Resume* LinkedIn*', { x: 20, y: 20, width: 500, height: 400 }, { path: [0] }),
117
+ ],
118
+ });
119
+ const model = buildPageModel(tree);
120
+ expect(model.forms[0]?.id).toBe('fm:0');
121
+ expect(model.forms[0]?.name).toBeUndefined();
60
122
  });
61
123
  });
62
124
  describe('buildUiDelta', () => {
@@ -115,14 +177,15 @@ describe('buildUiDelta', () => {
115
177
  const delta = buildUiDelta(before, after);
116
178
  expect(hasUiDelta(delta)).toBe(true);
117
179
  expect(delta.dialogsOpened).toHaveLength(1);
180
+ expect(delta.dialogsOpened[0]?.id).toBe('dg:0.2');
118
181
  expect(delta.dialogsOpened[0]?.name).toBe('Save complete');
119
182
  expect(delta.listCountsChanged).toEqual([
120
- { name: 'Results', path: [0, 1], beforeCount: 2, afterCount: 3 },
183
+ { id: 'ls:0.1', name: 'Results', beforeCount: 2, afterCount: 3 },
121
184
  ]);
122
185
  expect(delta.updated.some(update => update.after.name === 'Save' && update.changes.some(change => change.includes('disabled')))).toBe(true);
123
186
  const summary = summarizeUiDelta(delta);
124
- expect(summary).toContain('+ dialog "Save complete" opened');
125
- expect(summary).toContain('~ list "Results" items 2 -> 3');
126
- expect(summary).toContain('~ button "Save": disabled unset -> true');
187
+ expect(summary).toContain('+ dg:0.2 dialog "Save complete" opened');
188
+ expect(summary).toContain('~ ls:0.1 list "Results" items 2 -> 3');
189
+ expect(summary).toContain('~ n:0.0 button "Save": disabled unset -> true');
127
190
  });
128
191
  });
@@ -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
  });
@@ -0,0 +1,20 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ /** Resolve bundled @geometra/proxy CLI entry (dist/index.js). */
3
+ export declare function resolveProxyScriptPath(): string;
4
+ export interface SpawnProxyParams {
5
+ pageUrl: string;
6
+ port: number;
7
+ headless?: boolean;
8
+ width?: number;
9
+ height?: number;
10
+ slowMo?: number;
11
+ }
12
+ export declare function parseProxyReadySignalLine(line: string): string | undefined;
13
+ export declare function formatProxyStartupFailure(message: string, opts: SpawnProxyParams): string;
14
+ /**
15
+ * Spawn geometra-proxy as a child process and resolve when it emits a structured ready signal.
16
+ */
17
+ export declare function spawnGeometraProxy(opts: SpawnProxyParams): Promise<{
18
+ child: ChildProcess;
19
+ wsUrl: string;
20
+ }>;
@@ -0,0 +1,131 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ const require = createRequire(import.meta.url);
5
+ const READY_SIGNAL_TYPE = 'geometra-proxy-ready';
6
+ const READY_TIMEOUT_MS = 45_000;
7
+ /** Resolve bundled @geometra/proxy CLI entry (dist/index.js). */
8
+ export function resolveProxyScriptPath() {
9
+ try {
10
+ const pkgJson = require.resolve('@geometra/proxy/package.json');
11
+ return path.join(path.dirname(pkgJson), 'dist/index.js');
12
+ }
13
+ catch {
14
+ throw new Error('Could not resolve @geometra/proxy. Install it with the MCP package: npm install @geometra/proxy');
15
+ }
16
+ }
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];
36
+ }
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');
41
+ }
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(' ')}`;
48
+ }
49
+ /**
50
+ * Spawn geometra-proxy as a child process and resolve when it emits a structured ready signal.
51
+ */
52
+ export function spawnGeometraProxy(opts) {
53
+ const script = resolveProxyScriptPath();
54
+ const args = [script, opts.pageUrl, '--port', String(opts.port)];
55
+ if (opts.width != null && opts.width > 0)
56
+ args.push('--width', String(opts.width));
57
+ if (opts.height != null && opts.height > 0)
58
+ args.push('--height', String(opts.height));
59
+ if (opts.slowMo != null && opts.slowMo > 0)
60
+ args.push('--slow-mo', String(opts.slowMo));
61
+ if (opts.headless === true)
62
+ args.push('--headless');
63
+ else if (opts.headless === false)
64
+ args.push('--headed');
65
+ return new Promise((resolve, reject) => {
66
+ const child = spawn(process.execPath, args, {
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ env: { ...process.env, GEOMETRA_PROXY_READY_JSON: '1' },
69
+ });
70
+ let settled = false;
71
+ let stdoutBuf = '';
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
+ };
105
+ const deadline = setTimeout(() => {
106
+ if (!settled) {
107
+ settled = true;
108
+ child.kill('SIGTERM');
109
+ cleanup();
110
+ reject(new Error(formatProxyStartupFailure('geometra-proxy did not emit a ready signal within 45s', opts)));
111
+ }
112
+ }, READY_TIMEOUT_MS);
113
+ child.stdout?.on('data', consumeStdout);
114
+ child.stderr?.on('data', consumeStderr);
115
+ child.on('error', err => {
116
+ if (!settled) {
117
+ settled = true;
118
+ cleanup();
119
+ reject(new Error(formatProxyStartupFailure(err.message, opts)));
120
+ }
121
+ });
122
+ child.on('exit', (code, sig) => {
123
+ if (!settled) {
124
+ settled = true;
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)));
128
+ }
129
+ });
130
+ });
131
+ }