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 +152 -0
- cli.py +156 -0
- config.py +53 -0
- engine.py +755 -0
- ephemeral_buffer_mcp-0.1.1.dist-info/METADATA +216 -0
- ephemeral_buffer_mcp-0.1.1.dist-info/RECORD +11 -0
- ephemeral_buffer_mcp-0.1.1.dist-info/WHEEL +5 -0
- ephemeral_buffer_mcp-0.1.1.dist-info/entry_points.txt +2 -0
- ephemeral_buffer_mcp-0.1.1.dist-info/licenses/LICENSE +201 -0
- ephemeral_buffer_mcp-0.1.1.dist-info/top_level.txt +5 -0
- server.py +433 -0
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
|