grpchook 0.0.2__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.
- examples/__init__.py +0 -0
- examples/interactive_streaming/GrpcServerExample.py +16 -0
- examples/interactive_streaming/LMProxyClient.py +100 -0
- examples/interactive_streaming/TextClient.py +102 -0
- examples/interactive_streaming/__init__.py +0 -0
- examples/interactive_streaming/_lm_http.py +144 -0
- examples/interactive_streaming/readme.md +48 -0
- examples/interactive_streaming/run_server_proxy.py +26 -0
- examples/interactive_streaming/run_text_client.py +14 -0
- examples/mcp_server/FileOperationClient.py +200 -0
- examples/mcp_server/GrpcServer.py +22 -0
- examples/mcp_server/LlmBridgeClient.py +359 -0
- examples/mcp_server/RunnerClient.py +218 -0
- examples/mcp_server/__init__.py +0 -0
- examples/mcp_server/_llm_utils.py +92 -0
- examples/mcp_server/_task.py +131 -0
- examples/mcp_server/readme.md +84 -0
- examples/mcp_server/run_example.py +96 -0
- examples/readme.md +3 -0
- examples/watchdog/__init__.py +0 -0
- examples/watchdog/readme.md +42 -0
- examples/watchdog/server_watchdog.py +160 -0
- examples/watchdog/watchdog_ui.py +1016 -0
- grpchook/__init__.py +1 -0
- grpchook/__main__.py +675 -0
- grpchook/assets/HOW_TO.md +296 -0
- grpchook/baseclient.py +615 -0
- grpchook/baseserver.py +418 -0
- grpchook/custom_interface.py +200 -0
- grpchook/data_register.py +204 -0
- grpchook/exceptions.py +25 -0
- grpchook/logger.py +164 -0
- grpchook/message.proto +60 -0
- grpchook/message_pb2.py +50 -0
- grpchook/message_pb2.pyi +75 -0
- grpchook/message_pb2_grpc.py +101 -0
- grpchook/schema_version.py +28 -0
- grpchook/timer.py +302 -0
- grpchook/tools.py +208 -0
- grpchook-0.0.2.dist-info/METADATA +275 -0
- grpchook-0.0.2.dist-info/RECORD +44 -0
- grpchook-0.0.2.dist-info/WHEEL +4 -0
- grpchook-0.0.2.dist-info/entry_points.txt +4 -0
- grpchook-0.0.2.dist-info/licenses/LICENSE.txt +28 -0
examples/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Minimal gRPC server for the interactive streaming example."""
|
|
2
|
+
from grpchook.baseserver import BaseServer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GrpcServer(BaseServer):
|
|
6
|
+
"""
|
|
7
|
+
Minimal server for interactive streaming example.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, port: int = 49999):
|
|
11
|
+
super().__init__(port)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
s = GrpcServer(49999)
|
|
16
|
+
s.serve_forever()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""LM Studio proxy client: receives lm_request, streams lm_response_stream chunks."""
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from grpchook import message_pb2
|
|
6
|
+
from grpchook.baseclient import BaseClient
|
|
7
|
+
from grpchook.tools import json_to_struct, struct_to_json
|
|
8
|
+
from examples.interactive_streaming import _lm_http
|
|
9
|
+
from examples.interactive_streaming._lm_http import (
|
|
10
|
+
_iter_stream, _fetch_sync, _offline_stream, make_http_session,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import requests
|
|
16
|
+
except ImportError:
|
|
17
|
+
requests = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LMProxyClient(BaseClient):
|
|
21
|
+
"""Proxy: receives lm_request, queries LM Studio, streams lm_response_stream chunks."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, name: str, port: int, lmstudio_base: str | None = None,
|
|
24
|
+
model: str = "gemma-4e2b"):
|
|
25
|
+
super().__init__(port, name=name, provides=["lm_response_stream"], requires=["lm_request"])
|
|
26
|
+
self.lmstudio_base = lmstudio_base or "http://127.0.0.1:1234/v1"
|
|
27
|
+
self.model = model
|
|
28
|
+
|
|
29
|
+
if requests:
|
|
30
|
+
sess = make_http_session()
|
|
31
|
+
if sess:
|
|
32
|
+
_lm_http._session = sess
|
|
33
|
+
self.logger.debug("persistent HTTP session created")
|
|
34
|
+
else:
|
|
35
|
+
self.logger.warning("requests not installed — HTTP calls will fail")
|
|
36
|
+
|
|
37
|
+
threading.Thread(target=self.spin_forever, daemon=True).start()
|
|
38
|
+
|
|
39
|
+
def _send_chunk(self, request: message_pb2.Message, text: str,
|
|
40
|
+
done: bool = False):
|
|
41
|
+
msg = message_pb2.Message(
|
|
42
|
+
metaInfo=message_pb2.MetaInformation(messageName="lm_response_stream"),
|
|
43
|
+
payload=message_pb2.Payload(
|
|
44
|
+
structPayload=json_to_struct({"chunk": text, "done": done})
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
msg.metaInfo.messageId = request.metaInfo.messageId
|
|
48
|
+
self.send_data(msg)
|
|
49
|
+
|
|
50
|
+
def _handle_request(self, request: message_pb2.Message):
|
|
51
|
+
"""Forward an lm_request to LM Studio and stream back the response."""
|
|
52
|
+
try:
|
|
53
|
+
prompt = struct_to_json(request.payload.structPayload).get("text", "")
|
|
54
|
+
except (ValueError, TypeError, AttributeError):
|
|
55
|
+
prompt = ""
|
|
56
|
+
|
|
57
|
+
if not prompt:
|
|
58
|
+
self._send_chunk(request, "", done=True)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
mid = request.metaInfo.messageId
|
|
62
|
+
self.logger.info("Forwarding to LM Studio %s messageId=%s", self.lmstudio_base, mid)
|
|
63
|
+
|
|
64
|
+
had_chunks = False
|
|
65
|
+
try:
|
|
66
|
+
for chunk in _iter_stream(prompt, self.lmstudio_base, self.model):
|
|
67
|
+
if not self.run_event.is_set():
|
|
68
|
+
self.logger.debug("proxy shutting down, aborting stream messageId=%s", mid)
|
|
69
|
+
return
|
|
70
|
+
self._send_chunk(request, chunk)
|
|
71
|
+
had_chunks = True
|
|
72
|
+
except OSError:
|
|
73
|
+
self.logger.exception("stream failed messageId=%s", mid)
|
|
74
|
+
|
|
75
|
+
if not had_chunks:
|
|
76
|
+
try:
|
|
77
|
+
self._send_chunk(request, _fetch_sync(prompt, self.lmstudio_base, self.model))
|
|
78
|
+
had_chunks = True
|
|
79
|
+
except OSError:
|
|
80
|
+
self.logger.exception("sync fetch failed messageId=%s", mid)
|
|
81
|
+
|
|
82
|
+
if not had_chunks:
|
|
83
|
+
self.logger.warning("LM Studio unreachable, using offline stream messageId=%s", mid)
|
|
84
|
+
for chunk in _offline_stream(prompt):
|
|
85
|
+
self._send_chunk(request, chunk)
|
|
86
|
+
|
|
87
|
+
self._send_chunk(request, "", done=True)
|
|
88
|
+
|
|
89
|
+
def on_receive(self, data: message_pb2.Message) -> bool:
|
|
90
|
+
threading.Thread(target=self._handle_request, args=(data,), daemon=True).start()
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
proxy = LMProxyClient("lm-proxy", 49999)
|
|
96
|
+
try:
|
|
97
|
+
while True:
|
|
98
|
+
time.sleep(1)
|
|
99
|
+
except KeyboardInterrupt:
|
|
100
|
+
proxy.disconnect()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Interactive text client for the LM Studio streaming example."""
|
|
2
|
+
import uuid
|
|
3
|
+
import queue
|
|
4
|
+
|
|
5
|
+
from grpchook import message_pb2
|
|
6
|
+
from grpchook.baseclient import BaseClient
|
|
7
|
+
from grpchook.tools import json_to_struct, struct_to_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TextClient(BaseClient):
|
|
11
|
+
"""
|
|
12
|
+
Interactive text client. Sends `lm_request` and receives streaming
|
|
13
|
+
`lm_response_stream` messages (partial chunks with `done` flag).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, identifier: str, port: int):
|
|
17
|
+
super().__init__(port, name=identifier,
|
|
18
|
+
provides=["lm_request"],
|
|
19
|
+
requires=["lm_response_stream"])
|
|
20
|
+
self.logger.setLevel("WARNING")
|
|
21
|
+
|
|
22
|
+
def on_receive(self, data: message_pb2.Message) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
payload = (
|
|
25
|
+
struct_to_json(data.payload.structPayload)
|
|
26
|
+
if data.payload and data.payload.structPayload
|
|
27
|
+
else {}
|
|
28
|
+
)
|
|
29
|
+
except (ValueError, TypeError, AttributeError):
|
|
30
|
+
payload = {}
|
|
31
|
+
|
|
32
|
+
chunk = payload.get("chunk", "")
|
|
33
|
+
done = payload.get("done", False)
|
|
34
|
+
|
|
35
|
+
if chunk:
|
|
36
|
+
print(chunk, end="", flush=True)
|
|
37
|
+
if done:
|
|
38
|
+
print()
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
def interactive_loop(self):
|
|
42
|
+
"""
|
|
43
|
+
Prompt user, send request, stream responses until `done` True.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
while True:
|
|
47
|
+
text = input("You: ").strip()
|
|
48
|
+
if not text:
|
|
49
|
+
continue
|
|
50
|
+
if text.lower() in ("exit", "quit"):
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
msg_id = str(uuid.uuid4())
|
|
54
|
+
msg = message_pb2.Message(
|
|
55
|
+
metaInfo=message_pb2.MetaInformation(
|
|
56
|
+
messageId=msg_id,
|
|
57
|
+
messageName="lm_request",
|
|
58
|
+
),
|
|
59
|
+
payload=message_pb2.Payload(structPayload=json_to_struct({"text": text}))
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self.send_data(msg)
|
|
63
|
+
|
|
64
|
+
# collect streaming chunks for this request
|
|
65
|
+
while True:
|
|
66
|
+
try:
|
|
67
|
+
resp = self.get_data()
|
|
68
|
+
except queue.Empty:
|
|
69
|
+
print("\nTimeout waiting for response")
|
|
70
|
+
break
|
|
71
|
+
except (RuntimeError, OSError) as e:
|
|
72
|
+
print("\nClient stopped:", e)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if resp.metaInfo.messageId != msg_id:
|
|
76
|
+
# not for this request; ignore
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
payload = (
|
|
81
|
+
struct_to_json(resp.payload.structPayload)
|
|
82
|
+
if resp.payload and resp.payload.structPayload
|
|
83
|
+
else {}
|
|
84
|
+
)
|
|
85
|
+
except (ValueError, TypeError, AttributeError):
|
|
86
|
+
payload = {}
|
|
87
|
+
|
|
88
|
+
chunk = payload.get("chunk", "")
|
|
89
|
+
done = payload.get("done", False)
|
|
90
|
+
if chunk:
|
|
91
|
+
print(chunk, end="", flush=True)
|
|
92
|
+
if done:
|
|
93
|
+
print()
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
finally:
|
|
97
|
+
self.disconnect()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
client = TextClient("text-ui", 49999)
|
|
102
|
+
client.interactive_loop()
|
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""HTTP helpers for LMProxyClient: streaming/sync LM Studio calls."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import requests
|
|
11
|
+
from requests.adapters import HTTPAdapter as _HTTPAdapter
|
|
12
|
+
except ImportError:
|
|
13
|
+
requests = None
|
|
14
|
+
_HTTPAdapter = None
|
|
15
|
+
|
|
16
|
+
from grpchook.logger import get_logger
|
|
17
|
+
|
|
18
|
+
SYSTEM_PROMPT = os.environ.get(
|
|
19
|
+
"LMSTUDIO_SYSTEM_PROMPT",
|
|
20
|
+
"Use only printable UTF-8 characters. Reply in plain text without special symbols or emojis.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Set by LMProxyClient.__init__() to a persistent requests.Session.
|
|
24
|
+
_session = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sanitize_text(s: object) -> str:
|
|
28
|
+
if s is None:
|
|
29
|
+
return ""
|
|
30
|
+
if isinstance(s, bytes):
|
|
31
|
+
s = s.decode("utf-8", errors="ignore")
|
|
32
|
+
s = str(s)
|
|
33
|
+
s = s.replace("\ufffd", "")
|
|
34
|
+
s = re.sub(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]", "", s)
|
|
35
|
+
return s
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_messages(prompt: str) -> list:
|
|
39
|
+
return [
|
|
40
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
41
|
+
{"role": "user", "content": prompt},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _extract_text(j: dict) -> str | None:
|
|
46
|
+
"""Return the text content from a parsed SSE JSON object, or None."""
|
|
47
|
+
if "choices" in j and j["choices"]:
|
|
48
|
+
c = j["choices"][0]
|
|
49
|
+
return c.get("delta", {}).get("content") or c.get("text")
|
|
50
|
+
return j.get("text") or j.get("token")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _iter_stream(prompt: str, base_url: str, model: str):
|
|
54
|
+
"""Stream from OpenAI-compatible /chat/completions. Yields sanitized text chunks."""
|
|
55
|
+
api_key = os.environ.get("LMSTUDIO_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
56
|
+
url = base_url.rstrip("/") + "/chat/completions"
|
|
57
|
+
headers = {"Accept": "text/event-stream", "Content-Type": "application/json"}
|
|
58
|
+
if api_key:
|
|
59
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
60
|
+
payload = {"model": model, "messages": _build_messages(prompt), "stream": True}
|
|
61
|
+
log = get_logger("LMProxyClient", log_level=logging.DEBUG)
|
|
62
|
+
sess = _session or requests
|
|
63
|
+
log.debug("stream POST %s model=%s", url, model)
|
|
64
|
+
n_chunks = 0
|
|
65
|
+
with sess.post(url, json=payload, stream=True, headers=headers, timeout=(5, None)) as r:
|
|
66
|
+
log.debug("stream response status=%s", r.status_code)
|
|
67
|
+
if not 200 <= r.status_code < 300:
|
|
68
|
+
log.warning("stream got non-2xx status=%s", r.status_code)
|
|
69
|
+
return
|
|
70
|
+
for raw in r.iter_lines(decode_unicode=False):
|
|
71
|
+
if not raw:
|
|
72
|
+
continue
|
|
73
|
+
line = (
|
|
74
|
+
raw.decode("utf-8", errors="ignore").strip()
|
|
75
|
+
if isinstance(raw, bytes)
|
|
76
|
+
else str(raw).strip()
|
|
77
|
+
)
|
|
78
|
+
if line.startswith("data: "):
|
|
79
|
+
line = line[6:]
|
|
80
|
+
if line == "[DONE]":
|
|
81
|
+
log.debug("stream [DONE] after %d chunks", n_chunks)
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
j = json.loads(line)
|
|
85
|
+
except json.JSONDecodeError:
|
|
86
|
+
yield _sanitize_text(line)
|
|
87
|
+
n_chunks += 1
|
|
88
|
+
continue
|
|
89
|
+
text = _extract_text(j)
|
|
90
|
+
if text:
|
|
91
|
+
if n_chunks == 0:
|
|
92
|
+
log.debug("stream first chunk received")
|
|
93
|
+
n_chunks += 1
|
|
94
|
+
yield _sanitize_text(text)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fetch_sync(prompt: str, base_url: str, model: str) -> str:
|
|
98
|
+
"""Synchronous /chat/completions fallback. Returns text or raises."""
|
|
99
|
+
api_key = os.environ.get("LMSTUDIO_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
100
|
+
url = base_url.rstrip("/") + "/chat/completions"
|
|
101
|
+
headers = {"Content-Type": "application/json"}
|
|
102
|
+
if api_key:
|
|
103
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
104
|
+
payload = {"model": model, "messages": _build_messages(prompt)}
|
|
105
|
+
log = get_logger("LMProxyClient", log_level=logging.DEBUG)
|
|
106
|
+
sess = _session or requests
|
|
107
|
+
log.debug("sync POST %s model=%s", url, model)
|
|
108
|
+
r = sess.post(url, json=payload, headers=headers, timeout=30)
|
|
109
|
+
log.debug("sync response status=%s", r.status_code)
|
|
110
|
+
r.raise_for_status()
|
|
111
|
+
j = r.json()
|
|
112
|
+
if "choices" in j and j["choices"]:
|
|
113
|
+
c = j["choices"][0]
|
|
114
|
+
text = (c.get("message") or {}).get("content") or c.get("text")
|
|
115
|
+
if text:
|
|
116
|
+
log.debug("sync got text len=%d", len(text))
|
|
117
|
+
return _sanitize_text(text)
|
|
118
|
+
raise RuntimeError("No text in sync response")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _offline_stream(_prompt: str):
|
|
122
|
+
"""Multi-chunk offline fallback when LM Studio is unreachable."""
|
|
123
|
+
chunks = [
|
|
124
|
+
"LM Studio is not available. This is a static offline response.",
|
|
125
|
+
" It simulates streaming by sending multiple chunks.",
|
|
126
|
+
" Use it to test client rendering and chunk reassembly.",
|
|
127
|
+
]
|
|
128
|
+
for chunk in chunks:
|
|
129
|
+
yield _sanitize_text(chunk)
|
|
130
|
+
time.sleep(0.05)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def make_http_session():
|
|
134
|
+
"""Create a persistent requests.Session with connection pooling.
|
|
135
|
+
|
|
136
|
+
Returns None when requests is not installed.
|
|
137
|
+
"""
|
|
138
|
+
if requests is None or _HTTPAdapter is None:
|
|
139
|
+
return None
|
|
140
|
+
sess = requests.Session()
|
|
141
|
+
adapter = _HTTPAdapter(pool_connections=4, pool_maxsize=16)
|
|
142
|
+
sess.mount("http://", adapter)
|
|
143
|
+
sess.mount("https://", adapter)
|
|
144
|
+
return sess
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# interactive_streaming
|
|
2
|
+
|
|
3
|
+
Bidirectional streaming chat demo using **LM Studio** as the LLM backend.
|
|
4
|
+
A proxy client forwards user prompts to LM Studio and streams the response back token-by-token.
|
|
5
|
+
|
|
6
|
+
## Architecture
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
TextClient ──lm_request──► GrpcServer ──lm_request──► LMProxyClient
|
|
10
|
+
◄─lm_response_stream────────────────────────── (HTTP → LM Studio)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- **GrpcServer** — plain `BaseServer`, no custom logic.
|
|
14
|
+
- **LMProxyClient** — receives `lm_request`, calls LM Studio `/v1/chat/completions` (streaming), sends back `lm_response_stream` chunks with a `done` flag.
|
|
15
|
+
- **TextClient** — interactive CLI; reads user input, sends `lm_request`, prints streaming chunks as they arrive.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- LM Studio running locally at `http://127.0.0.1:1234` with a model loaded (default: `gemma-4e2b`).
|
|
20
|
+
- `pip install requests` (included in `requirements_examples.txt`).
|
|
21
|
+
|
|
22
|
+
> If LM Studio is not running, `LMProxyClient` falls back to an offline stub that echoes the prompt.
|
|
23
|
+
|
|
24
|
+
## How to run
|
|
25
|
+
|
|
26
|
+
**Option A — two separate terminals:**
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
# Terminal 1: server + proxy together
|
|
30
|
+
python examples/interactive_streaming/run_server_proxy.py
|
|
31
|
+
|
|
32
|
+
# Terminal 2: interactive text UI
|
|
33
|
+
python examples/interactive_streaming/run_text_client.py
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Option B — three separate processes:**
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
python examples/interactive_streaming/GrpcServerExample.py
|
|
40
|
+
python examples/interactive_streaming/LMProxyClient.py
|
|
41
|
+
python examples/interactive_streaming/TextClient.py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Type a prompt in the `TextClient` terminal and the LLM response streams back live. `exit` or `quit` to stop.
|
|
45
|
+
|
|
46
|
+
## TL;DR
|
|
47
|
+
|
|
48
|
+
Start `run_server_proxy.py`, then `run_text_client.py`. Type prompts, get streamed LLM responses. Needs LM Studio running locally.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Launch the gRPC server and LM proxy client for the interactive streaming example."""
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from examples.interactive_streaming.GrpcServerExample import GrpcServer
|
|
6
|
+
from examples.interactive_streaming.LMProxyClient import LMProxyClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
server = GrpcServer(49999)
|
|
11
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
12
|
+
server_thread.start()
|
|
13
|
+
|
|
14
|
+
# start LM proxy client (connects automatically)
|
|
15
|
+
proxy = LMProxyClient("lm-proxy", 49999)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
while True:
|
|
19
|
+
time.sleep(1)
|
|
20
|
+
except KeyboardInterrupt:
|
|
21
|
+
proxy.disconnect()
|
|
22
|
+
server.shutdown()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Launch the interactive text client for the LM Studio streaming example."""
|
|
2
|
+
from examples.interactive_streaming.TextClient import TextClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
client = TextClient("text-ui", 49999)
|
|
7
|
+
try:
|
|
8
|
+
client.interactive_loop()
|
|
9
|
+
except KeyboardInterrupt:
|
|
10
|
+
client.disconnect()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""gRPC client that executes sandboxed file operations (create / edit / delete)."""
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from grpchook.baseclient import BaseClient
|
|
8
|
+
from grpchook import message_pb2
|
|
9
|
+
from grpchook.tools import struct_to_json, json_to_struct
|
|
10
|
+
|
|
11
|
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
MCP_OPERATIONS = ["mcp.file.create", "mcp.file.edit", "mcp.file.delete"]
|
|
14
|
+
MCP_RESPONSE = "mcp.file.response"
|
|
15
|
+
|
|
16
|
+
# Use the OS temp directory so no project files are ever touched.
|
|
17
|
+
# Windows: %TMP% / %TEMP% Unix: $TMPDIR / /tmp
|
|
18
|
+
BASE_DIR = Path(tempfile.gettempdir()) / "mcp_server"
|
|
19
|
+
MAX_FILE_SIZE = 1024 * 1024 # 1 MB
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── FileOperationClient ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
class FileOperationClient(BaseClient):
|
|
25
|
+
"""
|
|
26
|
+
gRPC client that executes sandboxed file operations (create / edit / delete).
|
|
27
|
+
|
|
28
|
+
Receives mcp.file.{create,edit,delete} messages from the gRPC server,
|
|
29
|
+
executes the requested file operation (restricted to BASE_DIR), and
|
|
30
|
+
sends back an mcp.file.response message to the original requester.
|
|
31
|
+
|
|
32
|
+
Security
|
|
33
|
+
--------
|
|
34
|
+
- All paths are resolved with Path.resolve() + relative_to() so that
|
|
35
|
+
any attempt to escape BASE_DIR (e.g. via "..") is rejected.
|
|
36
|
+
- Content size is capped at MAX_FILE_SIZE bytes.
|
|
37
|
+
- Writes are atomic: temp file → os.replace().
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, port: int):
|
|
41
|
+
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
super().__init__(
|
|
43
|
+
name="FileOperationClient",
|
|
44
|
+
port=port,
|
|
45
|
+
requires=MCP_OPERATIONS,
|
|
46
|
+
provides=[MCP_RESPONSE],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def on_init(self):
|
|
50
|
+
self.logger.info(
|
|
51
|
+
"FileOperationClient ready. ALL file operations are restricted to: %s",
|
|
52
|
+
BASE_DIR.resolve(),
|
|
53
|
+
)
|
|
54
|
+
# Drive on_receive() from a background thread so the client processes
|
|
55
|
+
# incoming messages without the caller needing to call spin_forever().
|
|
56
|
+
threading.Thread(target=self.spin_forever, daemon=True).start()
|
|
57
|
+
|
|
58
|
+
# ── Path safety ───────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def _safe_path(self, rel_path: str) -> Path | None:
|
|
61
|
+
"""
|
|
62
|
+
Resolve *rel_path* relative to BASE_DIR.
|
|
63
|
+
Returns None when the resolved path escapes BASE_DIR.
|
|
64
|
+
"""
|
|
65
|
+
if not rel_path:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
target = (BASE_DIR / rel_path).resolve()
|
|
69
|
+
target.relative_to(BASE_DIR.resolve()) # raises ValueError if outside
|
|
70
|
+
return target
|
|
71
|
+
except (ValueError, OSError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# ── Response helper ───────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def _make_response(self, ok: bool, operation: str, path: str,
|
|
77
|
+
error: str = "") -> message_pb2.Message:
|
|
78
|
+
payload: dict = {"ok": ok, "operation": operation, "path": path}
|
|
79
|
+
if error:
|
|
80
|
+
payload["error"] = error
|
|
81
|
+
return message_pb2.Message(
|
|
82
|
+
metaInfo=message_pb2.MetaInformation(messageName=MCP_RESPONSE),
|
|
83
|
+
payload=message_pb2.Payload(structPayload=json_to_struct(payload)),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ── File operations ───────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def _handle_create(self, data: dict) -> tuple[bool, str]:
|
|
89
|
+
"""Atomically create a new file at *data['path']* with *data['content']*."""
|
|
90
|
+
path = data.get("path", "")
|
|
91
|
+
content = data.get("content", "")
|
|
92
|
+
enc = data.get("encoding", "utf-8")
|
|
93
|
+
|
|
94
|
+
target = self._safe_path(path)
|
|
95
|
+
if target is None:
|
|
96
|
+
return False, f"path '{path}' is outside the allowed directory"
|
|
97
|
+
if target.exists():
|
|
98
|
+
return False, f"file already exists: {path}"
|
|
99
|
+
if len(content.encode(enc, errors="replace")) > MAX_FILE_SIZE:
|
|
100
|
+
return False, f"content exceeds {MAX_FILE_SIZE} bytes"
|
|
101
|
+
|
|
102
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
104
|
+
try:
|
|
105
|
+
self.logger.info("create: writing to '%s'", target)
|
|
106
|
+
tmp.write_text(content, encoding=enc)
|
|
107
|
+
os.replace(tmp, target)
|
|
108
|
+
self.logger.info("create: file written at '%s'", target)
|
|
109
|
+
return True, ""
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
tmp.unlink(missing_ok=True)
|
|
112
|
+
return False, str(exc)
|
|
113
|
+
|
|
114
|
+
def _handle_edit(self, data: dict) -> tuple[bool, str]:
|
|
115
|
+
"""Atomically overwrite an existing file at *data['path']* with *data['content']*."""
|
|
116
|
+
path = data.get("path", "")
|
|
117
|
+
content = data.get("content", "")
|
|
118
|
+
enc = data.get("encoding", "utf-8")
|
|
119
|
+
|
|
120
|
+
target = self._safe_path(path)
|
|
121
|
+
if target is None:
|
|
122
|
+
return False, f"path '{path}' is outside the allowed directory"
|
|
123
|
+
if not target.exists():
|
|
124
|
+
return False, f"file does not exist: {path}"
|
|
125
|
+
if len(content.encode(enc, errors="replace")) > MAX_FILE_SIZE:
|
|
126
|
+
return False, f"content exceeds {MAX_FILE_SIZE} bytes"
|
|
127
|
+
|
|
128
|
+
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
129
|
+
try:
|
|
130
|
+
self.logger.info("edit: overwriting '%s'", target)
|
|
131
|
+
tmp.write_text(content, encoding=enc)
|
|
132
|
+
os.replace(tmp, target)
|
|
133
|
+
self.logger.info("edit: file updated at '%s'", target)
|
|
134
|
+
return True, ""
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
tmp.unlink(missing_ok=True)
|
|
137
|
+
return False, str(exc)
|
|
138
|
+
|
|
139
|
+
def _handle_delete(self, data: dict) -> tuple[bool, str]:
|
|
140
|
+
"""Remove the file at *data['path']* if it exists inside BASE_DIR."""
|
|
141
|
+
path = data.get("path", "")
|
|
142
|
+
|
|
143
|
+
target = self._safe_path(path)
|
|
144
|
+
if target is None:
|
|
145
|
+
return False, f"path '{path}' is outside the allowed directory"
|
|
146
|
+
if not target.exists():
|
|
147
|
+
return False, f"file does not exist: {path}"
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
self.logger.info("delete: removing '%s'", target)
|
|
151
|
+
target.unlink()
|
|
152
|
+
self.logger.info("delete: file removed at '%s'", target)
|
|
153
|
+
return True, ""
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
return False, str(exc)
|
|
156
|
+
|
|
157
|
+
# ── Hook ────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def on_receive(self, data: message_pb2.Message) -> bool:
|
|
160
|
+
"""Dispatch incoming file operation requests to the appropriate handler."""
|
|
161
|
+
operation = data.metaInfo.messageName
|
|
162
|
+
payload = struct_to_json(data.payload.structPayload)
|
|
163
|
+
path = payload.get("path", "")
|
|
164
|
+
|
|
165
|
+
self.logger.info("op=%s path='%s'", operation, path)
|
|
166
|
+
|
|
167
|
+
if operation == "mcp.file.create":
|
|
168
|
+
ok, error = self._handle_create(payload)
|
|
169
|
+
elif operation == "mcp.file.edit":
|
|
170
|
+
ok, error = self._handle_edit(payload)
|
|
171
|
+
elif operation == "mcp.file.delete":
|
|
172
|
+
ok, error = self._handle_delete(payload)
|
|
173
|
+
else:
|
|
174
|
+
self.logger.warning("Unknown operation: %s", operation)
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
abs_path = self._safe_path(path)
|
|
178
|
+
|
|
179
|
+
if ok:
|
|
180
|
+
self.logger.info("op=%s rel='%s' abs='%s' -> OK",
|
|
181
|
+
operation, path, abs_path)
|
|
182
|
+
else:
|
|
183
|
+
self.logger.warning("op=%s rel='%s' -> FAIL: %s", operation, path, error)
|
|
184
|
+
|
|
185
|
+
response = self._make_response(ok, operation, path, error)
|
|
186
|
+
response.metaInfo.messageId = data.metaInfo.messageId
|
|
187
|
+
self.send_data(response)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
client = FileOperationClient(49998)
|
|
195
|
+
try:
|
|
196
|
+
client.spin_forever()
|
|
197
|
+
except KeyboardInterrupt:
|
|
198
|
+
pass
|
|
199
|
+
finally:
|
|
200
|
+
client.disconnect()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Plain gRPC relay server for the mcp_server example."""
|
|
2
|
+
from grpchook.baseserver import BaseServer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class McpGrpcServer(BaseServer):
|
|
6
|
+
"""
|
|
7
|
+
Plain gRPC server for the mcp_server example.
|
|
8
|
+
|
|
9
|
+
No custom routing logic needed — the default on_receive() returns True
|
|
10
|
+
so every message is fanned out via DataRegister to all subscribers.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, port: int):
|
|
14
|
+
super().__init__(port, name="McpGrpcServer")
|
|
15
|
+
|
|
16
|
+
def on_init(self):
|
|
17
|
+
self.logger.info("McpGrpcServer ready on port %d", self._port)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
server = McpGrpcServer(49998)
|
|
22
|
+
server.serve_forever()
|