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,324 @@
1
+ """Process configuration, path validation, and subprocess execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ import anyio
18
+ from anyio.abc import ByteReceiveStream, ByteSendStream, Process
19
+ from mcp.server.mcpserver.exceptions import ToolError
20
+ from pydantic import JsonValue
21
+
22
+ Transport = Literal["stdio", "streamable-http"]
23
+
24
+ _CATALOG_COMPONENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
25
+ _HOSTED_TARGET = re.compile(r"^pxt://[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+(?:/[A-Za-z_][A-Za-z0-9_]*)*$")
26
+ _SECRET_ASSIGNMENT = re.compile(r"(?i)(api[_-]?key|password|secret|token)(\s*[=:]\s*)([^\s,;]+|\"[^\"]*\"|'[^']*')")
27
+ _URL_PASSWORD = re.compile(r"(://[^:/\s]+:)[^@/\s]+(@)")
28
+ _OPENAI_TOKEN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
29
+
30
+
31
+ def find_pxt_executable() -> str:
32
+ """Prefer the Pixeltable CLI installed beside the running server."""
33
+
34
+ environment_pxt = os.environ.get("PIXELTABLE_MCP_PXT")
35
+ if environment_pxt:
36
+ return environment_pxt
37
+ sibling_pxt = Path(sys.executable).with_name("pxt")
38
+ if sibling_pxt.is_file():
39
+ return str(sibling_pxt)
40
+ return shutil.which("pxt") or "pxt"
41
+
42
+
43
+ def redact_secrets(text: str) -> str:
44
+ """Remove common secret forms from diagnostic text."""
45
+
46
+ text = _SECRET_ASSIGNMENT.sub(r"\1\2***", text)
47
+ text = _URL_PASSWORD.sub(r"\1***\2", text)
48
+ return _OPENAI_TOKEN.sub("sk-***", text)
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class ServerConfig:
53
+ """Immutable configuration fixed when the MCP process starts."""
54
+
55
+ project_root: Path
56
+ pixeltable_home: Path
57
+ transport: Transport = "stdio"
58
+ unsafe_enabled: bool = False
59
+ pxt_executable: str = "pxt"
60
+ command_timeout_seconds: float = 120.0
61
+ max_output_bytes: int = 1_000_000
62
+
63
+ def __post_init__(self) -> None:
64
+ project_root = self.project_root.expanduser().resolve()
65
+ if not project_root.is_dir():
66
+ raise ValueError(f"Project root is not a directory: {project_root}")
67
+ pixeltable_home = self.pixeltable_home.expanduser().resolve()
68
+ if self.transport != "stdio" and self.unsafe_enabled:
69
+ raise ValueError("Unsafe tools can only be enabled over stdio")
70
+ if self.command_timeout_seconds <= 0:
71
+ raise ValueError("command_timeout_seconds must be positive")
72
+ if self.max_output_bytes <= 0:
73
+ raise ValueError("max_output_bytes must be positive")
74
+ object.__setattr__(self, "project_root", project_root)
75
+ object.__setattr__(self, "pixeltable_home", pixeltable_home)
76
+
77
+ @classmethod
78
+ def from_env(cls, *, transport: Transport = "stdio") -> ServerConfig:
79
+ """Load startup-only settings from the environment."""
80
+
81
+ project_root = Path(os.environ.get("PIXELTABLE_MCP_PROJECT_ROOT", os.getcwd()))
82
+ pixeltable_home = Path(os.environ.get("PIXELTABLE_HOME", "~/.pixeltable"))
83
+ unsafe_enabled = os.environ.get("PIXELTABLE_MCP_ENABLE_UNSAFE") == "1"
84
+ pxt_executable = find_pxt_executable()
85
+ return cls(
86
+ project_root=project_root,
87
+ pixeltable_home=pixeltable_home,
88
+ transport=transport,
89
+ unsafe_enabled=unsafe_enabled,
90
+ pxt_executable=pxt_executable,
91
+ )
92
+
93
+ def apply_environment(self) -> None:
94
+ """Pin Pixeltable to this process's catalog before it is imported."""
95
+
96
+ os.environ["PIXELTABLE_HOME"] = str(self.pixeltable_home)
97
+ os.environ.setdefault("PIXELTABLE_DISABLE_STDOUT", "1")
98
+
99
+ def command_environment(self) -> dict[str, str]:
100
+ env = os.environ.copy()
101
+ env["PIXELTABLE_HOME"] = str(self.pixeltable_home)
102
+ env.setdefault("PIXELTABLE_DISABLE_STDOUT", "1")
103
+ return env
104
+
105
+ def resolve_project_file(self, value: str, *, must_exist: bool = True) -> Path:
106
+ """Resolve an application path and reject project-root escapes."""
107
+
108
+ if not value.strip() or "\x00" in value:
109
+ raise ToolError("File path must be a non-empty project path")
110
+ raw = Path(value).expanduser()
111
+ candidate = (raw if raw.is_absolute() else self.project_root / raw).resolve(strict=False)
112
+ try:
113
+ candidate.relative_to(self.project_root)
114
+ except ValueError as exc:
115
+ raise ToolError("File path must stay inside the configured project root") from exc
116
+ if must_exist and not candidate.is_file():
117
+ raise ToolError(f"Project file does not exist: {value}")
118
+ return candidate
119
+
120
+ def resolve_scaffold_output(self, value: str) -> Path:
121
+ """Validate a new project-relative scaffold path."""
122
+
123
+ raw = Path(value)
124
+ if raw.is_absolute():
125
+ raise ToolError("Scaffold output must be relative to the project root")
126
+ candidate = self.resolve_project_file(value, must_exist=False)
127
+ if candidate.exists():
128
+ raise ToolError(f"Refusing to overwrite existing path: {value}")
129
+ if not candidate.parent.is_dir():
130
+ raise ToolError(f"Scaffold output directory does not exist: {candidate.parent}")
131
+ return candidate
132
+
133
+ @staticmethod
134
+ def validate_catalog_path(value: str, *, allow_empty: bool = False) -> str:
135
+ """Validate a local Pixeltable catalog path."""
136
+
137
+ value = value.strip().strip("/")
138
+ if not value and allow_empty:
139
+ return ""
140
+ if not value or any(not _CATALOG_COMPONENT.fullmatch(part) for part in value.split("/")):
141
+ raise ToolError(f"Invalid Pixeltable catalog path: {value!r}")
142
+ return value
143
+
144
+ @staticmethod
145
+ def validate_target(value: str) -> str:
146
+ """Validate a local catalog path or an explicit hosted database URI."""
147
+
148
+ value = value.strip().rstrip("/")
149
+ if value.startswith("pxt://"):
150
+ if not _HOSTED_TARGET.fullmatch(value):
151
+ raise ToolError(f"Invalid hosted Pixeltable target: {value!r}")
152
+ return value
153
+ return ServerConfig.validate_catalog_path(value)
154
+
155
+ @staticmethod
156
+ def validate_identifier(value: str, *, label: str = "identifier") -> str:
157
+ value = value.strip()
158
+ if not _CATALOG_COMPONENT.fullmatch(value):
159
+ raise ToolError(f"Invalid {label}: {value!r}")
160
+ return value
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class ProcessResult:
165
+ exit_code: int
166
+ stdout: str
167
+ stderr: str
168
+ data: JsonValue
169
+
170
+ @property
171
+ def pending(self) -> bool:
172
+ return self.exit_code == 2
173
+
174
+
175
+ class CommandRunner:
176
+ """Run fixed argument arrays with captured, redacted output."""
177
+
178
+ def __init__(self, config: ServerConfig):
179
+ self.config = config
180
+
181
+ async def pxt(
182
+ self,
183
+ arguments: Sequence[str],
184
+ *,
185
+ allowed_exit_codes: frozenset[int] = frozenset({0}),
186
+ parse_json: bool = True,
187
+ ) -> ProcessResult:
188
+ return await self.run(
189
+ [self.config.pxt_executable, *arguments],
190
+ allowed_exit_codes=allowed_exit_codes,
191
+ parse_json=parse_json,
192
+ )
193
+
194
+ async def worker(self, operation: str, payload: object) -> ProcessResult:
195
+ worker_path = Path(__file__).with_name("_worker.py")
196
+ return await self.run(
197
+ [sys.executable, str(worker_path), operation],
198
+ input_bytes=json.dumps(payload).encode(),
199
+ allowed_exit_codes=frozenset({0}),
200
+ parse_json=True,
201
+ )
202
+
203
+ async def run(
204
+ self,
205
+ arguments: Sequence[str],
206
+ *,
207
+ input_bytes: bytes | None = None,
208
+ allowed_exit_codes: frozenset[int],
209
+ parse_json: bool,
210
+ ) -> ProcessResult:
211
+ """Execute a known command without a shell and normalize its result."""
212
+
213
+ process: Process | None = None
214
+ stdout_buffer = bytearray()
215
+ stderr_buffer = bytearray()
216
+ output_limit_exceeded = False
217
+
218
+ def terminate_process() -> None:
219
+ if process is None or process.returncode is not None:
220
+ return
221
+ try:
222
+ if os.name == "posix":
223
+ os.killpg(process.pid, signal.SIGKILL)
224
+ else:
225
+ process.kill()
226
+ except OSError:
227
+ pass
228
+
229
+ async def drain_stream(stream: ByteReceiveStream, destination: bytearray) -> None:
230
+ nonlocal output_limit_exceeded
231
+ while True:
232
+ try:
233
+ chunk = await stream.receive()
234
+ except anyio.EndOfStream:
235
+ return
236
+ remaining = self.config.max_output_bytes + 1 - len(destination)
237
+ if remaining > 0:
238
+ destination.extend(chunk[:remaining])
239
+ if len(destination) > self.config.max_output_bytes:
240
+ output_limit_exceeded = True
241
+ terminate_process()
242
+ return
243
+
244
+ async def send_input(stream: ByteSendStream, value: bytes) -> None:
245
+ try:
246
+ await stream.send(value)
247
+ except (anyio.BrokenResourceError, anyio.ClosedResourceError):
248
+ if not output_limit_exceeded:
249
+ raise
250
+ finally:
251
+ await stream.aclose()
252
+
253
+ try:
254
+ started_process = await anyio.open_process(
255
+ list(arguments),
256
+ stdin=subprocess.PIPE if input_bytes is not None else subprocess.DEVNULL,
257
+ stdout=subprocess.PIPE,
258
+ stderr=subprocess.PIPE,
259
+ cwd=self.config.project_root,
260
+ env=self.config.command_environment(),
261
+ start_new_session=os.name == "posix",
262
+ )
263
+ process = started_process
264
+ with anyio.fail_after(self.config.command_timeout_seconds):
265
+ async with anyio.create_task_group() as task_group:
266
+ if started_process.stdout is not None:
267
+ task_group.start_soon(drain_stream, started_process.stdout, stdout_buffer)
268
+ if started_process.stderr is not None:
269
+ task_group.start_soon(drain_stream, started_process.stderr, stderr_buffer)
270
+ if started_process.stdin is not None and input_bytes is not None:
271
+ task_group.start_soon(send_input, started_process.stdin, input_bytes)
272
+ await started_process.wait()
273
+ except TimeoutError as exc:
274
+ raise ToolError(
275
+ f"Pixeltable command timed out after {self.config.command_timeout_seconds:g} seconds"
276
+ ) from exc
277
+ except FileNotFoundError as exc:
278
+ raise ToolError(f"Pixeltable executable was not found: {arguments[0]}") from exc
279
+ except OSError as exc:
280
+ raise ToolError(f"Could not start Pixeltable command: {exc}") from exc
281
+ finally:
282
+ if process is not None:
283
+ terminate_process()
284
+ with anyio.CancelScope(shield=True):
285
+ with anyio.move_on_after(5):
286
+ await process.wait()
287
+ await process.aclose()
288
+
289
+ if output_limit_exceeded:
290
+ raise ToolError("Pixeltable command output exceeded the configured limit")
291
+ assert process is not None and process.returncode is not None
292
+ stdout_bytes = bytes(stdout_buffer)
293
+ stderr_bytes = bytes(stderr_buffer)
294
+ stdout = redact_secrets(stdout_bytes.decode(errors="replace").strip())
295
+ stderr = redact_secrets(stderr_bytes.decode(errors="replace").strip())
296
+ if process.returncode not in allowed_exit_codes:
297
+ detail = stderr or stdout or f"exit code {process.returncode}"
298
+ raise ToolError(f"Pixeltable command failed: {detail}")
299
+
300
+ data: JsonValue = None
301
+ if parse_json:
302
+ data = self._parse_json(stdout)
303
+ elif stdout:
304
+ data = stdout
305
+ return ProcessResult(exit_code=process.returncode, stdout=stdout, stderr=stderr, data=data)
306
+
307
+ @staticmethod
308
+ def _parse_json(stdout: str) -> JsonValue:
309
+ if not stdout:
310
+ return None
311
+ try:
312
+ return json.loads(stdout)
313
+ except json.JSONDecodeError:
314
+ decoder = json.JSONDecoder()
315
+ for index, character in enumerate(stdout):
316
+ if character not in "[{":
317
+ continue
318
+ try:
319
+ value, end = decoder.raw_decode(stdout[index:])
320
+ except json.JSONDecodeError:
321
+ continue
322
+ if not stdout[index + end :].strip():
323
+ return value
324
+ raise ToolError("Pixeltable command returned invalid JSON")
@@ -0,0 +1,125 @@
1
+ """MCP 2.2 server construction for Pixeltable developer workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ import os
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+ from dataclasses import dataclass, replace
11
+ from typing import Any
12
+
13
+ from mcp.server.caching import CacheHint
14
+ from mcp.server.mcpserver import MCPServer
15
+
16
+ from . import __version__
17
+ from .prompts import register_prompts
18
+ from .resources import register_resources
19
+ from .runtime import CommandRunner, ServerConfig
20
+ from .tools import register_default_tools
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ SERVER_INSTRUCTIONS = """Pixeltable developer server for source-controlled multimodal applications.
25
+ Read pixeltable://guidance/app before scaffolding or changing an application. Prefer read-only describe,
26
+ check, and diff calls before updates. Schema and service updates may affect hosted targets; prune tools
27
+ default to dry runs. Keep tables, computed columns, and HTTP routes in the application file. Unsafe local
28
+ Python, package-install, and display tools are absent unless PIXELTABLE_MCP_ENABLE_UNSAFE=1 over stdio."""
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class RuntimeState:
33
+ """Dependencies fixed for one MCP server lifetime."""
34
+
35
+ config: ServerConfig
36
+ runner: CommandRunner
37
+
38
+
39
+ def create_server(config: ServerConfig | None = None) -> MCPServer[RuntimeState]:
40
+ """Build a fully registered MCP server without starting a transport."""
41
+
42
+ config = config or ServerConfig.from_env(transport="stdio")
43
+ if config.unsafe_enabled and os.environ.get("PIXELTABLE_MCP_ENABLE_UNSAFE") != "1":
44
+ config = replace(config, unsafe_enabled=False)
45
+ config.apply_environment()
46
+ runner = CommandRunner(config)
47
+ state = RuntimeState(config=config, runner=runner)
48
+ unsafe_runtime: list[Any] = []
49
+
50
+ @asynccontextmanager
51
+ async def lifespan(_: MCPServer[RuntimeState]) -> AsyncIterator[RuntimeState]:
52
+ config.apply_environment()
53
+ try:
54
+ yield state
55
+ finally:
56
+ for runtime in reversed(unsafe_runtime):
57
+ close = getattr(runtime, "close", None)
58
+ if close is None:
59
+ continue
60
+ result = close()
61
+ if inspect.isawaitable(result):
62
+ await result
63
+
64
+ server: MCPServer[RuntimeState] = MCPServer(
65
+ "pixeltable-developer",
66
+ title="Pixeltable Developer",
67
+ description="Local developer tools for Pixeltable application and service workflows.",
68
+ instructions=SERVER_INSTRUCTIONS,
69
+ website_url="https://github.com/pixeltable/mcp-server-pixeltable-developer",
70
+ version=__version__,
71
+ lifespan=lifespan,
72
+ cache_hints={
73
+ "server/discover": CacheHint(ttl_ms=300_000, scope="private"),
74
+ "tools/list": CacheHint(ttl_ms=300_000, scope="private"),
75
+ "resources/list": CacheHint(ttl_ms=300_000, scope="private"),
76
+ "prompts/list": CacheHint(ttl_ms=300_000, scope="private"),
77
+ "resources/read": CacheHint(ttl_ms=0, scope="private"),
78
+ },
79
+ )
80
+ register_default_tools(server, config, runner)
81
+ register_resources(server, config, runner)
82
+ register_prompts(server)
83
+
84
+ if config.unsafe_enabled:
85
+ from .unsafe import register_unsafe_tools
86
+
87
+ unsafe_runtime.append(register_unsafe_tools(server, config))
88
+
89
+ return server
90
+
91
+
92
+ def create_conformance_app(config: ServerConfig | None = None) -> Any:
93
+ """Create a stateless Streamable HTTP app for local conformance tests only."""
94
+
95
+ if config is None:
96
+ env_config = ServerConfig.from_env(transport="streamable-http")
97
+ config = ServerConfig(
98
+ project_root=env_config.project_root,
99
+ pixeltable_home=env_config.pixeltable_home,
100
+ transport="streamable-http",
101
+ unsafe_enabled=False,
102
+ pxt_executable=env_config.pxt_executable,
103
+ command_timeout_seconds=env_config.command_timeout_seconds,
104
+ max_output_bytes=env_config.max_output_bytes,
105
+ )
106
+ elif config.transport != "streamable-http":
107
+ raise ValueError("Conformance app requires transport='streamable-http'")
108
+ server = create_server(config)
109
+ from .conformance import register_conformance_diagnostics
110
+
111
+ register_conformance_diagnostics(server)
112
+ return server.streamable_http_app(stateless_http=True, json_response=True, host="127.0.0.1")
113
+
114
+
115
+ mcp = create_server()
116
+
117
+
118
+ def main() -> None:
119
+ """Run the production stdio server and propagate startup failures."""
120
+
121
+ logger.info("Starting Pixeltable Developer MCP %s", __version__)
122
+ mcp.run("stdio")
123
+
124
+
125
+ __all__ = ["RuntimeState", "create_conformance_app", "create_server", "main", "mcp"]