foxmcp 1.5.0__py3-none-any.whl

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.
foxmcp/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ FoxMCP Server Package
3
+ """
4
+
5
+ from .server import FoxMCPServer
6
+ from .mcp_tools import FoxMCPTools
7
+
8
+ __all__ = ["FoxMCPServer", "FoxMCPTools"]
@@ -0,0 +1,257 @@
1
+ """Discovery and browser-command forwarding on the extension's existing port."""
2
+
3
+ import asyncio
4
+ from http import HTTPStatus
5
+ import ipaddress
6
+ import json
7
+ import logging
8
+ import math
9
+ import os
10
+ import re
11
+ import time
12
+ import uuid
13
+
14
+ from websockets.exceptions import ConnectionClosed
15
+
16
+ logger = logging.getLogger(__name__)
17
+ SERVER_NAME = "FoxMCP"
18
+ PROTOCOL_VERSION = 2
19
+ EXTENSION_ORIGIN_PATTERN = re.compile(r"moz-extension://.+")
20
+
21
+
22
+ class BrowserProxyHub:
23
+ """Route extension and local-client sockets without mixing their identities."""
24
+
25
+ def __init__(self, server):
26
+ self.server = server
27
+ self.clients = {}
28
+ self.idle_timeout = None # Manually started servers have no idle shutdown.
29
+ self.last_departure = time.monotonic()
30
+ self.stopping = False
31
+
32
+ @staticmethod
33
+ def _is_local_host(authority):
34
+ # Validate the authority independently of the listener port: a local
35
+ # TCP forwarder preserves Host while connecting to a different port.
36
+ match = re.fullmatch(
37
+ r"(?:localhost|127\.0\.0\.1|\[::1\])(?::([0-9]{1,5}))?",
38
+ authority.lower(),
39
+ )
40
+ return match is not None and (
41
+ match[1] is None or 1 <= int(match[1]) <= 65535
42
+ )
43
+
44
+ def discovery(self):
45
+ return {
46
+ "server": SERVER_NAME,
47
+ "protocol_version": PROTOCOL_VERSION,
48
+ "proxy_path": "/proxy",
49
+ "pid": os.getpid(),
50
+ "lifetime": "automatic" if self.idle_timeout is not None else "manual",
51
+ }
52
+
53
+ def process_request(self, connection, request):
54
+ """Apply route-specific access checks before a socket can replace Firefox."""
55
+ host = request.headers.get_all("Host")
56
+ origin = request.headers.get_all("Origin")
57
+ remote = ipaddress.ip_address(connection.remote_address[0])
58
+ if not remote.is_loopback or len(host) != 1 or not self._is_local_host(host[0]):
59
+ return connection.respond(HTTPStatus.FORBIDDEN, "Localhost clients only\n")
60
+
61
+ if request.path == "/":
62
+ if not request.headers.get_all("Upgrade"):
63
+ return connection.respond(
64
+ HTTPStatus.UPGRADE_REQUIRED, "WebSocket required\n"
65
+ )
66
+ if len(origin) != 1 or not EXTENSION_ORIGIN_PATTERN.fullmatch(origin[0]):
67
+ return connection.respond(
68
+ HTTPStatus.FORBIDDEN, "Extension origin required\n"
69
+ )
70
+ elif request.path in ("/discovery", "/proxy"):
71
+ # Browser scripts always send Origin on WebSockets. Discovery has
72
+ # no CORS allowance either; reject even simple HTTP page requests.
73
+ if origin:
74
+ return connection.respond(
75
+ HTTPStatus.FORBIDDEN, "Browser origins are refused\n"
76
+ )
77
+ if self.stopping:
78
+ return connection.respond(
79
+ HTTPStatus.SERVICE_UNAVAILABLE, "Server stopping\n"
80
+ )
81
+ if request.path == "/discovery":
82
+ response = connection.respond(
83
+ HTTPStatus.OK, json.dumps(self.discovery())
84
+ )
85
+ del response.headers["Content-Type"]
86
+ response.headers["Content-Type"] = "application/json"
87
+ response.headers["Cache-Control"] = "no-store"
88
+ return response
89
+ else:
90
+ return connection.respond(HTTPStatus.NOT_FOUND, "Unknown endpoint\n")
91
+ return None
92
+
93
+ async def handle_connection(self, websocket):
94
+ if websocket.request.path == "/proxy":
95
+ await self.handle_proxy(websocket)
96
+ else:
97
+ await self.server.handle_extension_connection(websocket)
98
+
99
+ async def handle_proxy(self, websocket):
100
+ owner = f"proxy:{uuid.uuid4().hex}"
101
+ self.clients[websocket] = owner
102
+ pending = {}
103
+ try:
104
+ await websocket.send(json.dumps({"type": "hello", **self.discovery()}))
105
+ async for message in websocket:
106
+ try:
107
+ request = json.loads(message)
108
+ if not isinstance(request, dict):
109
+ raise ValueError("Request must be an object")
110
+ request_id = request.get("id")
111
+ timeout = request.get("timeout", 30.0)
112
+ if (
113
+ not isinstance(request_id, str)
114
+ or not request_id
115
+ or request.get("type") != "request"
116
+ or not isinstance(request.get("action"), str)
117
+ or not isinstance(request.get("data", {}), dict)
118
+ or not isinstance(timeout, (float, int))
119
+ or isinstance(timeout, bool)
120
+ or not 0 < timeout <= 300
121
+ or not math.isfinite(timeout)
122
+ ):
123
+ raise ValueError("Invalid browser request")
124
+ if request_id in pending:
125
+ raise ValueError("Request ID is already in flight")
126
+ except (ValueError, TypeError):
127
+ await websocket.close(code=1008, reason="Invalid browser request")
128
+ break
129
+ task = asyncio.create_task(
130
+ self.forward(websocket, owner, request, timeout)
131
+ )
132
+ pending[request_id] = task
133
+ task.add_done_callback(
134
+ lambda _task, key=request_id: pending.pop(key, None)
135
+ )
136
+ except ConnectionClosed:
137
+ pass
138
+ finally:
139
+ # A start-monitoring call may finish after the client goes away.
140
+ # Finish tracking its reply before cleaning up everything it owned.
141
+ await asyncio.gather(*list(pending.values()), return_exceptions=True)
142
+ self.clients.pop(websocket, None)
143
+ try:
144
+ await self.server.stop_monitors_of_gone_clients()
145
+ finally:
146
+ self.last_departure = time.monotonic()
147
+
148
+ async def forward(self, websocket, owner, request, timeout):
149
+ original_id = request["id"]
150
+ action = request["action"]
151
+ try:
152
+ if action == "proxy.status":
153
+ response = {
154
+ "type": "response",
155
+ "action": action,
156
+ "data": {"status": await self.server.get_extension_status()},
157
+ }
158
+ else:
159
+ upstream = {**request, "id": uuid.uuid4().hex}
160
+ upstream.pop("timeout", None)
161
+ response = await self.server.send_request_and_wait(
162
+ upstream, timeout=timeout
163
+ )
164
+ if response.get("type") == "response":
165
+ data = response.get("data", {})
166
+ if action == "requests.start_monitoring":
167
+ self.server.register_monitor(data.get("monitor_id"), owner)
168
+ elif action == "requests.stop_monitoring":
169
+ self.server.forget_monitor(
170
+ request.get("data", {}).get("monitor_id")
171
+ )
172
+ await websocket.send(json.dumps({**response, "id": original_id}))
173
+ except ConnectionClosed:
174
+ pass
175
+ except Exception:
176
+ logger.exception("Browser proxy request failed: %s", action)
177
+ try:
178
+ await websocket.send(
179
+ json.dumps(
180
+ {"id": original_id, "error": "Browser proxy request failed"}
181
+ )
182
+ )
183
+ except ConnectionClosed:
184
+ pass
185
+
186
+ async def wait_until_idle(self):
187
+ while True:
188
+ await asyncio.sleep(min(self.idle_timeout, 1.0))
189
+ if (
190
+ not self.clients
191
+ and time.monotonic() - self.last_departure >= self.idle_timeout
192
+ ):
193
+ self.stopping = True
194
+ return
195
+
196
+
197
+ class RemoteBrowser:
198
+ """The browser interface used by local MCP tools, over a shared proxy socket."""
199
+
200
+ def __init__(self, websocket, file_transfers):
201
+ self.websocket = websocket
202
+ self.file_transfers = file_transfers
203
+ self.pending = {}
204
+ self.reader = asyncio.create_task(self.read_responses())
205
+
206
+ async def read_responses(self):
207
+ try:
208
+ async for message in self.websocket:
209
+ response = json.loads(message)
210
+ future = self.pending.get(response.get("id"))
211
+ if future is not None and not future.done():
212
+ future.set_result(response)
213
+ except (ConnectionClosed, ValueError, TypeError, AttributeError):
214
+ pass
215
+ finally:
216
+ for future in self.pending.values():
217
+ if not future.done():
218
+ future.set_result(
219
+ {
220
+ "error": "Browser proxy disconnected; reconnect the MCP client"
221
+ }
222
+ )
223
+
224
+ async def send_request_and_wait(self, request, timeout=30.0):
225
+ if self.reader.done():
226
+ return {"error": "Browser proxy disconnected; reconnect the MCP client"}
227
+ request_id = request["id"]
228
+ if request_id in self.pending:
229
+ return {"error": "Request ID is already in flight"}
230
+ future = asyncio.get_running_loop().create_future()
231
+ self.pending[request_id] = future
232
+ try:
233
+ await self.websocket.send(json.dumps({**request, "timeout": timeout}))
234
+ return await asyncio.wait_for(future, timeout + 1)
235
+ except ConnectionClosed:
236
+ return {"error": "Browser proxy disconnected; reconnect the MCP client"}
237
+ except asyncio.TimeoutError:
238
+ return {"error": f"Request timed out after {timeout} seconds"}
239
+ finally:
240
+ self.pending.pop(request_id, None)
241
+
242
+ async def get_extension_status(self):
243
+ response = await self.send_request_and_wait(
244
+ {
245
+ "id": uuid.uuid4().hex,
246
+ "type": "request",
247
+ "action": "proxy.status",
248
+ "data": {},
249
+ }
250
+ )
251
+ return response.get("data", {}).get("status") or response.get(
252
+ "error", "Unknown browser status"
253
+ )
254
+
255
+ async def close(self):
256
+ await self.websocket.close()
257
+ await self.reader
foxmcp/cli.py ADDED
@@ -0,0 +1,14 @@
1
+ """Console entry point for the installed FoxMCP distribution."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .server import run
7
+
8
+
9
+ def main() -> None:
10
+ """Use bundled examples unless configured, then start the existing server."""
11
+ examples = Path(__file__).with_name("examples")
12
+ if examples.is_dir():
13
+ os.environ.setdefault("FOXMCP_EXT_SCRIPTS", str(examples))
14
+ run()
@@ -0,0 +1,207 @@
1
+ #!/bin/bash
2
+ # foxmcp-description: List the page's visible interactive elements as an indented tree, tagging each with a persistent dsid attribute to select it by later
3
+ # foxmcp-args: [onscreen [withpos]]
4
+ # DOM Summarization Script
5
+ # Extracts visible interactive elements (input, textarea, select, button, a, option) from the current page
6
+ # Assigns persistent dsid attributes to each element for future reference
7
+ # Outputs a hierarchical structure with indentation showing nesting relationships
8
+ #
9
+ # Usage: Called via foxmcp content_execute_predefined
10
+ # Arguments:
11
+ # $1 - Optional: "onscreen" to filter only elements visible in viewport
12
+ # $2 - Optional: "withpos" to include position/size info (only valid with "onscreen")
13
+ #
14
+ # Output format:
15
+ # - Elements show only dsid attribute (and type for input, value/selected for options)
16
+ # - With "withpos": adds pos="x,y,width,height" attribute showing element's screen position
17
+ # - Text nodes shown as quoted strings without prefix
18
+ # - 2-space indentation shows nesting hierarchy
19
+ # - Non-interesting containers are flattened (their children promoted to parent level)
20
+
21
+ FILTER_MODE="${1:-all}"
22
+ INCLUDE_POS="${2:-}"
23
+
24
+ # Validate that withpos is only used with onscreen
25
+ if [ "$INCLUDE_POS" = "withpos" ] && [ "$FILTER_MODE" != "onscreen" ]; then
26
+ echo "Error: 'withpos' option requires 'onscreen' mode" >&2
27
+ exit 1
28
+ fi
29
+
30
+ # Generate JavaScript with filter mode injected
31
+ cat << 'OUTER_EOF'
32
+ (function() {
33
+ OUTER_EOF
34
+
35
+ echo " const FILTER_MODE = \"${FILTER_MODE}\";"
36
+ echo " const INCLUDE_POS = \"${INCLUDE_POS}\";"
37
+
38
+ cat << 'INNER_EOF'
39
+ let dsIdCounter = 1;
40
+
41
+ // Find the highest existing dsid to continue numbering from there
42
+ const allElements = document.querySelectorAll('[dsid]');
43
+ allElements.forEach(el => {
44
+ const id = parseInt(el.getAttribute('dsid'));
45
+ if (id >= dsIdCounter) {
46
+ dsIdCounter = id + 1;
47
+ }
48
+ });
49
+
50
+ function isElementVisible(element) {
51
+ if (!element) return false;
52
+
53
+ const style = window.getComputedStyle(element);
54
+
55
+ if (style.display === 'none') return false;
56
+ if (style.visibility === 'hidden') return false;
57
+ if (parseFloat(style.opacity) === 0) return false;
58
+
59
+ return true;
60
+ }
61
+
62
+ function isElementOnScreen(element) {
63
+ if (!element) return false;
64
+
65
+ const rect = element.getBoundingClientRect();
66
+ return (
67
+ rect.top < window.innerHeight &&
68
+ rect.bottom > 0 &&
69
+ rect.left < window.innerWidth &&
70
+ rect.right > 0
71
+ );
72
+ }
73
+
74
+ function hasEventListeners(element) {
75
+ for (let attr of element.attributes) {
76
+ if (attr.name.startsWith('on')) {
77
+ return true;
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+
83
+ function isInterestingElement(node) {
84
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
85
+ const tagName = node.tagName.toLowerCase();
86
+ if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || tagName === 'button' || tagName === 'a' || tagName === 'option') {
87
+ return true;
88
+ }
89
+ if (hasEventListeners(node)) {
90
+ return true;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ function traverseDOM(node, depth = 0) {
96
+ const result = [];
97
+ const indent = ' '.repeat(depth);
98
+
99
+ if (node.nodeType === Node.TEXT_NODE) {
100
+ const text = node.textContent.trim();
101
+ if (text.length > 0) {
102
+ result.push(`${indent}"${text.substring(0, 100)}${text.length > 100 ? '...' : ''}"`);
103
+ }
104
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
105
+ if (!isElementVisible(node)) {
106
+ return result;
107
+ }
108
+
109
+ // Apply onscreen filter if requested
110
+ if (FILTER_MODE === 'onscreen' && !isElementOnScreen(node)) {
111
+ return result;
112
+ }
113
+
114
+ const isInteresting = isInterestingElement(node);
115
+
116
+ if (isInteresting) {
117
+ // Assign dsid if not already present
118
+ if (!node.hasAttribute('dsid')) {
119
+ node.setAttribute('dsid', dsIdCounter++);
120
+ }
121
+ const dsId = node.getAttribute('dsid');
122
+ const tagName = node.tagName.toLowerCase();
123
+
124
+ let elementStr = `${indent}<${tagName} dsid="${dsId}"`;
125
+
126
+ // Include type attribute for input elements
127
+ if (tagName === 'input') {
128
+ const type = node.type || 'text';
129
+ elementStr += ` type="${type}"`;
130
+ }
131
+
132
+ // Only include value and selected attributes for options
133
+ if (tagName === 'option') {
134
+ if (node.value) elementStr += ` value="${node.value}"`;
135
+ if (node.selected) elementStr += ` selected="true"`;
136
+ }
137
+
138
+ // Include position and size if requested
139
+ if (INCLUDE_POS === 'withpos') {
140
+ const rect = node.getBoundingClientRect();
141
+ const x = Math.round(rect.left);
142
+ const y = Math.round(rect.top);
143
+ const width = Math.round(rect.width);
144
+ const height = Math.round(rect.height);
145
+ elementStr += ` pos="${x},${y},${width},${height}"`;
146
+ }
147
+
148
+ elementStr += '>';
149
+ result.push(elementStr);
150
+
151
+ for (let child of node.childNodes) {
152
+ result.push(...traverseDOM(child, depth + 1));
153
+ }
154
+ } else {
155
+ // Not interesting, flatten: process children at same depth
156
+ for (let child of node.childNodes) {
157
+ result.push(...traverseDOM(child, depth));
158
+ }
159
+ }
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ function concatenateAdjacentText(lines) {
166
+ const result = [];
167
+ let i = 0;
168
+
169
+ while (i < lines.length) {
170
+ const line = lines[i];
171
+ const indent = line.match(/^(\s*)/)[1];
172
+ const isText = line.trim().startsWith('"');
173
+
174
+ if (isText) {
175
+ // Collect all adjacent text nodes at the same indentation level
176
+ let concatenated = line.trim().slice(1, -1); // Remove quotes
177
+ let j = i + 1;
178
+
179
+ while (j < lines.length) {
180
+ const nextLine = lines[j];
181
+ const nextIndent = nextLine.match(/^(\s*)/)[1];
182
+ const nextIsText = nextLine.trim().startsWith('"');
183
+
184
+ if (nextIsText && nextIndent === indent) {
185
+ concatenated += ' ' + nextLine.trim().slice(1, -1);
186
+ j++;
187
+ } else {
188
+ break;
189
+ }
190
+ }
191
+
192
+ result.push(`${indent}"${concatenated}"`);
193
+ i = j;
194
+ } else {
195
+ result.push(line);
196
+ i++;
197
+ }
198
+ }
199
+
200
+ return result;
201
+ }
202
+
203
+ const output = traverseDOM(document.body);
204
+ const concatenated = concatenateAdjacentText(output);
205
+ return concatenated.join('\n');
206
+ })();
207
+ INNER_EOF