aether-mcp-server 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
5
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
+ const ws_server_1 = require("./ws-server");
7
+ const mcp_server_1 = require("./mcp-server");
8
+ const cdp_client_1 = require("./cdp-client");
9
+ const WS_PORT = 3009;
10
+ const MODE = process.env.AETHER_MODE || "cdp"; // "cdp" or "extension"
11
+ // Graceful shutdown handler
12
+ async function shutdown() {
13
+ console.error("\n[Server] Shutting down...");
14
+ const client = (0, cdp_client_1.getCdpClient)();
15
+ if (client.isConnected()) {
16
+ console.error("[Server] Killing browser...");
17
+ await client.killBrowser();
18
+ }
19
+ process.exit(0);
20
+ }
21
+ // Handle shutdown signals and crash events
22
+ process.on("SIGINT", shutdown);
23
+ process.on("SIGTERM", shutdown);
24
+ process.on("uncaughtException", (err) => {
25
+ console.error("[Server] Uncaught Exception:", err);
26
+ shutdown();
27
+ });
28
+ process.on("unhandledRejection", (reason, promise) => {
29
+ console.error("[Server] Unhandled Rejection at:", promise, "reason:", reason);
30
+ shutdown();
31
+ });
32
+ process.on("exit", () => {
33
+ const client = (0, cdp_client_1.getCdpClient)();
34
+ if (client.isConnected()) {
35
+ client.disconnect();
36
+ }
37
+ });
38
+ async function main() {
39
+ console.error(`Starting MCP Browser Server (mode: ${MODE})...`);
40
+ let wsServer = null;
41
+ if (MODE === "extension") {
42
+ // 1. Kill any stale server on the port
43
+ await (0, ws_server_1.ensurePortAvailable)(WS_PORT);
44
+ // 2. Start WebSocket Server for Extension
45
+ wsServer = (0, ws_server_1.StartWebSocketServer)(WS_PORT);
46
+ console.error("WebSocket server started for extension mode");
47
+ }
48
+ else {
49
+ console.error("CDP mode enabled - no extension needed");
50
+ console.error("To connect to a browser, use the 'launch_browser' or 'connect_browser' tool");
51
+ }
52
+ // 3. Initialize MCP Server
53
+ const server = new index_js_1.Server({
54
+ name: "mcp-browser-control",
55
+ version: "2.0.0",
56
+ }, {
57
+ capabilities: {
58
+ tools: {},
59
+ },
60
+ });
61
+ // 4. Register Tools
62
+ (0, mcp_server_1.RegisterMcpTools)(server, wsServer);
63
+ // 5. Connect Transport
64
+ const transport = new stdio_js_1.StdioServerTransport();
65
+ await server.connect(transport);
66
+ console.error("MCP Server connected via Stdio");
67
+ }
68
+ main().catch((err) => {
69
+ console.error("Fatal Error:", err);
70
+ process.exit(1);
71
+ });
@@ -0,0 +1,326 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LocatorEngine = void 0;
4
+ const DEFAULT_TIMEOUT = 7000;
5
+ class LocatorEngine {
6
+ client;
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+ async list(maxElements = 50, includeText = true, withOverlay = false) {
11
+ const capped = Math.max(0, Math.min(Number(maxElements || 50), 200));
12
+ const result = await this.client.evaluate(locatorScript({
13
+ target: "",
14
+ role: "",
15
+ selector: "",
16
+ maxCandidates: capped,
17
+ includeText,
18
+ mode: "list",
19
+ }));
20
+ const candidates = normalizeCandidates(result?.candidates).slice(0, capped);
21
+ if (withOverlay && candidates.length > 0) {
22
+ await this.client.getInteractiveElements(true).catch(() => ({ elements: [], somInjected: false }));
23
+ }
24
+ return candidates;
25
+ }
26
+ async resolve(query) {
27
+ const target = String(query.target ?? query.text ?? "").trim();
28
+ const role = query.role ? String(query.role).toLowerCase() : "";
29
+ const selector = query.selector ? String(query.selector).trim() : "";
30
+ const timeout = query.timeout ?? DEFAULT_TIMEOUT;
31
+ const maxCandidates = Math.max(1, Math.min(Number(query.maxCandidates ?? 20), 50));
32
+ const started = Date.now();
33
+ if (!target && !role && !selector) {
34
+ return { success: false, message: "target, role, or selector required" };
35
+ }
36
+ while (Date.now() - started < timeout) {
37
+ const result = await this.client.evaluate(locatorScript({
38
+ target,
39
+ role,
40
+ selector,
41
+ maxCandidates,
42
+ includeText: true,
43
+ mode: "resolve",
44
+ })).catch((error) => ({ error: error.message }));
45
+ const candidates = normalizeCandidates(result?.candidates);
46
+ const best = candidates[0];
47
+ if (best && (selector || best.score > 0 || role)) {
48
+ return {
49
+ success: true,
50
+ target,
51
+ selector: best.selector,
52
+ ref: best.ref,
53
+ matchedBy: best.matchedBy,
54
+ confidence: Math.min(1, Math.max(0.1, best.score / 18)),
55
+ candidate: best,
56
+ candidates: query.includeCandidates ? candidates.slice(0, maxCandidates) : undefined,
57
+ };
58
+ }
59
+ await sleep(150);
60
+ }
61
+ return { success: false, target, message: "No matching visible element found" };
62
+ }
63
+ async click(candidate, button) {
64
+ const x = candidate.bounds.x + candidate.bounds.width / 2;
65
+ const y = candidate.bounds.y + candidate.bounds.height / 2;
66
+ await this.client.click(x, y, button, candidate.bounds.width);
67
+ }
68
+ async focusAndClear(candidate) {
69
+ await this.click(candidate);
70
+ await this.client.pressKey("a", ["Ctrl"]).catch(() => { });
71
+ await this.client.pressKey("Backspace").catch(() => { });
72
+ return true;
73
+ }
74
+ }
75
+ exports.LocatorEngine = LocatorEngine;
76
+ function normalizeCandidates(value) {
77
+ if (!Array.isArray(value))
78
+ return [];
79
+ return value.filter(Boolean);
80
+ }
81
+ function sleep(ms) {
82
+ return new Promise((resolve) => setTimeout(resolve, ms));
83
+ }
84
+ function locatorScript(input) {
85
+ return `
86
+ (function() {
87
+ const target = ${JSON.stringify(input.target)};
88
+ const targetLower = target.toLowerCase();
89
+ const roleHint = ${JSON.stringify(input.role)};
90
+ const selectorHint = ${JSON.stringify(input.selector)};
91
+ const maxCandidates = ${JSON.stringify(input.maxCandidates)};
92
+ const includeText = ${JSON.stringify(input.includeText)};
93
+ const mode = ${JSON.stringify(input.mode)};
94
+ const interactiveSelector = [
95
+ 'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea',
96
+ '[onclick]', '[role]', '[tabindex]:not([tabindex="-1"])', 'label', 'summary',
97
+ '[contenteditable="true"]', '[aria-label]', '[placeholder]'
98
+ ].join(', ');
99
+
100
+ function norm(value) {
101
+ return String(value || '').trim().replace(/\\s+/g, ' ');
102
+ }
103
+
104
+ function visible(el) {
105
+ const rect = el.getBoundingClientRect();
106
+ const style = window.getComputedStyle(el);
107
+ return style.display !== 'none' &&
108
+ style.visibility !== 'hidden' &&
109
+ style.opacity !== '0' &&
110
+ rect.width > 0 &&
111
+ rect.height > 0;
112
+ }
113
+
114
+ function cssPath(el) {
115
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) return '';
116
+ if (el.id) return '#' + CSS.escape(el.id);
117
+ const path = [];
118
+ let node = el;
119
+ while (node && node.nodeType === Node.ELEMENT_NODE && node !== node.ownerDocument.body) {
120
+ let part = node.nodeName.toLowerCase();
121
+ if (node.classList && node.classList.length) {
122
+ part += '.' + Array.from(node.classList).slice(0, 2).map((c) => CSS.escape(c)).join('.');
123
+ }
124
+ const parent = node.parentElement;
125
+ if (parent) {
126
+ const same = Array.from(parent.children).filter((child) => child.nodeName === node.nodeName);
127
+ if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
128
+ }
129
+ path.unshift(part);
130
+ node = parent;
131
+ }
132
+ return path.join(' > ');
133
+ }
134
+
135
+ function xpath(el) {
136
+ const parts = [];
137
+ let node = el;
138
+ while (node && node.nodeType === Node.ELEMENT_NODE) {
139
+ let index = 1;
140
+ let sibling = node.previousElementSibling;
141
+ while (sibling) {
142
+ if (sibling.nodeName === node.nodeName) index++;
143
+ sibling = sibling.previousElementSibling;
144
+ }
145
+ parts.unshift(node.nodeName.toLowerCase() + '[' + index + ']');
146
+ node = node.parentElement;
147
+ }
148
+ return '/' + parts.join('/');
149
+ }
150
+
151
+ function inferRole(el) {
152
+ const explicit = (el.getAttribute('role') || '').toLowerCase();
153
+ if (explicit) return explicit;
154
+ const tag = el.tagName.toLowerCase();
155
+ const type = (el.getAttribute('type') || '').toLowerCase();
156
+ if (tag === 'button' || type === 'button' || type === 'submit' || type === 'reset') return 'button';
157
+ if (tag === 'a') return 'link';
158
+ if (tag === 'textarea') return 'textbox';
159
+ if (tag === 'select') return 'combobox';
160
+ if (tag === 'input' && ['checkbox', 'radio'].includes(type)) return type;
161
+ if (tag === 'input') return 'textbox';
162
+ if (el.isContentEditable) return 'textbox';
163
+ if (tag === 'summary') return 'button';
164
+ return tag;
165
+ }
166
+
167
+ function byId(doc, id) {
168
+ if (!id) return '';
169
+ const el = doc.getElementById(id);
170
+ return el ? norm(el.innerText || el.textContent) : '';
171
+ }
172
+
173
+ function labelFor(el) {
174
+ const doc = el.ownerDocument;
175
+ const labelledBy = norm((el.getAttribute('aria-labelledby') || '').split(/\\s+/).map((id) => byId(doc, id)).join(' '));
176
+ if (labelledBy) return labelledBy;
177
+ if (el.id) {
178
+ const direct = doc.querySelector('label[for="' + CSS.escape(el.id) + '"]');
179
+ if (direct) return norm(direct.innerText || direct.textContent);
180
+ }
181
+ const wrapping = el.closest && el.closest('label');
182
+ return wrapping ? norm(wrapping.innerText || wrapping.textContent) : '';
183
+ }
184
+
185
+ function textFor(el) {
186
+ return norm(
187
+ el.getAttribute('aria-label') ||
188
+ labelFor(el) ||
189
+ el.getAttribute('placeholder') ||
190
+ el.getAttribute('alt') ||
191
+ el.getAttribute('title') ||
192
+ el.innerText ||
193
+ el.textContent ||
194
+ el.getAttribute('value') ||
195
+ el.getAttribute('name') ||
196
+ ''
197
+ );
198
+ }
199
+
200
+ function scoreField(field, value, exact, includes) {
201
+ const text = norm(value);
202
+ const lower = text.toLowerCase();
203
+ if (!targetLower) return { score: 0, by: '' };
204
+ if (lower === targetLower) return { score: exact, by: field + ':exact' };
205
+ if (lower.includes(targetLower)) return { score: includes, by: field + ':contains' };
206
+ if (targetLower.includes(lower) && lower.length >= 3) return { score: Math.max(1, includes - 1), by: field + ':contained_by_target' };
207
+ return { score: 0, by: '' };
208
+ }
209
+
210
+ function scoreCandidate(item) {
211
+ let score = 0;
212
+ let matchedBy = '';
213
+ const fields = [
214
+ ['selector', item.selector, 13, 10],
215
+ ['xpath', item.xpath, 11, 8],
216
+ ['name', item.name, 12, 10],
217
+ ['label', item.label, 12, 10],
218
+ ['placeholder', item.placeholder, 11, 9],
219
+ ['text', item.text, 10, 8],
220
+ ['title', item.title, 8, 6],
221
+ ['value', item.value, 7, 5]
222
+ ];
223
+ for (const field of fields) {
224
+ const match = scoreField(field[0], field[1], field[2], field[3]);
225
+ if (match.score > score) {
226
+ score = match.score;
227
+ matchedBy = match.by;
228
+ }
229
+ }
230
+ if (roleHint) {
231
+ if (item.role === roleHint) score += 5;
232
+ else if (roleHint === 'textbox' && ['input', 'textarea'].includes(item.tag)) score += 3;
233
+ else score -= 3;
234
+ }
235
+ if (!targetLower && roleHint && item.role === roleHint) {
236
+ matchedBy = 'role';
237
+ score = Math.max(score, 5);
238
+ }
239
+ if (item.scope !== 'document') score += 1;
240
+ item.score = score;
241
+ item.matchedBy = matchedBy || (selectorHint ? 'selector' : '');
242
+ return item;
243
+ }
244
+
245
+ function collectFromRoot(root, framePath, frameOffset, shadowDepth, scope, out) {
246
+ let nodes = [];
247
+ try {
248
+ if (selectorHint) {
249
+ nodes = Array.from(root.querySelectorAll(selectorHint));
250
+ } else {
251
+ nodes = Array.from(root.querySelectorAll(interactiveSelector));
252
+ }
253
+ } catch {
254
+ nodes = [];
255
+ }
256
+
257
+ for (const el of nodes) {
258
+ if (!visible(el)) continue;
259
+ const rect = el.getBoundingClientRect();
260
+ const selector = cssPath(el);
261
+ const role = inferRole(el);
262
+ const label = labelFor(el);
263
+ const item = {
264
+ ref: '',
265
+ selector,
266
+ xpath: xpath(el),
267
+ scope,
268
+ framePath: framePath.slice(),
269
+ shadowDepth,
270
+ tag: el.tagName.toLowerCase(),
271
+ role,
272
+ type: el.getAttribute('type') || '',
273
+ name: norm(el.getAttribute('aria-label') || label || textFor(el) || el.getAttribute('name')),
274
+ text: includeText ? norm(el.innerText || el.textContent).substring(0, 180) : '',
275
+ label,
276
+ placeholder: norm(el.getAttribute('placeholder')),
277
+ title: norm(el.getAttribute('title')),
278
+ value: norm(el.getAttribute('value')),
279
+ score: 0,
280
+ matchedBy: '',
281
+ bounds: {
282
+ x: Math.round(frameOffset.x + rect.left),
283
+ y: Math.round(frameOffset.y + rect.top),
284
+ width: Math.round(rect.width),
285
+ height: Math.round(rect.height)
286
+ }
287
+ };
288
+ item.ref = item.scope === 'document' && item.framePath.length === 0 && item.shadowDepth === 0
289
+ ? 'css:' + item.selector
290
+ : 'point:' + Math.round(item.bounds.x + item.bounds.width / 2) + ',' + Math.round(item.bounds.y + item.bounds.height / 2);
291
+ out.push(scoreCandidate(item));
292
+ }
293
+
294
+ const all = [];
295
+ try {
296
+ all.push(...Array.from(root.querySelectorAll('*')));
297
+ } catch {}
298
+
299
+ for (const el of all) {
300
+ if (el.shadowRoot) {
301
+ collectFromRoot(el.shadowRoot, framePath, frameOffset, shadowDepth + 1, 'shadow', out);
302
+ }
303
+ if (el.tagName && el.tagName.toLowerCase() === 'iframe') {
304
+ try {
305
+ const doc = el.contentDocument;
306
+ if (!doc) continue;
307
+ const rect = el.getBoundingClientRect();
308
+ collectFromRoot(doc, framePath.concat([Array.from(el.ownerDocument.querySelectorAll('iframe')).indexOf(el)]), {
309
+ x: frameOffset.x + rect.left,
310
+ y: frameOffset.y + rect.top
311
+ }, shadowDepth, 'frame', out);
312
+ } catch {}
313
+ }
314
+ }
315
+ }
316
+
317
+ const candidates = [];
318
+ collectFromRoot(document, [], { x: 0, y: 0 }, 0, 'document', candidates);
319
+ candidates.sort((a, b) => {
320
+ if (mode === 'list') return (a.bounds.y - b.bounds.y) || (a.bounds.x - b.bounds.x);
321
+ return b.score - a.score || a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x;
322
+ });
323
+ return { candidates: candidates.slice(0, maxCandidates) };
324
+ })()
325
+ `;
326
+ }
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.textContent = textContent;
4
+ exports.jsonContent = jsonContent;
5
+ exports.toolError = toolError;
6
+ function textContent(text) {
7
+ return { content: [{ type: "text", text }] };
8
+ }
9
+ function jsonContent(value, pretty = false) {
10
+ return textContent(JSON.stringify(value, null, pretty ? 2 : 0));
11
+ }
12
+ function toolError(error) {
13
+ if (error?.captcha) {
14
+ return { content: [{ type: "text", text: JSON.stringify(error.captcha) }], isError: true };
15
+ }
16
+ if (error?.message?.includes("not connected") || error?.message?.includes("No active extension")) {
17
+ return { content: [{ type: "text", text: "Browser not connected. Use 'connect_browser' tool first to connect or launch Chrome." }], isError: true };
18
+ }
19
+ return { content: [{ type: "text", text: `Error: ${error?.message || String(error)}` }], isError: true };
20
+ }