ephemeral-buffer-mcp 0.1.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.
capture_utils.py ADDED
@@ -0,0 +1,152 @@
1
+ """Utilities for bounded command and stream output capture."""
2
+
3
+ import os
4
+ import selectors
5
+ import signal
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Iterable, Optional, Tuple
10
+ from config import DEFAULT_MAX_OUTPUT_BYTES
11
+
12
+
13
+ class _BoundedCapture:
14
+ """Incrementally retain bounded head/tail bytes from a binary stream."""
15
+
16
+ def __init__(self, max_output_bytes: int):
17
+ if max_output_bytes < 512:
18
+ raise ValueError("max_output_bytes must be at least 512")
19
+ payload_limit = max_output_bytes - 256
20
+ self.max_output_bytes = max_output_bytes
21
+ self.head_limit = payload_limit // 2
22
+ self.tail_limit = payload_limit - self.head_limit
23
+ self.captured = bytearray()
24
+ self.head = bytearray()
25
+ self.tail = bytearray()
26
+ self.total_bytes = 0
27
+ self.truncated = False
28
+
29
+ def add(self, chunk: bytes) -> None:
30
+ if not chunk:
31
+ return
32
+ self.total_bytes += len(chunk)
33
+ if not self.truncated and len(self.captured) + len(chunk) <= self.head_limit + self.tail_limit:
34
+ self.captured.extend(chunk)
35
+ return
36
+ if not self.truncated:
37
+ combined = bytes(self.captured) + chunk
38
+ self.head.extend(combined[:self.head_limit])
39
+ self.tail.extend(combined[-self.tail_limit:])
40
+ self.truncated = True
41
+ return
42
+ self.tail.extend(chunk)
43
+ if len(self.tail) > self.tail_limit:
44
+ del self.tail[:-self.tail_limit]
45
+
46
+ def finish(self) -> Tuple[str, bool, int]:
47
+ if not self.truncated:
48
+ return self.captured.decode("utf-8", errors="replace"), False, self.total_bytes
49
+ marker = (
50
+ f"\n\n[output truncated: retained first {len(self.head):,} and last {len(self.tail):,} bytes "
51
+ f"of {self.total_bytes:,}]\n\n"
52
+ ).encode("utf-8")
53
+ output = bytes(self.head) + marker + bytes(self.tail)
54
+ if len(output) > self.max_output_bytes:
55
+ output = output[:self.max_output_bytes]
56
+ return output.decode("utf-8", errors="replace"), True, self.total_bytes
57
+
58
+
59
+ def bound_chunks(chunks: Iterable[bytes], max_output_bytes: int) -> Tuple[str, bool, int]:
60
+ """Retain bounded head/tail bytes from an arbitrary binary stream."""
61
+ capture = _BoundedCapture(max_output_bytes)
62
+ for chunk in chunks:
63
+ capture.add(chunk)
64
+ return capture.finish()
65
+
66
+
67
+ def run_command_bounded(
68
+ command: str,
69
+ cwd: Optional[str],
70
+ max_output_bytes: int,
71
+ timeout_seconds: Optional[float] = None,
72
+ ) -> Tuple[str, int, bool, int, bool]:
73
+ """Run a command while retaining bounded output and enforcing an optional timeout."""
74
+ return _run_command_bounded(command, cwd, max_output_bytes, timeout_seconds)
75
+
76
+
77
+ def _run_command_bounded(
78
+ command: str,
79
+ cwd: Optional[str],
80
+ max_output_bytes: int,
81
+ timeout_seconds: Optional[float],
82
+ ) -> Tuple[str, int, bool, int, bool]:
83
+ if timeout_seconds is not None and timeout_seconds <= 0:
84
+ raise ValueError("timeout_seconds must be greater than 0")
85
+ capture = _BoundedCapture(max_output_bytes)
86
+ proc = subprocess.Popen(
87
+ command,
88
+ shell=True,
89
+ cwd=cwd,
90
+ stdout=subprocess.PIPE,
91
+ stderr=subprocess.STDOUT,
92
+ start_new_session=True,
93
+ )
94
+ selector = selectors.DefaultSelector()
95
+ selector.register(proc.stdout, selectors.EVENT_READ)
96
+ deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
97
+ timed_out = False
98
+ try:
99
+ while selector.get_map():
100
+ remaining = None if deadline is None else deadline - time.monotonic()
101
+ if remaining is not None and remaining <= 0:
102
+ timed_out = True
103
+ break
104
+ events = selector.select(remaining)
105
+ if not events:
106
+ timed_out = True
107
+ break
108
+ for key, _ in events:
109
+ chunk = key.fileobj.read1(65536)
110
+ if chunk:
111
+ capture.add(chunk)
112
+ else:
113
+ selector.unregister(key.fileobj)
114
+ finally:
115
+ if timed_out:
116
+ _terminate_process_group(proc)
117
+ try:
118
+ proc.wait(timeout=1)
119
+ except subprocess.TimeoutExpired:
120
+ proc.kill()
121
+ proc.wait()
122
+ selector.close()
123
+ if proc.stdout is not None:
124
+ proc.stdout.close()
125
+ output, truncated, total_bytes = capture.finish()
126
+ return output, (124 if timed_out else proc.returncode), truncated, total_bytes, timed_out
127
+
128
+
129
+ def _terminate_process_group(proc: subprocess.Popen) -> None:
130
+ """Terminate a shell command and all children started in its process group."""
131
+ try:
132
+ os.killpg(proc.pid, signal.SIGTERM)
133
+ except (ProcessLookupError, OSError):
134
+ proc.terminate()
135
+ try:
136
+ proc.wait(timeout=1)
137
+ except subprocess.TimeoutExpired:
138
+ try:
139
+ os.killpg(proc.pid, signal.SIGKILL)
140
+ except (ProcessLookupError, OSError):
141
+ proc.kill()
142
+
143
+
144
+ def read_file_bounded(file_path: str, max_bytes: int) -> str:
145
+ """Read a UTF-8 file only when it fits within the configured byte limit."""
146
+ if max_bytes < 1:
147
+ raise ValueError("max_bytes must be at least 1")
148
+ with Path(file_path).open("rb") as stream:
149
+ content = stream.read(max_bytes + 1)
150
+ if len(content) > max_bytes:
151
+ raise ValueError(f"File exceeds the {max_bytes:,}-byte capture limit")
152
+ return content.decode("utf-8", errors="replace")
cli.py ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI Helper tool for piping command output into Ephemeral Buffer MCP Server.
4
+ Usage:
5
+ # Pipe stdout/stderr directly:
6
+ pytest -v 2>&1 | ephbuf --label "pytest run"
7
+
8
+ # Force unified-diff parsing when automatic detection is ambiguous:
9
+ git diff HEAD~3 | ephbuf --label "feature diff" --type diff
10
+
11
+ # Or wrap a command execution:
12
+ ephbuf --label "build" -- make all
13
+
14
+ --type accepts: auto (default), diff, log, or text.
15
+ """
16
+
17
+ import sys
18
+ import os
19
+ import socket
20
+ import json
21
+ import argparse
22
+ from capture_utils import DEFAULT_MAX_OUTPUT_BYTES, bound_chunks, run_command_bounded
23
+ from config import positive_int_env, socket_path
24
+
25
+ SOCKET_PATH = socket_path()
26
+
27
+
28
+ def send_to_mcp(
29
+ text: str,
30
+ label: str = "",
31
+ content_type: str = "auto",
32
+ truncated: bool = False,
33
+ original_byte_size: int = 0,
34
+ command_exit_code: int | None = None,
35
+ timed_out: bool = False,
36
+ ) -> dict:
37
+ if not os.path.exists(SOCKET_PATH):
38
+ return {
39
+ "status": "error",
40
+ "message": f"MCP server socket not found at {SOCKET_PATH}. Is the ephemeral-buffer MCP server running?"
41
+ }
42
+
43
+ try:
44
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
45
+ sock.connect(SOCKET_PATH)
46
+
47
+ payload = json.dumps({
48
+ "label": label,
49
+ "text": text,
50
+ "content_type": content_type,
51
+ "truncated": truncated,
52
+ "original_byte_size": original_byte_size if truncated else None,
53
+ "command_exit_code": command_exit_code,
54
+ "timed_out": timed_out,
55
+ }).encode("utf-8")
56
+ sock.sendall(payload)
57
+ sock.shutdown(socket.SHUT_WR)
58
+
59
+ resp_data = b""
60
+ while True:
61
+ chunk = sock.recv(4096)
62
+ if not chunk:
63
+ break
64
+ resp_data += chunk
65
+
66
+ sock.close()
67
+ return json.loads(resp_data.decode("utf-8"))
68
+ except Exception as e:
69
+ return {"status": "error", "message": f"Failed to communicate with MCP server: {e}"}
70
+
71
+
72
+ def main():
73
+ parser = argparse.ArgumentParser(
74
+ description="Pipe output into Ephemeral Buffer MCP server for hybrid search and agent analysis."
75
+ )
76
+ parser.add_argument("--label", "-l", default="", help="Optional descriptive label for this capture")
77
+ parser.add_argument("--type", "-t", choices=["auto", "diff", "log", "text"], default="auto", help="Optional content type hint (default: auto)")
78
+ parser.add_argument(
79
+ "--max-output-bytes",
80
+ type=int,
81
+ default=positive_int_env("EPHEMERAL_MAX_BUFFER_BYTES", DEFAULT_MAX_OUTPUT_BYTES),
82
+ help="Maximum output retained (default: EPHEMERAL_MAX_BUFFER_BYTES or 50 MiB)",
83
+ )
84
+ parser.add_argument(
85
+ "--timeout-seconds",
86
+ type=float,
87
+ default=None,
88
+ help="Maximum runtime for a wrapped command; timed-out commands exit with status 124",
89
+ )
90
+ parser.add_argument("command", nargs=argparse.REMAINDER, help="Optional command to execute and capture")
91
+
92
+ args = parser.parse_args()
93
+
94
+ # If a command was passed after '--'
95
+ if args.command:
96
+ cmd_list = args.command
97
+ if cmd_list and cmd_list[0] == "--":
98
+ cmd_list = cmd_list[1:]
99
+
100
+ cmd_str = " ".join(cmd_list)
101
+ label = args.label or cmd_str
102
+ print(f"[ephbuf] Executing: {cmd_str}")
103
+
104
+ try:
105
+ output, exit_code, truncated, original_byte_size, timed_out = run_command_bounded(
106
+ cmd_str, None, args.max_output_bytes, args.timeout_seconds
107
+ )
108
+ except ValueError as e:
109
+ parser.error(str(e))
110
+ # Also print output locally so user can see it if desired
111
+ sys.stdout.write(output)
112
+ sys.stdout.flush()
113
+
114
+ res = send_to_mcp(
115
+ output,
116
+ label=label,
117
+ content_type=args.type,
118
+ truncated=truncated,
119
+ original_byte_size=original_byte_size,
120
+ command_exit_code=exit_code,
121
+ timed_out=timed_out,
122
+ )
123
+ if res.get("status") == "ok":
124
+ print(f"\n[ephbuf] Successfully captured {res['line_count']:,} lines into buffer `{res['capture_id']}` ({res['label']})", file=sys.stderr)
125
+ else:
126
+ print(f"\n[ephbuf] Warning: {res.get('message')}", file=sys.stderr)
127
+ if timed_out:
128
+ print(f"\n[ephbuf] Command timed out after {args.timeout_seconds:g}s", file=sys.stderr)
129
+ sys.exit(exit_code)
130
+
131
+ # Otherwise read from stdin (piped input)
132
+ if not sys.stdin.isatty():
133
+ input_stream = getattr(sys.stdin, "buffer", sys.stdin)
134
+ if input_stream is sys.stdin:
135
+ input_chunks = (chunk.encode("utf-8") for chunk in iter(lambda: sys.stdin.read(65536), ""))
136
+ else:
137
+ input_chunks = iter(lambda: input_stream.read(65536), b"")
138
+ input_text, truncated, original_byte_size = bound_chunks(input_chunks, args.max_output_bytes)
139
+ label = args.label or "Piped STDIN"
140
+ res = send_to_mcp(
141
+ input_text,
142
+ label=label,
143
+ content_type=args.type,
144
+ truncated=truncated,
145
+ original_byte_size=original_byte_size,
146
+ )
147
+ if res.get("status") == "ok":
148
+ print(f"[ephbuf] Successfully captured {res['line_count']:,} lines into buffer `{res['capture_id']}` ({res['label']})", file=sys.stderr)
149
+ else:
150
+ print(f"[ephbuf] Warning: {res.get('message')}", file=sys.stderr)
151
+ else:
152
+ parser.print_help()
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
config.py ADDED
@@ -0,0 +1,53 @@
1
+ """Environment-backed server configuration helpers."""
2
+
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import hashlib
7
+
8
+
9
+ DEFAULT_MAX_CAPTURES = 25
10
+ DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024
11
+ DEFAULT_MAX_OUTPUT_BYTES = DEFAULT_MAX_BUFFER_BYTES
12
+ DEFAULT_EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5"
13
+ DEFAULT_SOCKET_PATH = os.path.join(tempfile.gettempdir(), "ephemeral_buffer.sock")
14
+ SESSION_SOCKET_PREFIX = "ephemeral_buffer-"
15
+
16
+
17
+ def socket_path() -> str:
18
+ """Return the shared server/CLI socket path for the current session."""
19
+ configured_path = os.environ.get("EPHEMERAL_SOCKET_PATH")
20
+ if configured_path:
21
+ return configured_path
22
+
23
+ session_id = os.environ.get("EPHEMERAL_SESSION_ID")
24
+ if session_id:
25
+ digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16]
26
+ return os.path.join(tempfile.gettempdir(), f"{SESSION_SOCKET_PREFIX}{digest}.sock")
27
+
28
+ return DEFAULT_SOCKET_PATH
29
+
30
+
31
+ def embedding_model_name() -> str:
32
+ """Return the configured FastEmbed model name."""
33
+ return os.environ.get("EPHEMERAL_EMBEDDING_MODEL", DEFAULT_EMBEDDING_MODEL)
34
+
35
+
36
+ def embedding_cache_dir() -> str | None:
37
+ """Return the optional FastEmbed model cache directory."""
38
+ return os.environ.get("EPHEMERAL_FASTEMBED_CACHE_DIR") or None
39
+
40
+
41
+ def positive_int_env(name: str, default: int) -> int:
42
+ """Return a positive integer environment setting or its safe default."""
43
+ value = os.environ.get(name)
44
+ if value is None:
45
+ return default
46
+ try:
47
+ parsed = int(value)
48
+ if parsed < 1:
49
+ raise ValueError
50
+ return parsed
51
+ except ValueError:
52
+ print(f"Ignoring invalid {name}={value!r}; using {default}", file=sys.stderr)
53
+ return default