@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 +4 -2
- package/dist/__tests__/connect-utils.test.d.ts +1 -0
- package/dist/__tests__/connect-utils.test.js +78 -0
- 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 +3 -3
- package/dist/proxy-spawn.js +76 -55
- package/dist/server.js +40 -34
- package/dist/session.d.ts +11 -7
- package/dist/session.js +10 -7
- package/package.json +2 -2
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
|
});
|
package/dist/proxy-spawn.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/proxy-spawn.js
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
109
|
+
cleanup();
|
|
110
|
+
reject(new Error(formatProxyStartupFailure('geometra-proxy did not emit a ready signal within 45s', opts)));
|
|
81
111
|
}
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
const
|
|
106
|
-
reject(new Error(`geometra-proxy exited before ready (code=${code} signal=${sig}). Stderr (tail): ${
|
|
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.
|
|
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
|
|
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).
|
|
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('
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
63
|
+
const session = await connect(target.wsUrl);
|
|
62
64
|
const summary = compactSessionSummary(session);
|
|
63
|
-
return ok(`Connected to ${
|
|
65
|
+
return ok(`Connected to ${target.wsUrl}. UI state:\n${summary}`);
|
|
64
66
|
}
|
|
65
67
|
catch (e) {
|
|
66
|
-
return err(`Failed to connect: ${e
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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 {
|
|
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
|
-
//
|
|
1112
|
-
const timeout = setTimeout(() => {
|
|
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.
|
|
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.
|
|
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"
|