sari 0.0.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.
- app/__init__.py +1 -0
- app/config.py +240 -0
- app/db.py +932 -0
- app/dedup_queue.py +77 -0
- app/engine_registry.py +56 -0
- app/engine_runtime.py +472 -0
- app/http_server.py +204 -0
- app/indexer.py +1532 -0
- app/main.py +147 -0
- app/models.py +39 -0
- app/queue_pipeline.py +65 -0
- app/ranking.py +144 -0
- app/registry.py +172 -0
- app/search_engine.py +572 -0
- app/watcher.py +124 -0
- app/workspace.py +286 -0
- deckard/__init__.py +3 -0
- deckard/__main__.py +4 -0
- deckard/main.py +345 -0
- deckard/version.py +1 -0
- mcp/__init__.py +1 -0
- mcp/__main__.py +19 -0
- mcp/cli.py +485 -0
- mcp/daemon.py +149 -0
- mcp/proxy.py +304 -0
- mcp/registry.py +218 -0
- mcp/server.py +519 -0
- mcp/session.py +234 -0
- mcp/telemetry.py +112 -0
- mcp/test_cli.py +89 -0
- mcp/test_daemon.py +124 -0
- mcp/test_server.py +197 -0
- mcp/tools/__init__.py +14 -0
- mcp/tools/_util.py +244 -0
- mcp/tools/deckard_guide.py +32 -0
- mcp/tools/doctor.py +208 -0
- mcp/tools/get_callers.py +60 -0
- mcp/tools/get_implementations.py +60 -0
- mcp/tools/index_file.py +75 -0
- mcp/tools/list_files.py +138 -0
- mcp/tools/read_file.py +48 -0
- mcp/tools/read_symbol.py +99 -0
- mcp/tools/registry.py +212 -0
- mcp/tools/repo_candidates.py +89 -0
- mcp/tools/rescan.py +46 -0
- mcp/tools/scan_once.py +54 -0
- mcp/tools/search.py +208 -0
- mcp/tools/search_api_endpoints.py +72 -0
- mcp/tools/search_symbols.py +63 -0
- mcp/tools/status.py +135 -0
- sari/__init__.py +1 -0
- sari/__main__.py +4 -0
- sari-0.0.1.dist-info/METADATA +521 -0
- sari-0.0.1.dist-info/RECORD +58 -0
- sari-0.0.1.dist-info/WHEEL +5 -0
- sari-0.0.1.dist-info/entry_points.txt +2 -0
- sari-0.0.1.dist-info/licenses/LICENSE +21 -0
- sari-0.0.1.dist-info/top_level.txt +4 -0
mcp/cli.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Sari CLI - Command-line interface for daemon management.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
sari daemon start [-d] Start daemon (foreground or daemonized)
|
|
7
|
+
sari daemon stop Stop running daemon
|
|
8
|
+
sari daemon status Check daemon status
|
|
9
|
+
sari proxy Run in proxy mode (stdio ↔ daemon)
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import socket
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
import ipaddress
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional, Dict, Any, Tuple
|
|
25
|
+
|
|
26
|
+
# Add project root to sys.path for absolute imports
|
|
27
|
+
SCRIPT_DIR = Path(__file__).parent
|
|
28
|
+
REPO_ROOT = SCRIPT_DIR.parent
|
|
29
|
+
if str(REPO_ROOT) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
from app.workspace import WorkspaceManager
|
|
34
|
+
from app.registry import ServerRegistry
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
38
|
+
DEFAULT_PORT = 47779
|
|
39
|
+
PID_FILE = WorkspaceManager.get_global_data_dir() / "daemon.pid"
|
|
40
|
+
DEFAULT_HTTP_HOST = "127.0.0.1"
|
|
41
|
+
DEFAULT_HTTP_PORT = 47777
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_daemon_address():
|
|
45
|
+
"""Get daemon host and port from environment or defaults."""
|
|
46
|
+
host = os.environ.get("DECKARD_DAEMON_HOST", DEFAULT_HOST)
|
|
47
|
+
port = int(os.environ.get("DECKARD_DAEMON_PORT", DEFAULT_PORT))
|
|
48
|
+
return host, port
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _package_config_path() -> Path:
|
|
52
|
+
return Path(__file__).parent.parent / "config" / "config.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_http_config(workspace_root: str) -> Optional[dict]:
|
|
56
|
+
try:
|
|
57
|
+
from app.workspace import WorkspaceManager
|
|
58
|
+
from app.config import Config
|
|
59
|
+
cfg_path = WorkspaceManager.resolve_config_path(workspace_root)
|
|
60
|
+
return json.loads(Path(cfg_path).read_text(encoding="utf-8")) if Path(cfg_path).exists() else None
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def _load_server_info(workspace_root: str) -> Optional[dict]:
|
|
65
|
+
"""Legacy server.json location for backward compatibility."""
|
|
66
|
+
server_json = Path(workspace_root) / ".codex" / "tools" / "sari" / "data" / "server.json"
|
|
67
|
+
if not server_json.exists():
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(server_json.read_text(encoding="utf-8"))
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_loopback(host: str) -> bool:
|
|
76
|
+
h = (host or "").strip().lower()
|
|
77
|
+
if h == "localhost":
|
|
78
|
+
return True
|
|
79
|
+
try:
|
|
80
|
+
return ipaddress.ip_address(h).is_loopback
|
|
81
|
+
except ValueError:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _enforce_loopback(host: str) -> None:
|
|
86
|
+
if os.environ.get("DECKARD_ALLOW_NON_LOOPBACK") == "1" or os.environ.get("LOCAL_SEARCH_ALLOW_NON_LOOPBACK") == "1":
|
|
87
|
+
return
|
|
88
|
+
if not _is_loopback(host):
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"sari loopback-only: server_host must be 127.0.0.1/localhost/::1 (got={host}). "
|
|
91
|
+
"Set DECKARD_ALLOW_NON_LOOPBACK=1 to override (NOT recommended)."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get_http_host_port() -> tuple[str, int]:
|
|
96
|
+
"""Get active HTTP server address with Environment priority (v2.7.0)."""
|
|
97
|
+
# 0. Environment Override (Highest Priority for testing/isolation)
|
|
98
|
+
env_host = (
|
|
99
|
+
os.environ.get("DECKARD_HTTP_API_HOST")
|
|
100
|
+
or os.environ.get("DECKARD_HTTP_HOST")
|
|
101
|
+
or os.environ.get("LOCAL_SEARCH_HTTP_HOST")
|
|
102
|
+
or os.environ.get("DECKARD_HOST")
|
|
103
|
+
)
|
|
104
|
+
env_port_raw = (
|
|
105
|
+
os.environ.get("DECKARD_HTTP_API_PORT")
|
|
106
|
+
or os.environ.get("DECKARD_HTTP_PORT")
|
|
107
|
+
or os.environ.get("LOCAL_SEARCH_HTTP_PORT")
|
|
108
|
+
or os.environ.get("DECKARD_PORT")
|
|
109
|
+
)
|
|
110
|
+
env_port = None if env_port_raw in (None, "", "0") else env_port_raw
|
|
111
|
+
if env_host or env_port:
|
|
112
|
+
return str(env_host or DEFAULT_HTTP_HOST), int(env_port or DEFAULT_HTTP_PORT)
|
|
113
|
+
|
|
114
|
+
workspace_root = WorkspaceManager.resolve_workspace_root()
|
|
115
|
+
|
|
116
|
+
# 1. Try Global Registry
|
|
117
|
+
try:
|
|
118
|
+
inst = ServerRegistry().get_instance(workspace_root)
|
|
119
|
+
if inst and inst.get("port"):
|
|
120
|
+
return str(inst.get("host", DEFAULT_HTTP_HOST)), int(inst["port"])
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
# 2. Legacy server.json
|
|
125
|
+
server_info = _load_server_info(workspace_root)
|
|
126
|
+
if server_info:
|
|
127
|
+
try:
|
|
128
|
+
return str(server_info.get("host", DEFAULT_HTTP_HOST)), int(server_info.get("port", DEFAULT_HTTP_PORT))
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
# 3. Fallback to Config
|
|
133
|
+
cfg = _load_http_config(workspace_root) or {}
|
|
134
|
+
host = str(cfg.get("http_api_host", cfg.get("server_host", DEFAULT_HTTP_HOST)))
|
|
135
|
+
port = int(cfg.get("http_api_port", cfg.get("server_port", DEFAULT_HTTP_PORT)))
|
|
136
|
+
return host, port
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _request_http(path: str, params: dict) -> dict:
|
|
141
|
+
host, port = _get_http_host_port()
|
|
142
|
+
_enforce_loopback(host)
|
|
143
|
+
qs = urllib.parse.urlencode(params)
|
|
144
|
+
url = f"http://{host}:{port}{path}?{qs}"
|
|
145
|
+
with urllib.request.urlopen(url, timeout=3.0) as r:
|
|
146
|
+
return json.loads(r.read().decode("utf-8"))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_daemon_running(host: str, port: int) -> bool:
|
|
150
|
+
"""Check if daemon is running by attempting connection."""
|
|
151
|
+
try:
|
|
152
|
+
with socket.create_connection((host, port), timeout=0.5):
|
|
153
|
+
return True
|
|
154
|
+
except (ConnectionRefusedError, OSError):
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def read_pid() -> Optional[int]:
|
|
159
|
+
"""Read PID from pidfile."""
|
|
160
|
+
if PID_FILE.exists():
|
|
161
|
+
try:
|
|
162
|
+
return int(PID_FILE.read_text().strip())
|
|
163
|
+
except (ValueError, OSError):
|
|
164
|
+
pass
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def remove_pid() -> None:
|
|
169
|
+
"""Remove pidfile manually (if daemon crashed)."""
|
|
170
|
+
if PID_FILE.exists():
|
|
171
|
+
PID_FILE.unlink()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_daemon_start(args):
|
|
175
|
+
"""Start the daemon."""
|
|
176
|
+
host, port = get_daemon_address()
|
|
177
|
+
|
|
178
|
+
if is_daemon_running(host, port):
|
|
179
|
+
print(f"✅ Daemon already running on {host}:{port}")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
repo_root = Path(__file__).parent.parent.resolve()
|
|
183
|
+
|
|
184
|
+
if args.daemonize:
|
|
185
|
+
# Start in background
|
|
186
|
+
print(f"Starting daemon on {host}:{port} (background)...")
|
|
187
|
+
|
|
188
|
+
proc = subprocess.Popen(
|
|
189
|
+
[sys.executable, "-m", "mcp.daemon"],
|
|
190
|
+
cwd=repo_root,
|
|
191
|
+
start_new_session=True,
|
|
192
|
+
stdout=subprocess.DEVNULL,
|
|
193
|
+
stderr=subprocess.DEVNULL
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# PID file will be written by the daemon process itself
|
|
197
|
+
|
|
198
|
+
# Wait for startup
|
|
199
|
+
for _ in range(30):
|
|
200
|
+
if is_daemon_running(host, port):
|
|
201
|
+
print(f"✅ Daemon started (PID: {proc.pid})")
|
|
202
|
+
return 0
|
|
203
|
+
time.sleep(0.1)
|
|
204
|
+
|
|
205
|
+
print("❌ Daemon failed to start", file=sys.stderr)
|
|
206
|
+
return 1
|
|
207
|
+
else:
|
|
208
|
+
# Start in foreground
|
|
209
|
+
print(f"Starting daemon on {host}:{port} (foreground, Ctrl+C to stop)...")
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
# Import and run directly
|
|
213
|
+
from mcp.daemon import main as daemon_main
|
|
214
|
+
import asyncio
|
|
215
|
+
asyncio.run(daemon_main())
|
|
216
|
+
except KeyboardInterrupt:
|
|
217
|
+
print("\nDaemon stopped.")
|
|
218
|
+
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_daemon_stop(args):
|
|
223
|
+
"""Stop the daemon."""
|
|
224
|
+
host, port = get_daemon_address()
|
|
225
|
+
|
|
226
|
+
if not is_daemon_running(host, port):
|
|
227
|
+
print("Daemon is not running")
|
|
228
|
+
remove_pid()
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
pid = read_pid()
|
|
232
|
+
|
|
233
|
+
if pid:
|
|
234
|
+
try:
|
|
235
|
+
if os.name == 'nt':
|
|
236
|
+
# Windows force kill
|
|
237
|
+
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, check=False)
|
|
238
|
+
print(f"Executed taskkill for PID {pid}")
|
|
239
|
+
else:
|
|
240
|
+
os.kill(pid, signal.SIGTERM)
|
|
241
|
+
print(f"Sent SIGTERM to PID {pid}")
|
|
242
|
+
|
|
243
|
+
# Wait for shutdown
|
|
244
|
+
for _ in range(30):
|
|
245
|
+
if not is_daemon_running(host, port):
|
|
246
|
+
print("✅ Daemon stopped")
|
|
247
|
+
remove_pid()
|
|
248
|
+
return 0
|
|
249
|
+
time.sleep(0.1)
|
|
250
|
+
|
|
251
|
+
# Force kill (Unix only, Windows already done with /F)
|
|
252
|
+
if os.name != 'nt':
|
|
253
|
+
print("Daemon not responding, sending SIGKILL...")
|
|
254
|
+
os.kill(pid, signal.SIGKILL)
|
|
255
|
+
|
|
256
|
+
remove_pid()
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
except (ProcessLookupError, PermissionError):
|
|
260
|
+
print("PID not found or permission denied, daemon may have crashed or locked")
|
|
261
|
+
remove_pid()
|
|
262
|
+
return 0
|
|
263
|
+
else:
|
|
264
|
+
print("No PID file found. Try stopping manually.")
|
|
265
|
+
return 1
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def cmd_daemon_status(args):
|
|
269
|
+
"""Check daemon status."""
|
|
270
|
+
host, port = get_daemon_address()
|
|
271
|
+
|
|
272
|
+
running = is_daemon_running(host, port)
|
|
273
|
+
pid = read_pid()
|
|
274
|
+
|
|
275
|
+
print(f"Host: {host}")
|
|
276
|
+
print(f"Port: {port}")
|
|
277
|
+
print(f"Status: {'🟢 Running' if running else '⚫ Stopped'}")
|
|
278
|
+
|
|
279
|
+
if pid:
|
|
280
|
+
print(f"PID: {pid}")
|
|
281
|
+
|
|
282
|
+
if running:
|
|
283
|
+
# Try to get workspace info
|
|
284
|
+
try:
|
|
285
|
+
with socket.create_connection((host, port), timeout=1.0) as sock:
|
|
286
|
+
# Send a status request
|
|
287
|
+
import json
|
|
288
|
+
request = json.dumps({
|
|
289
|
+
"jsonrpc": "2.0",
|
|
290
|
+
"id": 1,
|
|
291
|
+
"method": "tools/call",
|
|
292
|
+
"params": {"name": "status", "arguments": {"details": True}}
|
|
293
|
+
}) + "\n"
|
|
294
|
+
sock.sendall(request.encode())
|
|
295
|
+
|
|
296
|
+
# We'd need an initialize first, so just skip detailed status for now
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
return 0 if running else 1
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def cmd_proxy(args):
|
|
304
|
+
"""Run in proxy mode (for MCP stdio)."""
|
|
305
|
+
from mcp.proxy import main as proxy_main
|
|
306
|
+
proxy_main()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _tcp_blocked(err: OSError) -> bool:
|
|
310
|
+
return getattr(err, "errno", None) in (1, 13)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cmd_auto(args):
|
|
314
|
+
"""Try TCP daemon/proxy first, fallback to STDIO server."""
|
|
315
|
+
host, port = get_daemon_address()
|
|
316
|
+
|
|
317
|
+
# Fast path: if TCP is blocked by sandbox, skip daemon/proxy.
|
|
318
|
+
try:
|
|
319
|
+
with socket.create_connection((host, port), timeout=0.1):
|
|
320
|
+
return cmd_proxy(args)
|
|
321
|
+
except OSError as e:
|
|
322
|
+
if _tcp_blocked(e):
|
|
323
|
+
from mcp.server import main as server_main
|
|
324
|
+
server_main()
|
|
325
|
+
return 0
|
|
326
|
+
# Connection refused etc. We'll try to start daemon below.
|
|
327
|
+
|
|
328
|
+
# Try to start daemon in background, then proxy.
|
|
329
|
+
if not is_daemon_running(host, port):
|
|
330
|
+
repo_root = Path(__file__).parent.parent.resolve()
|
|
331
|
+
subprocess.Popen(
|
|
332
|
+
[sys.executable, "-m", "mcp.daemon"],
|
|
333
|
+
cwd=repo_root,
|
|
334
|
+
start_new_session=True,
|
|
335
|
+
stdout=subprocess.DEVNULL,
|
|
336
|
+
stderr=subprocess.DEVNULL,
|
|
337
|
+
)
|
|
338
|
+
for _ in range(30):
|
|
339
|
+
try:
|
|
340
|
+
if is_daemon_running(host, port):
|
|
341
|
+
break
|
|
342
|
+
except OSError as e:
|
|
343
|
+
if _tcp_blocked(e):
|
|
344
|
+
from mcp.server import main as server_main
|
|
345
|
+
server_main()
|
|
346
|
+
return 0
|
|
347
|
+
time.sleep(0.1)
|
|
348
|
+
|
|
349
|
+
if is_daemon_running(host, port):
|
|
350
|
+
return cmd_proxy(args)
|
|
351
|
+
|
|
352
|
+
# Final fallback to STDIO server.
|
|
353
|
+
from mcp.server import main as server_main
|
|
354
|
+
server_main()
|
|
355
|
+
return 0
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_status(args):
|
|
359
|
+
"""Query HTTP status endpoint."""
|
|
360
|
+
try:
|
|
361
|
+
host, port = _get_http_host_port()
|
|
362
|
+
# Fast check if port is even open
|
|
363
|
+
if not is_daemon_running(host, port):
|
|
364
|
+
print(f"❌ Error: Daemon is not running on {host}:{port}")
|
|
365
|
+
return 1
|
|
366
|
+
|
|
367
|
+
data = _request_http("/status", {})
|
|
368
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
369
|
+
return 0
|
|
370
|
+
except Exception as e:
|
|
371
|
+
print(f"❌ Error: Could not connect to Sari HTTP server.")
|
|
372
|
+
print(f" Details: {e}")
|
|
373
|
+
print(f" Hint: Make sure the Deamon is running for this workspace.")
|
|
374
|
+
return 1
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_search(args):
|
|
378
|
+
"""Query HTTP search endpoint."""
|
|
379
|
+
params = {"q": args.query, "limit": args.limit}
|
|
380
|
+
if args.repo:
|
|
381
|
+
params["repo"] = args.repo
|
|
382
|
+
data = _request_http("/search", params)
|
|
383
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def cmd_init(args):
|
|
388
|
+
"""Initialize workspace with Sari config."""
|
|
389
|
+
workspace_root = Path(args.workspace).expanduser().resolve() if args.workspace else Path(WorkspaceManager.resolve_workspace_root()).resolve()
|
|
390
|
+
cfg_path = Path(WorkspaceManager.resolve_config_path(str(workspace_root)))
|
|
391
|
+
data_dir = workspace_root / ".codex" / "tools" / "sari" / "data"
|
|
392
|
+
|
|
393
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
394
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
|
|
396
|
+
from app.config import Config
|
|
397
|
+
default_cfg = Config.get_defaults(str(workspace_root))
|
|
398
|
+
|
|
399
|
+
data = {}
|
|
400
|
+
if cfg_path.exists() and not args.force:
|
|
401
|
+
try:
|
|
402
|
+
data = json.loads(cfg_path.read_text(encoding="utf-8")) or {}
|
|
403
|
+
except Exception:
|
|
404
|
+
data = {}
|
|
405
|
+
|
|
406
|
+
roots = data.get("roots") or data.get("workspace_roots") or []
|
|
407
|
+
if not isinstance(roots, list):
|
|
408
|
+
roots = []
|
|
409
|
+
if str(workspace_root) not in roots:
|
|
410
|
+
roots.append(str(workspace_root))
|
|
411
|
+
data["roots"] = roots
|
|
412
|
+
data.setdefault("db_path", default_cfg["db_path"])
|
|
413
|
+
data.setdefault("exclude_dirs", default_cfg["exclude_dirs"])
|
|
414
|
+
|
|
415
|
+
cfg_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
416
|
+
print(f"✅ Updated Sari config: {cfg_path}")
|
|
417
|
+
print(f"\n🚀 Workspace initialized successfully at {workspace_root}")
|
|
418
|
+
return 0
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main():
|
|
422
|
+
parser = argparse.ArgumentParser(
|
|
423
|
+
prog="sari",
|
|
424
|
+
description="Sari - Local Search MCP Server"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
428
|
+
|
|
429
|
+
# daemon subcommand
|
|
430
|
+
daemon_parser = subparsers.add_parser("daemon", help="Daemon management")
|
|
431
|
+
daemon_sub = daemon_parser.add_subparsers(dest="daemon_command")
|
|
432
|
+
|
|
433
|
+
# daemon start
|
|
434
|
+
start_parser = daemon_sub.add_parser("start", help="Start daemon")
|
|
435
|
+
start_parser.add_argument("-d", "--daemonize", action="store_true",
|
|
436
|
+
help="Run in background")
|
|
437
|
+
start_parser.set_defaults(func=cmd_daemon_start)
|
|
438
|
+
|
|
439
|
+
# daemon stop
|
|
440
|
+
stop_parser = daemon_sub.add_parser("stop", help="Stop daemon")
|
|
441
|
+
stop_parser.set_defaults(func=cmd_daemon_stop)
|
|
442
|
+
|
|
443
|
+
# daemon status
|
|
444
|
+
status_parser = daemon_sub.add_parser("status", help="Check status")
|
|
445
|
+
status_parser.set_defaults(func=cmd_daemon_status)
|
|
446
|
+
|
|
447
|
+
# proxy subcommand
|
|
448
|
+
proxy_parser = subparsers.add_parser("proxy", help="Run in proxy mode")
|
|
449
|
+
proxy_parser.set_defaults(func=cmd_proxy)
|
|
450
|
+
|
|
451
|
+
# status subcommand (HTTP)
|
|
452
|
+
status_parser = subparsers.add_parser("status", help="Query HTTP status")
|
|
453
|
+
status_parser.set_defaults(func=cmd_status)
|
|
454
|
+
|
|
455
|
+
# search subcommand (HTTP)
|
|
456
|
+
search_parser = subparsers.add_parser("search", help="Search via HTTP server")
|
|
457
|
+
search_parser.add_argument("query", help="Search query")
|
|
458
|
+
search_parser.add_argument("--repo", default="", help="Limit search to repo")
|
|
459
|
+
search_parser.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")
|
|
460
|
+
search_parser.set_defaults(func=cmd_search)
|
|
461
|
+
|
|
462
|
+
# init subcommand
|
|
463
|
+
init_parser = subparsers.add_parser("init", help="Initialize workspace config")
|
|
464
|
+
init_parser.add_argument("--workspace", default="", help="Workspace root (default: auto-detect)")
|
|
465
|
+
init_parser.add_argument("--force", action="store_true", help="Overwrite existing config")
|
|
466
|
+
init_parser.set_defaults(func=cmd_init)
|
|
467
|
+
|
|
468
|
+
args = parser.parse_args()
|
|
469
|
+
|
|
470
|
+
if not args.command:
|
|
471
|
+
parser.print_help()
|
|
472
|
+
return 1
|
|
473
|
+
|
|
474
|
+
if args.command == "daemon" and not args.daemon_command:
|
|
475
|
+
daemon_parser.print_help()
|
|
476
|
+
return 1
|
|
477
|
+
|
|
478
|
+
if hasattr(args, "func"):
|
|
479
|
+
return args.func(args)
|
|
480
|
+
|
|
481
|
+
return 0
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
if __name__ == "__main__":
|
|
485
|
+
sys.exit(main())
|
mcp/daemon.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import logging
|
|
5
|
+
import ipaddress
|
|
6
|
+
from .session import Session
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from app.workspace import WorkspaceManager
|
|
10
|
+
|
|
11
|
+
def _resolve_log_dir() -> Path:
|
|
12
|
+
for env_key in ["DECKARD_LOG_DIR", "LOCAL_SEARCH_LOG_DIR"]:
|
|
13
|
+
val = (os.environ.get(env_key) or "").strip()
|
|
14
|
+
if val:
|
|
15
|
+
return Path(os.path.expanduser(val)).resolve()
|
|
16
|
+
return WorkspaceManager.get_global_data_dir()
|
|
17
|
+
|
|
18
|
+
def _init_logging() -> None:
|
|
19
|
+
log_dir = _resolve_log_dir()
|
|
20
|
+
handlers = [logging.StreamHandler()]
|
|
21
|
+
try:
|
|
22
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
handlers.insert(0, logging.FileHandler(log_dir / "daemon.log"))
|
|
24
|
+
except Exception:
|
|
25
|
+
# Fall back to /tmp if the default log dir is not writable.
|
|
26
|
+
try:
|
|
27
|
+
tmp_dir = Path(os.environ.get("TMPDIR", "/tmp")) / "sari"
|
|
28
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
handlers.insert(0, logging.FileHandler(tmp_dir / "daemon.log"))
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=logging.INFO,
|
|
35
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
36
|
+
handlers=handlers,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_init_logging()
|
|
40
|
+
logger = logging.getLogger("mcp-daemon")
|
|
41
|
+
|
|
42
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
43
|
+
DEFAULT_PORT = 47779
|
|
44
|
+
PID_FILE = WorkspaceManager.get_global_data_dir() / "daemon.pid"
|
|
45
|
+
|
|
46
|
+
class SariDaemon:
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self.host = os.environ.get("DECKARD_DAEMON_HOST", DEFAULT_HOST)
|
|
49
|
+
self.port = int(os.environ.get("DECKARD_DAEMON_PORT", DEFAULT_PORT))
|
|
50
|
+
self.server = None
|
|
51
|
+
|
|
52
|
+
def _write_pid(self):
|
|
53
|
+
"""Write current PID to file."""
|
|
54
|
+
try:
|
|
55
|
+
pid = os.getpid()
|
|
56
|
+
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
PID_FILE.write_text(str(pid))
|
|
58
|
+
logger.info(f"Wrote PID {pid} to {PID_FILE}")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error(f"Failed to write PID file: {e}")
|
|
61
|
+
|
|
62
|
+
def _remove_pid(self):
|
|
63
|
+
"""Remove PID file."""
|
|
64
|
+
try:
|
|
65
|
+
if PID_FILE.exists():
|
|
66
|
+
PID_FILE.unlink()
|
|
67
|
+
logger.info("Removed PID file")
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f"Failed to remove PID file: {e}")
|
|
70
|
+
|
|
71
|
+
async def start(self):
|
|
72
|
+
allow_non_loopback = os.environ.get("DECKARD_ALLOW_NON_LOOPBACK") == "1" or os.environ.get("LOCAL_SEARCH_ALLOW_NON_LOOPBACK") == "1"
|
|
73
|
+
host = (self.host or "127.0.0.1").strip()
|
|
74
|
+
try:
|
|
75
|
+
is_loopback = host.lower() == "localhost" or ipaddress.ip_address(host).is_loopback
|
|
76
|
+
except ValueError:
|
|
77
|
+
is_loopback = host.lower() == "localhost"
|
|
78
|
+
|
|
79
|
+
if (not is_loopback) and (not allow_non_loopback):
|
|
80
|
+
raise SystemExit(
|
|
81
|
+
f"sari daemon refused to start: host must be loopback only (127.0.0.1/localhost/::1). got={host}. "
|
|
82
|
+
"Set DECKARD_ALLOW_NON_LOOPBACK=1 to override (NOT recommended)."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self._write_pid()
|
|
86
|
+
|
|
87
|
+
self.server = await asyncio.start_server(
|
|
88
|
+
self.handle_client, self.host, self.port
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
addr = self.server.sockets[0].getsockname()
|
|
92
|
+
logger.info(f"Sari Daemon serving on {addr}")
|
|
93
|
+
|
|
94
|
+
async with self.server:
|
|
95
|
+
await self.server.serve_forever()
|
|
96
|
+
|
|
97
|
+
async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
98
|
+
addr = writer.get_extra_info('peername')
|
|
99
|
+
logger.info(f"Accepted connection from {addr}")
|
|
100
|
+
|
|
101
|
+
session = Session(reader, writer)
|
|
102
|
+
await session.handle_connection()
|
|
103
|
+
|
|
104
|
+
logger.info(f"Closed connection from {addr}")
|
|
105
|
+
|
|
106
|
+
def shutdown(self):
|
|
107
|
+
if self.server:
|
|
108
|
+
self.server.close()
|
|
109
|
+
|
|
110
|
+
# Shutdown all workspaces to stop indexers and close DBs
|
|
111
|
+
from .registry import Registry
|
|
112
|
+
Registry.get_instance().shutdown_all()
|
|
113
|
+
|
|
114
|
+
self._remove_pid()
|
|
115
|
+
|
|
116
|
+
async def main():
|
|
117
|
+
daemon = SariDaemon()
|
|
118
|
+
|
|
119
|
+
# Handle signals
|
|
120
|
+
loop = asyncio.get_running_loop()
|
|
121
|
+
stop = asyncio.Future()
|
|
122
|
+
|
|
123
|
+
def _handle_signal():
|
|
124
|
+
stop.set_result(None)
|
|
125
|
+
|
|
126
|
+
loop.add_signal_handler(signal.SIGTERM, _handle_signal)
|
|
127
|
+
loop.add_signal_handler(signal.SIGINT, _handle_signal)
|
|
128
|
+
|
|
129
|
+
daemon_task = asyncio.create_task(daemon.start())
|
|
130
|
+
|
|
131
|
+
logger.info("Daemon started. Press Ctrl+C to stop.")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
await stop
|
|
135
|
+
finally:
|
|
136
|
+
logger.info("Stopping daemon...")
|
|
137
|
+
daemon.shutdown()
|
|
138
|
+
# Wait for server to close? asyncio.start_server manages this in async with
|
|
139
|
+
# but we created a task.
|
|
140
|
+
# Actually server.serve_forever() runs until cancelled.
|
|
141
|
+
daemon_task.cancel()
|
|
142
|
+
try:
|
|
143
|
+
await daemon_task
|
|
144
|
+
except asyncio.CancelledError:
|
|
145
|
+
pass
|
|
146
|
+
logger.info("Daemon stopped.")
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
asyncio.run(main())
|