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.
- mcp_server_pixeltable_developer/__init__.py +11 -0
- mcp_server_pixeltable_developer/__main__.py +29 -0
- mcp_server_pixeltable_developer/_worker.py +51 -0
- mcp_server_pixeltable_developer/canvas.py +403 -0
- mcp_server_pixeltable_developer/conformance.py +45 -0
- mcp_server_pixeltable_developer/models.py +192 -0
- mcp_server_pixeltable_developer/prompts.py +97 -0
- mcp_server_pixeltable_developer/resources.py +134 -0
- mcp_server_pixeltable_developer/runtime.py +324 -0
- mcp_server_pixeltable_developer/server.py +125 -0
- mcp_server_pixeltable_developer/tools.py +700 -0
- mcp_server_pixeltable_developer/unsafe.py +441 -0
- mcp_server_pixeltable_developer-0.2.1.data/data/share/mcp-server-pixeltable-developer/canvas.html +243 -0
- mcp_server_pixeltable_developer-0.2.1.dist-info/METADATA +303 -0
- mcp_server_pixeltable_developer-0.2.1.dist-info/RECORD +21 -0
- mcp_server_pixeltable_developer-0.2.1.dist-info/WHEEL +4 -0
- mcp_server_pixeltable_developer-0.2.1.dist-info/entry_points.txt +2 -0
- mcp_server_pixeltable_developer-0.2.1.dist-info/licenses/LICENSE +201 -0
- mcp_server_pixeltable_stio/__init__.py +14 -0
- mcp_server_pixeltable_stio/__main__.py +6 -0
- mcp_server_pixeltable_stio/server.py +5 -0
|
@@ -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"
|