mcp-server-pixeltable-developer 0.2.1__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.
@@ -0,0 +1,11 @@
1
+ """Pixeltable Developer MCP server."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("mcp-server-pixeltable-developer")
7
+ except PackageNotFoundError: # Source checkout without an installed distribution.
8
+ __version__ = "0.2.1"
9
+
10
+
11
+ __all__ = ["__version__"]
@@ -0,0 +1,29 @@
1
+ """Command-line entry point for the stdio MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+
8
+ from . import __version__
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(
13
+ prog="mcp-server-pixeltable-developer",
14
+ description="Pixeltable developer MCP server over stdio.",
15
+ )
16
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
17
+ parser.parse_args()
18
+
19
+ logging.basicConfig(
20
+ level=logging.INFO,
21
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
22
+ )
23
+ from .server import main as run_server
24
+
25
+ run_server()
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,51 @@
1
+ """Internal fixed-operation subprocess used to isolate Pixeltable's stdout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from contextlib import redirect_stdout
9
+ from typing import Any
10
+
11
+
12
+ def _insert(payload: dict[str, Any]) -> dict[str, Any]:
13
+ with redirect_stdout(sys.stderr):
14
+ import pixeltable as pxt
15
+
16
+ table = pxt.get_table(payload["path"], if_not_exists="ignore")
17
+ if table is None:
18
+ raise ValueError(f"Table does not exist: {payload['path']}")
19
+ rows = payload["rows"]
20
+ if not isinstance(rows, list) or not 1 <= len(rows) <= 1000:
21
+ raise ValueError("rows must contain between 1 and 1000 objects")
22
+ status = table.insert(rows, on_error=payload["on_error"], print_stats=False)
23
+ return {
24
+ "path": payload["path"],
25
+ "num_rows": status.num_rows,
26
+ "num_computed_values": status.num_computed_values,
27
+ "num_exceptions": status.num_excs,
28
+ "columns_with_exceptions": [str(column) for column in status.cols_with_excs],
29
+ "updated_columns": [str(column) for column in status.updated_cols],
30
+ }
31
+
32
+
33
+ def main() -> None:
34
+ os.environ.setdefault("PIXELTABLE_DISABLE_STDOUT", "1")
35
+ if len(sys.argv) != 2:
36
+ raise SystemExit("Internal worker requires one operation")
37
+ payload = json.load(sys.stdin)
38
+ operation = sys.argv[1]
39
+ try:
40
+ if operation == "insert":
41
+ result = _insert(payload)
42
+ else:
43
+ raise ValueError(f"Unknown internal operation: {operation}")
44
+ except Exception as exc:
45
+ print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
46
+ raise SystemExit(1) from None
47
+ print(json.dumps(result, default=str))
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
@@ -0,0 +1,403 @@
1
+ """Authenticated localhost canvas used by the opt-in unsafe display tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import json
7
+ import logging
8
+ import mimetypes
9
+ import os
10
+ import secrets
11
+ import stat
12
+ import sys
13
+ import threading
14
+ from collections import OrderedDict, deque
15
+ from collections.abc import Iterable, Mapping
16
+ from contextlib import suppress
17
+ from http import HTTPStatus
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from pathlib import Path
20
+ from queue import Empty, Full, Queue
21
+ from typing import Any
22
+ from urllib.parse import quote, unquote, urlsplit
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _MAX_CANVAS_MESSAGE_BYTES = 1_000_000
27
+ _MAX_MEDIA_BYTES = 256 * 1024 * 1024
28
+ _MAX_REGISTERED_MEDIA = 256
29
+ _MAX_CLIENT_QUEUE = 100
30
+ _MEDIA_CHUNK_BYTES = 64 * 1024
31
+ _ALLOWED_MEDIA_TYPES = ("image/", "audio/", "video/")
32
+ _ALLOWED_EXACT_MEDIA_TYPES = {"application/pdf"}
33
+
34
+
35
+ class CanvasError(ValueError):
36
+ """An anticipated canvas validation or startup failure."""
37
+
38
+
39
+ class _CanvasHTTPServer(ThreadingHTTPServer):
40
+ daemon_threads = True
41
+ allow_reuse_address = False
42
+
43
+
44
+ class CanvasController:
45
+ """Serve authenticated canvas events and allowlisted local media."""
46
+
47
+ def __init__(
48
+ self,
49
+ media_roots: Iterable[Path],
50
+ *,
51
+ port: int = 7777,
52
+ html_path: Path | None = None,
53
+ ) -> None:
54
+ if not 0 <= port <= 65535:
55
+ raise CanvasError("Canvas port must be between 0 and 65535.")
56
+
57
+ roots: list[Path] = []
58
+ for root in media_roots:
59
+ resolved = Path(root).expanduser().resolve(strict=False)
60
+ if resolved.exists() and resolved.is_dir() and resolved not in roots:
61
+ roots.append(resolved)
62
+ if not roots:
63
+ raise CanvasError("At least one existing media root is required.")
64
+
65
+ self.media_roots = tuple(roots)
66
+ self._token = secrets.token_urlsafe(32)
67
+ self._html = _load_canvas_html(html_path)
68
+ self._clients: set[Queue[bytes]] = set()
69
+ self._clients_lock = threading.Lock()
70
+ self._history: deque[bytes] = deque(maxlen=20)
71
+ self._media: OrderedDict[str, tuple[Path, str, int]] = OrderedDict()
72
+ self._media_lock = threading.Lock()
73
+ self._closed = threading.Event()
74
+
75
+ controller = self
76
+
77
+ class CanvasRequestHandler(BaseHTTPRequestHandler):
78
+ protocol_version = "HTTP/1.1"
79
+
80
+ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
81
+ controller._handle_get(self)
82
+
83
+ def do_OPTIONS(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
84
+ controller._handle_options(self)
85
+
86
+ def log_message(self, format: str, *args: Any) -> None:
87
+ logger.debug("Canvas HTTP: " + format, *args)
88
+
89
+ try:
90
+ self._server = _CanvasHTTPServer(("127.0.0.1", port), CanvasRequestHandler)
91
+ except OSError as exc:
92
+ raise CanvasError(f"Cannot bind the canvas to 127.0.0.1:{port}: {exc}") from exc
93
+
94
+ self.port = int(self._server.server_address[1])
95
+ self.origin = f"http://127.0.0.1:{self.port}"
96
+ self.url = f"{self.origin}/canvas#token={quote(self._token, safe='')}"
97
+ self._thread = threading.Thread(
98
+ target=self._serve,
99
+ name="pixeltable-canvas",
100
+ daemon=True,
101
+ )
102
+ self._thread.start()
103
+
104
+ def _serve(self) -> None:
105
+ try:
106
+ self._server.serve_forever(poll_interval=0.2)
107
+ except Exception:
108
+ if not self._closed.is_set():
109
+ logger.exception("Canvas server stopped unexpectedly")
110
+
111
+ def close(self) -> None:
112
+ """Stop the canvas server and disconnect its clients."""
113
+ if self._closed.is_set():
114
+ return
115
+ self._closed.set()
116
+ self._server.shutdown()
117
+ self._server.server_close()
118
+ self._thread.join(timeout=2)
119
+ with self._clients_lock:
120
+ self._clients.clear()
121
+ self._history.clear()
122
+ with self._media_lock:
123
+ self._media.clear()
124
+
125
+ def broadcast(self, message: Mapping[str, Any]) -> None:
126
+ """Send a JSON-serializable message to every connected canvas."""
127
+ try:
128
+ encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
129
+ except (TypeError, ValueError) as exc:
130
+ raise CanvasError(f"Canvas data must be JSON serializable: {exc}") from exc
131
+ if len(encoded) > _MAX_CANVAS_MESSAGE_BYTES:
132
+ raise CanvasError(f"Canvas message exceeds the {_MAX_CANVAS_MESSAGE_BYTES}-byte limit.")
133
+
134
+ event = b"data: " + encoded + b"\n\n"
135
+ with self._clients_lock:
136
+ self._history.append(event)
137
+ clients = tuple(self._clients)
138
+ for client in clients:
139
+ try:
140
+ client.put_nowait(event)
141
+ except Full:
142
+ with suppress(Empty):
143
+ client.get_nowait()
144
+ try:
145
+ client.put_nowait(event)
146
+ except Full:
147
+ logger.debug("Dropping a canvas message for a slow client")
148
+
149
+ def register_media(self, source: str) -> str:
150
+ """Validate a file URL and return its authenticated canvas URL."""
151
+ path = _path_from_file_url(source)
152
+ try:
153
+ resolved = path.expanduser().resolve(strict=True)
154
+ except (OSError, RuntimeError) as exc:
155
+ raise CanvasError("The local media file does not exist.") from exc
156
+ if not resolved.is_file() or not _is_within_roots(resolved, self.media_roots):
157
+ raise CanvasError("Local media must be a file inside a configured media root.")
158
+
159
+ media_type = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream"
160
+ if not (media_type.startswith(_ALLOWED_MEDIA_TYPES) or media_type in _ALLOWED_EXACT_MEDIA_TYPES):
161
+ raise CanvasError(f"Unsupported local media type: {media_type}.")
162
+ size = resolved.stat().st_size
163
+ if size > _MAX_MEDIA_BYTES:
164
+ raise CanvasError(f"Local media exceeds the {_MAX_MEDIA_BYTES}-byte limit.")
165
+
166
+ media_id = secrets.token_urlsafe(18)
167
+ with self._media_lock:
168
+ self._media[media_id] = (resolved, media_type, size)
169
+ self._media.move_to_end(media_id)
170
+ while len(self._media) > _MAX_REGISTERED_MEDIA:
171
+ self._media.popitem(last=False)
172
+ return f"/media/{media_id}/{quote(resolved.name, safe='')}"
173
+
174
+ def _handle_get(self, handler: BaseHTTPRequestHandler) -> None:
175
+ if not self._request_origin_is_allowed(handler):
176
+ self._send_error(handler, HTTPStatus.FORBIDDEN, "Cross-origin request rejected.")
177
+ return
178
+
179
+ request_path = urlsplit(handler.path).path
180
+ if request_path == "/canvas":
181
+ self._send_bytes(handler, HTTPStatus.OK, self._html, "text/html; charset=utf-8")
182
+ return
183
+ if request_path == "/canvas/stream":
184
+ if not self._is_authorized(handler):
185
+ self._send_unauthorized(handler)
186
+ return
187
+ self._serve_event_stream(handler)
188
+ return
189
+ if request_path.startswith("/media/"):
190
+ if not self._is_authorized(handler):
191
+ self._send_unauthorized(handler)
192
+ return
193
+ self._serve_media(handler, request_path)
194
+ return
195
+ self._send_error(handler, HTTPStatus.NOT_FOUND, "Not found.")
196
+
197
+ def _handle_options(self, handler: BaseHTTPRequestHandler) -> None:
198
+ if not self._request_origin_is_allowed(handler):
199
+ self._send_error(handler, HTTPStatus.FORBIDDEN, "Cross-origin request rejected.")
200
+ return
201
+ handler.send_response(HTTPStatus.NO_CONTENT)
202
+ self._send_security_headers(handler)
203
+ handler.send_header("Content-Length", "0")
204
+ handler.end_headers()
205
+
206
+ def _request_origin_is_allowed(self, handler: BaseHTTPRequestHandler) -> bool:
207
+ host = handler.headers.get("Host", "").lower()
208
+ allowed_hosts = {f"127.0.0.1:{self.port}", f"localhost:{self.port}"}
209
+ if host not in allowed_hosts:
210
+ return False
211
+ origin = handler.headers.get("Origin")
212
+ return origin is None or origin == self.origin
213
+
214
+ def _is_authorized(self, handler: BaseHTTPRequestHandler) -> bool:
215
+ authorization = handler.headers.get("Authorization", "")
216
+ return hmac.compare_digest(authorization, f"Bearer {self._token}")
217
+
218
+ def _serve_event_stream(self, handler: BaseHTTPRequestHandler) -> None:
219
+ client: Queue[bytes] = Queue(maxsize=_MAX_CLIENT_QUEUE)
220
+ with self._clients_lock:
221
+ self._clients.add(client)
222
+ history = tuple(self._history)
223
+
224
+ handler.send_response(HTTPStatus.OK)
225
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
226
+ handler.send_header("Cache-Control", "no-store")
227
+ handler.send_header("Connection", "keep-alive")
228
+ self._send_security_headers(handler)
229
+ handler.end_headers()
230
+ try:
231
+ handler.wfile.write(b'data: {"type":"connected"}\n\n')
232
+ for event in history:
233
+ handler.wfile.write(event)
234
+ handler.wfile.flush()
235
+ while not self._closed.is_set():
236
+ try:
237
+ event = client.get(timeout=15)
238
+ except Empty:
239
+ event = b": keepalive\n\n"
240
+ handler.wfile.write(event)
241
+ handler.wfile.flush()
242
+ except (BrokenPipeError, ConnectionResetError, OSError):
243
+ pass
244
+ finally:
245
+ with self._clients_lock:
246
+ self._clients.discard(client)
247
+
248
+ def _serve_media(self, handler: BaseHTTPRequestHandler, request_path: str) -> None:
249
+ parts = request_path.split("/", 4)
250
+ if len(parts) < 4 or not parts[2]:
251
+ self._send_error(handler, HTTPStatus.NOT_FOUND, "Media not found.")
252
+ return
253
+ media_id = parts[2]
254
+ with self._media_lock:
255
+ media = self._media.get(media_id)
256
+ if media is None:
257
+ self._send_error(handler, HTTPStatus.NOT_FOUND, "Media not found.")
258
+ return
259
+
260
+ path, media_type, expected_size = media
261
+ try:
262
+ resolved = path.resolve(strict=True)
263
+ if resolved != path or not _is_within_roots(resolved, self.media_roots):
264
+ raise OSError("media path changed")
265
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
266
+ file_descriptor = os.open(resolved, flags)
267
+ file_stat = os.fstat(file_descriptor)
268
+ if not stat.S_ISREG(file_stat.st_mode):
269
+ raise OSError("media is no longer a regular file")
270
+ if file_stat.st_size != expected_size or file_stat.st_size > _MAX_MEDIA_BYTES:
271
+ raise OSError("media size changed")
272
+ except OSError:
273
+ with suppress(NameError, OSError):
274
+ os.close(file_descriptor)
275
+ self._send_error(handler, HTTPStatus.NOT_FOUND, "Media not found.")
276
+ return
277
+
278
+ handler.send_response(HTTPStatus.OK)
279
+ handler.send_header("Content-Type", media_type)
280
+ handler.send_header("Content-Length", str(file_stat.st_size))
281
+ handler.send_header(
282
+ "Content-Disposition",
283
+ f"inline; filename*=UTF-8''{quote(path.name, safe='')}",
284
+ )
285
+ self._send_security_headers(handler)
286
+ handler.end_headers()
287
+ try:
288
+ with os.fdopen(file_descriptor, "rb") as media_file:
289
+ while chunk := media_file.read(_MEDIA_CHUNK_BYTES):
290
+ handler.wfile.write(chunk)
291
+ except (BrokenPipeError, ConnectionResetError, OSError):
292
+ pass
293
+
294
+ def _send_unauthorized(self, handler: BaseHTTPRequestHandler) -> None:
295
+ handler.send_response(HTTPStatus.UNAUTHORIZED)
296
+ handler.send_header("WWW-Authenticate", 'Bearer realm="pixeltable-canvas"')
297
+ self._send_json_body(handler, {"error": "Bearer token required."})
298
+
299
+ def _send_error(
300
+ self,
301
+ handler: BaseHTTPRequestHandler,
302
+ status: HTTPStatus,
303
+ message: str,
304
+ ) -> None:
305
+ handler.send_response(status)
306
+ self._send_json_body(handler, {"error": message})
307
+
308
+ def _send_json_body(self, handler: BaseHTTPRequestHandler, value: Mapping[str, str]) -> None:
309
+ body = json.dumps(value, separators=(",", ":")).encode("utf-8")
310
+ self._send_response_body(handler, body, "application/json; charset=utf-8")
311
+
312
+ def _send_bytes(
313
+ self,
314
+ handler: BaseHTTPRequestHandler,
315
+ status: HTTPStatus,
316
+ body: bytes,
317
+ media_type: str,
318
+ ) -> None:
319
+ handler.send_response(status)
320
+ self._send_response_body(handler, body, media_type)
321
+
322
+ def _send_response_body(
323
+ self,
324
+ handler: BaseHTTPRequestHandler,
325
+ body: bytes,
326
+ media_type: str,
327
+ ) -> None:
328
+ handler.send_header("Content-Type", media_type)
329
+ handler.send_header("Content-Length", str(len(body)))
330
+ self._send_security_headers(handler)
331
+ handler.end_headers()
332
+ handler.wfile.write(body)
333
+
334
+ def _send_security_headers(self, handler: BaseHTTPRequestHandler) -> None:
335
+ handler.send_header("Cache-Control", "no-store")
336
+ handler.send_header("X-Content-Type-Options", "nosniff")
337
+ handler.send_header("Referrer-Policy", "no-referrer")
338
+ handler.send_header("Cross-Origin-Opener-Policy", "same-origin")
339
+ handler.send_header("Cross-Origin-Resource-Policy", "same-origin")
340
+ handler.send_header("X-Frame-Options", "DENY")
341
+ handler.send_header(
342
+ "Content-Security-Policy",
343
+ "default-src 'self'; base-uri 'none'; connect-src 'self'; "
344
+ "form-action 'none'; frame-ancestors 'none'; frame-src 'self' data:; "
345
+ "img-src 'self' data: blob: https:; media-src 'self' blob: https:; "
346
+ "object-src 'none'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
347
+ "style-src 'self' 'unsafe-inline'",
348
+ )
349
+ if handler.headers.get("Origin") == self.origin:
350
+ handler.send_header("Access-Control-Allow-Origin", self.origin)
351
+ handler.send_header("Vary", "Origin")
352
+ handler.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
353
+ handler.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
354
+
355
+
356
+ def _path_from_file_url(source: str) -> Path:
357
+ parsed = urlsplit(source)
358
+ if parsed.scheme != "file" or parsed.netloc not in ("", "localhost"):
359
+ raise CanvasError("Local media must use a file:// URL without a remote host.")
360
+ if parsed.query or parsed.fragment:
361
+ raise CanvasError("Local media file URLs cannot contain a query or fragment.")
362
+ decoded = unquote(parsed.path)
363
+ if not decoded or not Path(decoded).is_absolute():
364
+ raise CanvasError("Local media file URLs must contain an absolute path.")
365
+ return Path(decoded)
366
+
367
+
368
+ def _is_within_roots(path: Path, roots: tuple[Path, ...]) -> bool:
369
+ return any(path == root or path.is_relative_to(root) for root in roots)
370
+
371
+
372
+ def _load_canvas_html(explicit_path: Path | None) -> bytes:
373
+ candidates = []
374
+ if explicit_path is not None:
375
+ candidates.append(Path(explicit_path))
376
+ candidates.extend(
377
+ [
378
+ Path(__file__).resolve().parents[2] / "canvas.html",
379
+ Path(sys.prefix) / "share" / "mcp-server-pixeltable-developer" / "canvas.html",
380
+ ]
381
+ )
382
+ for candidate in candidates:
383
+ try:
384
+ if candidate.is_file():
385
+ return candidate.read_bytes()
386
+ except OSError:
387
+ continue
388
+ raise CanvasError("The packaged canvas.html asset is missing.")
389
+
390
+
391
+ def transform_local_media(value: Any, canvas: CanvasController, *, _depth: int = 0) -> Any:
392
+ """Replace nested file URLs with authenticated, allowlisted canvas URLs."""
393
+ if _depth > 20:
394
+ raise CanvasError("Canvas data nesting exceeds the supported depth.")
395
+ if isinstance(value, str):
396
+ return canvas.register_media(value) if value.startswith("file://") else value
397
+ if isinstance(value, list):
398
+ return [transform_local_media(item, canvas, _depth=_depth + 1) for item in value]
399
+ if isinstance(value, tuple):
400
+ return [transform_local_media(item, canvas, _depth=_depth + 1) for item in value]
401
+ if isinstance(value, dict):
402
+ return {key: transform_local_media(item, canvas, _depth=_depth + 1) for key, item in value.items()}
403
+ return value
@@ -0,0 +1,45 @@
1
+ """Diagnostic fixtures used only by the Streamable HTTP conformance entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mcp.server.mcpserver import Context, MCPServer
8
+ from mcp.server.mcpserver.prompts.base import Prompt
9
+ from mcp.shared.exceptions import MCPError
10
+ from mcp.types import MISSING_REQUIRED_CLIENT_CAPABILITY
11
+
12
+
13
+ def register_conformance_diagnostics(server: MCPServer[Any]) -> None:
14
+ """Register harness probes without changing the production stdio surface."""
15
+
16
+ @server.tool(name="test_missing_capability")
17
+ async def missing_capability(ctx: Context) -> str:
18
+ capabilities = ctx.session.client_capabilities
19
+ if capabilities is None or capabilities.sampling is None:
20
+ raise MCPError(
21
+ code=MISSING_REQUIRED_CLIENT_CAPABILITY,
22
+ message="This diagnostic requires the sampling capability",
23
+ data={"requiredCapabilities": {"sampling": {}}},
24
+ )
25
+ return "sampling capability declared"
26
+
27
+ def dynamic_tool() -> str:
28
+ return "dynamic"
29
+
30
+ def dynamic_prompt() -> str:
31
+ return "dynamic"
32
+
33
+ @server.tool(name="test_trigger_tool_change")
34
+ async def trigger_tool_change(ctx: Context) -> str:
35
+ server.add_tool(dynamic_tool, name="test_dynamic_tool")
36
+ server.remove_tool("test_dynamic_tool")
37
+ await ctx.notify_tools_changed()
38
+ return "tool list changed"
39
+
40
+ @server.tool(name="test_trigger_prompt_change")
41
+ async def trigger_prompt_change(ctx: Context) -> str:
42
+ server.add_prompt(Prompt.from_function(dynamic_prompt, name="test_dynamic_prompt", description="dynamic"))
43
+ server.remove_prompt("test_dynamic_prompt")
44
+ await ctx.notify_prompts_changed()
45
+ return "prompt list changed"