@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 +22 -11
- package/dist/__tests__/connect-utils.test.d.ts +1 -0
- package/dist/__tests__/connect-utils.test.js +78 -0
- package/dist/__tests__/session-model.test.js +73 -10
- package/dist/connect-utils.d.ts +18 -0
- package/dist/connect-utils.js +94 -0
- package/dist/index.js +27 -0
- package/dist/proxy-spawn.d.ts +20 -0
- package/dist/proxy-spawn.js +131 -0
- package/dist/server.js +131 -52
- package/dist/session.d.ts +121 -45
- package/dist/session.js +434 -89
- package/package.json +4 -2
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**,
|
|
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:
|
|
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
|
|
20
|
-
| `geometra_query` | Find elements by role, name, or text content |
|
|
21
|
-
| `geometra_page_model` |
|
|
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
|
|
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},"
|
|
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`**
|
|
160
|
-
6.
|
|
161
|
-
7.
|
|
162
|
-
8.
|
|
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('
|
|
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.
|
|
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
|
-
{
|
|
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
|
+
}
|