opencontextengine-client 0.1.0__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.
- oce_client/__init__.py +33 -0
- oce_client/cli.py +250 -0
- oce_client/context.py +478 -0
- oce_client/defaults.py +2 -0
- oce_client/filesystem.py +62 -0
- oce_client/http.py +134 -0
- oce_client/identity.py +17 -0
- oce_client/ignore.py +84 -0
- oce_client/indexer.py +265 -0
- oce_client/mcp_server.py +309 -0
- oce_client/models.py +102 -0
- oce_client/ports.py +67 -0
- oce_client/runtime.py +245 -0
- oce_client/skill/SKILL.md +136 -0
- oce_client/skill/agents/openai.yaml +4 -0
- oce_client/state.py +288 -0
- oce_client/watcher.py +57 -0
- opencontextengine_client-0.1.0.dist-info/METADATA +162 -0
- opencontextengine_client-0.1.0.dist-info/RECORD +22 -0
- opencontextengine_client-0.1.0.dist-info/WHEEL +4 -0
- opencontextengine_client-0.1.0.dist-info/entry_points.txt +3 -0
- opencontextengine_client-0.1.0.dist-info/licenses/LICENSE +202 -0
oce_client/ignore.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
from pathspec.gitignore import GitIgnoreSpec
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_PATTERNS = (
|
|
10
|
+
".git/",
|
|
11
|
+
".oce-client/",
|
|
12
|
+
"__pycache__/",
|
|
13
|
+
".pytest_cache/",
|
|
14
|
+
".mypy_cache/",
|
|
15
|
+
".ruff_cache/",
|
|
16
|
+
".venv/",
|
|
17
|
+
"venv/",
|
|
18
|
+
"node_modules/",
|
|
19
|
+
"target/",
|
|
20
|
+
"dist/",
|
|
21
|
+
"build/",
|
|
22
|
+
"coverage/",
|
|
23
|
+
".idea/",
|
|
24
|
+
".vscode/",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _RuleLayer:
|
|
29
|
+
def __init__(self, lines: Iterable[str]) -> None:
|
|
30
|
+
cleaned = []
|
|
31
|
+
for raw in lines:
|
|
32
|
+
line = raw.strip("\r\n")
|
|
33
|
+
if line and not line.startswith("#"):
|
|
34
|
+
cleaned.append(line)
|
|
35
|
+
self.patterns = list(GitIgnoreSpec.from_lines(cleaned).patterns)
|
|
36
|
+
|
|
37
|
+
def match(self, path: str, is_dir: bool) -> bool | None:
|
|
38
|
+
candidate = path.rstrip("/") + ("/" if is_dir else "")
|
|
39
|
+
result: bool | None = None
|
|
40
|
+
for pattern in self.patterns:
|
|
41
|
+
if pattern.match_file(candidate) is not None:
|
|
42
|
+
# GitWildMatchPattern.include=True means the path is ignored;
|
|
43
|
+
# a leading ! produces include=False and re-includes it.
|
|
44
|
+
result = bool(pattern.include)
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LayeredIgnoreMatcher:
|
|
49
|
+
"""Merge runtime, project, git, and built-in ignore rules by precedence."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
root: Path,
|
|
54
|
+
runtime_patterns: Iterable[str] = (),
|
|
55
|
+
*,
|
|
56
|
+
oceignore_name: str = ".oceignore",
|
|
57
|
+
gitignore_name: str = ".gitignore",
|
|
58
|
+
) -> None:
|
|
59
|
+
self.root = root
|
|
60
|
+
self._hard = _RuleLayer((".git/", ".git/**", ".oce-client/", ".oce-client/**"))
|
|
61
|
+
self._runtime = _RuleLayer(runtime_patterns)
|
|
62
|
+
self._oce = _RuleLayer(self._read_lines(root / oceignore_name))
|
|
63
|
+
self._git = _RuleLayer(self._read_lines(root / gitignore_name))
|
|
64
|
+
self._defaults = _RuleLayer(DEFAULT_PATTERNS[2:])
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _read_lines(path: Path) -> list[str]:
|
|
68
|
+
try:
|
|
69
|
+
return path.read_text(encoding="utf-8").splitlines()
|
|
70
|
+
except (FileNotFoundError, UnicodeDecodeError, OSError):
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
def ignores(self, path: str, *, is_dir: bool = False) -> bool:
|
|
74
|
+
normalized = path.replace("\\", "/")
|
|
75
|
+
while normalized.startswith("./"):
|
|
76
|
+
normalized = normalized[2:]
|
|
77
|
+
# A hard rule cannot be undone by a higher-priority negation.
|
|
78
|
+
if self._hard.match(normalized, is_dir) is True:
|
|
79
|
+
return True
|
|
80
|
+
for layer in (self._runtime, self._oce, self._git, self._defaults):
|
|
81
|
+
decision = layer.match(normalized, is_dir)
|
|
82
|
+
if decision is not None:
|
|
83
|
+
return decision
|
|
84
|
+
return False
|
oce_client/indexer.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .ignore import LayeredIgnoreMatcher
|
|
9
|
+
from .runtime import ClientRuntime, ClientSettings
|
|
10
|
+
from .watcher import WatchHandle
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorkspaceIndexer:
|
|
14
|
+
"""Own one workspace's background synchronization and readiness barrier."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
settings: ClientSettings,
|
|
19
|
+
*,
|
|
20
|
+
runtime_factory: Callable[[ClientSettings], ClientRuntime] = ClientRuntime,
|
|
21
|
+
debounce_ms: int = 500,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.settings = settings
|
|
24
|
+
self.debounce_ms = debounce_ms
|
|
25
|
+
self._runtime = runtime_factory(settings)
|
|
26
|
+
self._condition = threading.Condition()
|
|
27
|
+
self._context_lock = threading.Lock()
|
|
28
|
+
self._stop = False
|
|
29
|
+
self._started = False
|
|
30
|
+
self._initialized = False
|
|
31
|
+
self._recovery_required = False
|
|
32
|
+
self._full_pending = False
|
|
33
|
+
self._pending_paths: set[Path] = set()
|
|
34
|
+
self._requested_generation = 0
|
|
35
|
+
self._synced_generation = 0
|
|
36
|
+
self._state = "idle"
|
|
37
|
+
self._last_error: str | None = None
|
|
38
|
+
self._watch: WatchHandle | None = None
|
|
39
|
+
self._worker: threading.Thread | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def root(self) -> Path:
|
|
43
|
+
return self.settings.root.resolve()
|
|
44
|
+
|
|
45
|
+
def start(self, *, initial_sync: bool = True) -> None:
|
|
46
|
+
with self._condition:
|
|
47
|
+
if self._started:
|
|
48
|
+
if (
|
|
49
|
+
initial_sync
|
|
50
|
+
and not self._initialized
|
|
51
|
+
and self._state in {"idle", "error"}
|
|
52
|
+
and not self._full_pending
|
|
53
|
+
):
|
|
54
|
+
self._request_full_locked()
|
|
55
|
+
return
|
|
56
|
+
self._started = True
|
|
57
|
+
self._watch = WatchHandle(self.root, self.notify_changes, self.debounce_ms)
|
|
58
|
+
self._worker = threading.Thread(
|
|
59
|
+
target=self._run,
|
|
60
|
+
name=f"oce-indexer-{self.root.name}",
|
|
61
|
+
daemon=True,
|
|
62
|
+
)
|
|
63
|
+
self._worker.start()
|
|
64
|
+
if initial_sync:
|
|
65
|
+
self._request_full_locked()
|
|
66
|
+
|
|
67
|
+
def stop(self) -> None:
|
|
68
|
+
watch: WatchHandle | None
|
|
69
|
+
worker: threading.Thread | None
|
|
70
|
+
with self._condition:
|
|
71
|
+
if not self._started:
|
|
72
|
+
self._runtime.close()
|
|
73
|
+
return
|
|
74
|
+
self._stop = True
|
|
75
|
+
watch = self._watch
|
|
76
|
+
worker = self._worker
|
|
77
|
+
self._condition.notify_all()
|
|
78
|
+
if watch is not None:
|
|
79
|
+
watch.stop()
|
|
80
|
+
watch.join(2.0)
|
|
81
|
+
if worker is not None:
|
|
82
|
+
worker.join(5.0)
|
|
83
|
+
if worker.is_alive():
|
|
84
|
+
return
|
|
85
|
+
with self._context_lock:
|
|
86
|
+
self._runtime.close()
|
|
87
|
+
|
|
88
|
+
def _request_full_locked(self) -> None:
|
|
89
|
+
self._requested_generation += 1
|
|
90
|
+
self._full_pending = True
|
|
91
|
+
self._state = "indexing"
|
|
92
|
+
self._last_error = None
|
|
93
|
+
self._condition.notify_all()
|
|
94
|
+
|
|
95
|
+
def request_full_sync(self) -> None:
|
|
96
|
+
with self._condition:
|
|
97
|
+
self._request_full_locked()
|
|
98
|
+
|
|
99
|
+
def notify_changes(self, paths: set[Path]) -> None:
|
|
100
|
+
matcher = LayeredIgnoreMatcher(self.root, self.settings.runtime_patterns)
|
|
101
|
+
relevant: set[Path] = set()
|
|
102
|
+
for path in paths:
|
|
103
|
+
resolved = path.resolve()
|
|
104
|
+
try:
|
|
105
|
+
relative = resolved.relative_to(self.root).as_posix()
|
|
106
|
+
except ValueError:
|
|
107
|
+
continue
|
|
108
|
+
if resolved.name in {".gitignore", ".oceignore"} or not matcher.ignores(
|
|
109
|
+
relative,
|
|
110
|
+
is_dir=resolved.is_dir(),
|
|
111
|
+
):
|
|
112
|
+
relevant.add(resolved)
|
|
113
|
+
if not relevant:
|
|
114
|
+
return
|
|
115
|
+
with self._condition:
|
|
116
|
+
if self._stop:
|
|
117
|
+
return
|
|
118
|
+
self._requested_generation += 1
|
|
119
|
+
if not self._initialized or self._recovery_required or any(
|
|
120
|
+
path.name in {".gitignore", ".oceignore"} for path in relevant
|
|
121
|
+
):
|
|
122
|
+
self._full_pending = True
|
|
123
|
+
else:
|
|
124
|
+
self._pending_paths.update(relevant)
|
|
125
|
+
self._state = "indexing"
|
|
126
|
+
self._last_error = None
|
|
127
|
+
self._condition.notify_all()
|
|
128
|
+
|
|
129
|
+
def _next_batch(self) -> tuple[bool, set[Path], int] | None:
|
|
130
|
+
with self._condition:
|
|
131
|
+
self._condition.wait_for(
|
|
132
|
+
lambda: self._stop or self._full_pending or bool(self._pending_paths)
|
|
133
|
+
)
|
|
134
|
+
if self._stop:
|
|
135
|
+
return None
|
|
136
|
+
full = self._full_pending
|
|
137
|
+
paths = set(self._pending_paths)
|
|
138
|
+
generation = self._requested_generation
|
|
139
|
+
self._full_pending = False
|
|
140
|
+
self._pending_paths.clear()
|
|
141
|
+
self._state = "indexing"
|
|
142
|
+
return full, paths, generation
|
|
143
|
+
|
|
144
|
+
def _run(self) -> None:
|
|
145
|
+
while True:
|
|
146
|
+
batch = self._next_batch()
|
|
147
|
+
if batch is None:
|
|
148
|
+
return
|
|
149
|
+
full, paths, generation = batch
|
|
150
|
+
try:
|
|
151
|
+
with self._context_lock:
|
|
152
|
+
context = self._runtime.context()
|
|
153
|
+
if full:
|
|
154
|
+
context.sync()
|
|
155
|
+
elif paths:
|
|
156
|
+
context.sync_paths(paths)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
with self._condition:
|
|
159
|
+
self._recovery_required = True
|
|
160
|
+
if self._full_pending or self._pending_paths:
|
|
161
|
+
self._full_pending = True
|
|
162
|
+
self._pending_paths.clear()
|
|
163
|
+
self._state = "error"
|
|
164
|
+
self._last_error = str(exc)
|
|
165
|
+
self._condition.notify_all()
|
|
166
|
+
continue
|
|
167
|
+
with self._condition:
|
|
168
|
+
self._initialized = True
|
|
169
|
+
self._recovery_required = False
|
|
170
|
+
self._synced_generation = max(self._synced_generation, generation)
|
|
171
|
+
self._last_error = None
|
|
172
|
+
if self._full_pending or self._pending_paths:
|
|
173
|
+
self._state = "indexing"
|
|
174
|
+
else:
|
|
175
|
+
self._state = "ready"
|
|
176
|
+
self._condition.notify_all()
|
|
177
|
+
|
|
178
|
+
def _ready_locked(self) -> bool:
|
|
179
|
+
return (
|
|
180
|
+
self._initialized
|
|
181
|
+
and self._state == "ready"
|
|
182
|
+
and not self._full_pending
|
|
183
|
+
and not self._pending_paths
|
|
184
|
+
and self._synced_generation >= self._requested_generation
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def wait_until_ready(self, timeout: float | None) -> str:
|
|
188
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
189
|
+
with self._condition:
|
|
190
|
+
if not self._started:
|
|
191
|
+
raise RuntimeError("workspace indexer has not been started")
|
|
192
|
+
if (
|
|
193
|
+
not self._initialized
|
|
194
|
+
and self._state in {"idle", "error"}
|
|
195
|
+
and not self._full_pending
|
|
196
|
+
):
|
|
197
|
+
self._request_full_locked()
|
|
198
|
+
while not self._ready_locked():
|
|
199
|
+
if (
|
|
200
|
+
self._state == "error"
|
|
201
|
+
and not self._full_pending
|
|
202
|
+
and not self._pending_paths
|
|
203
|
+
):
|
|
204
|
+
return "error"
|
|
205
|
+
remaining = (
|
|
206
|
+
None if deadline is None else deadline - time.monotonic()
|
|
207
|
+
)
|
|
208
|
+
if remaining is not None and remaining <= 0:
|
|
209
|
+
return "indexing"
|
|
210
|
+
self._condition.wait(remaining)
|
|
211
|
+
return "ready"
|
|
212
|
+
|
|
213
|
+
def retrieve(self, query: str, timeout: float) -> dict[str, object]:
|
|
214
|
+
self.start(initial_sync=True)
|
|
215
|
+
with self._condition:
|
|
216
|
+
if (
|
|
217
|
+
self._state == "error"
|
|
218
|
+
and not self._full_pending
|
|
219
|
+
and not self._pending_paths
|
|
220
|
+
):
|
|
221
|
+
self._request_full_locked()
|
|
222
|
+
deadline = time.monotonic() + timeout
|
|
223
|
+
while True:
|
|
224
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
225
|
+
status = self.wait_until_ready(remaining)
|
|
226
|
+
if status != "ready":
|
|
227
|
+
with self._condition:
|
|
228
|
+
payload: dict[str, object] = {
|
|
229
|
+
"status": status,
|
|
230
|
+
"workspace_folder": str(self.root),
|
|
231
|
+
}
|
|
232
|
+
if status == "error":
|
|
233
|
+
payload["error"] = self._last_error or "workspace synchronization failed"
|
|
234
|
+
else:
|
|
235
|
+
payload["message"] = "Workspace indexing is still in progress; retry shortly."
|
|
236
|
+
return payload
|
|
237
|
+
|
|
238
|
+
with self._context_lock:
|
|
239
|
+
with self._condition:
|
|
240
|
+
if not self._ready_locked():
|
|
241
|
+
continue
|
|
242
|
+
try:
|
|
243
|
+
result = self._runtime.context().retrieve(query)
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
return {
|
|
246
|
+
"status": "error",
|
|
247
|
+
"workspace_folder": str(self.root),
|
|
248
|
+
"error": str(exc),
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
"status": "ready",
|
|
252
|
+
"workspace_folder": str(self.root),
|
|
253
|
+
"formatted_retrieval": result.formatted_retrieval,
|
|
254
|
+
"elapsed_ms": result.elapsed_ms,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
def status(self) -> dict[str, object]:
|
|
258
|
+
with self._condition:
|
|
259
|
+
return {
|
|
260
|
+
"status": self._state,
|
|
261
|
+
"workspace_folder": str(self.root),
|
|
262
|
+
"requested_generation": self._requested_generation,
|
|
263
|
+
"synced_generation": self._synced_generation,
|
|
264
|
+
"error": self._last_error,
|
|
265
|
+
}
|
oce_client/mcp_server.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
from .indexer import WorkspaceIndexer
|
|
14
|
+
from .runtime import (
|
|
15
|
+
ClientConfigurationError,
|
|
16
|
+
ClientRuntime,
|
|
17
|
+
ClientSettings,
|
|
18
|
+
McpConfiguration,
|
|
19
|
+
iter_runtime_patterns,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
TOOL_DESCRIPTION = """This tool is Open Context Engine(oce), Open source codebase context engine. It:
|
|
24
|
+
1. Takes in a natural language description of the code you are looking for;
|
|
25
|
+
2. Uses a proprietary retrieval/embedding model suite that produces the highest-quality recall of relevant code snippets from across the codebase;
|
|
26
|
+
3. Maintains a real-time index of the codebase, so the results are always up-to-date and reflects the current state of the codebase;
|
|
27
|
+
4. Can retrieve across different programming languages;
|
|
28
|
+
5. Only reflects the current state of the codebase on the disk, and has no information on version control or code history."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _require_sdk() -> Any:
|
|
32
|
+
try:
|
|
33
|
+
from mcp.server.fastmcp import FastMCP
|
|
34
|
+
except ImportError as exc:
|
|
35
|
+
raise ClientConfigurationError(
|
|
36
|
+
"MCP support is not installed; install opencontextengine-client with the 'mcp' extra"
|
|
37
|
+
) from exc
|
|
38
|
+
return FastMCP
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def add_mcp_arguments(parser: argparse.ArgumentParser) -> None:
|
|
42
|
+
"""Add the MCP server launch options to the standalone entry point."""
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--workspace",
|
|
45
|
+
action="append",
|
|
46
|
+
default=argparse.SUPPRESS,
|
|
47
|
+
metavar="PATH",
|
|
48
|
+
help="allowed workspace folder; repeat for multiple workspaces",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--api-url",
|
|
52
|
+
default=argparse.SUPPRESS,
|
|
53
|
+
help="OCE API URL (default: OCE_API_URL or the local default)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--state-path",
|
|
57
|
+
default=argparse.SUPPRESS,
|
|
58
|
+
help="SQLite state file; only valid with one workspace",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--state-dir",
|
|
62
|
+
default=argparse.SUPPRESS,
|
|
63
|
+
help="directory for per-workspace SQLite state (OCE_STATE_DIR)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--ignore",
|
|
67
|
+
action="append",
|
|
68
|
+
default=argparse.SUPPRESS,
|
|
69
|
+
metavar="PATTERN",
|
|
70
|
+
help="runtime ignore pattern; repeat or comma-separate (OCE_IGNORE)",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--debounce-ms",
|
|
74
|
+
type=int,
|
|
75
|
+
default=argparse.SUPPRESS,
|
|
76
|
+
help="filesystem watcher debounce interval",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--initial-sync",
|
|
80
|
+
choices=("background", "blocking", "off"),
|
|
81
|
+
default=argparse.SUPPRESS,
|
|
82
|
+
help="initial workspace synchronization strategy",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--ready-timeout",
|
|
86
|
+
type=float,
|
|
87
|
+
default=argparse.SUPPRESS,
|
|
88
|
+
help="seconds a retrieval waits for the latest index generation",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--log-level",
|
|
92
|
+
choices=("debug", "info", "warning", "error", "critical"),
|
|
93
|
+
default=argparse.SUPPRESS,
|
|
94
|
+
help="MCP server log level",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def mcp_configuration_from_args(args: argparse.Namespace) -> McpConfiguration:
|
|
99
|
+
values = getattr(args, "workspace", None)
|
|
100
|
+
return McpConfiguration.from_environment(
|
|
101
|
+
workspace_roots=tuple(values) if values else None,
|
|
102
|
+
api_url=getattr(args, "api_url", None),
|
|
103
|
+
state_path=getattr(args, "state_path", None),
|
|
104
|
+
state_dir=getattr(args, "state_dir", None),
|
|
105
|
+
runtime_patterns=(
|
|
106
|
+
iter_runtime_patterns(args.ignore)
|
|
107
|
+
if getattr(args, "ignore", None) is not None
|
|
108
|
+
else None
|
|
109
|
+
),
|
|
110
|
+
debounce_ms=getattr(args, "debounce_ms", None),
|
|
111
|
+
initial_sync=getattr(args, "initial_sync", None),
|
|
112
|
+
ready_timeout=getattr(args, "ready_timeout", None),
|
|
113
|
+
log_level=getattr(args, "log_level", None),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_server(
|
|
118
|
+
settings: ClientSettings,
|
|
119
|
+
*,
|
|
120
|
+
workspace_roots: Sequence[Path] | None = None,
|
|
121
|
+
state_dir: Path | None = None,
|
|
122
|
+
debounce_ms: int = 500,
|
|
123
|
+
initial_sync: str = "background",
|
|
124
|
+
ready_timeout: float = 3.0,
|
|
125
|
+
log_level: str = "WARNING",
|
|
126
|
+
runtime_factory: Callable[[ClientSettings], ClientRuntime] = ClientRuntime,
|
|
127
|
+
) -> Any:
|
|
128
|
+
if debounce_ms < 0:
|
|
129
|
+
raise ClientConfigurationError("debounce-ms must not be negative")
|
|
130
|
+
if ready_timeout < 0:
|
|
131
|
+
raise ClientConfigurationError("ready-timeout must not be negative")
|
|
132
|
+
if initial_sync not in {"background", "blocking", "off"}:
|
|
133
|
+
raise ClientConfigurationError(f"unknown initial sync mode: {initial_sync}")
|
|
134
|
+
|
|
135
|
+
roots = tuple(
|
|
136
|
+
dict.fromkeys(
|
|
137
|
+
root.expanduser().resolve()
|
|
138
|
+
for root in (workspace_roots or (settings.root,))
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
if not roots:
|
|
142
|
+
raise ClientConfigurationError("at least one workspace is required")
|
|
143
|
+
for root in roots:
|
|
144
|
+
if not root.is_dir():
|
|
145
|
+
raise ClientConfigurationError(f"workspace is not a directory: {root}")
|
|
146
|
+
|
|
147
|
+
FastMCP = _require_sdk()
|
|
148
|
+
indexers: dict[Path, WorkspaceIndexer] = {}
|
|
149
|
+
for root in roots:
|
|
150
|
+
if state_dir is not None:
|
|
151
|
+
state_path = _state_path(state_dir, root)
|
|
152
|
+
elif root == settings.root.resolve():
|
|
153
|
+
state_path = settings.state_path
|
|
154
|
+
else:
|
|
155
|
+
state_path = None
|
|
156
|
+
root_settings = ClientSettings(
|
|
157
|
+
root=root,
|
|
158
|
+
api_url=settings.api_url,
|
|
159
|
+
api_key=settings.api_key,
|
|
160
|
+
state_path=state_path,
|
|
161
|
+
runtime_patterns=settings.runtime_patterns,
|
|
162
|
+
)
|
|
163
|
+
indexers[root] = WorkspaceIndexer(
|
|
164
|
+
root_settings,
|
|
165
|
+
runtime_factory=runtime_factory,
|
|
166
|
+
debounce_ms=debounce_ms,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def indexer_for(workspace_folder: str | None) -> WorkspaceIndexer:
|
|
170
|
+
if workspace_folder is None:
|
|
171
|
+
if len(indexers) != 1:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
"workspace_folder is required when multiple workspaces are configured"
|
|
174
|
+
)
|
|
175
|
+
return next(iter(indexers.values()))
|
|
176
|
+
if not workspace_folder.strip():
|
|
177
|
+
raise ValueError("workspace_folder must not be empty")
|
|
178
|
+
requested = Path(workspace_folder).expanduser().resolve()
|
|
179
|
+
indexer = indexers.get(requested)
|
|
180
|
+
if indexer is None:
|
|
181
|
+
allowed = ", ".join(str(root) for root in roots)
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"workspace_folder is not configured: {requested}; allowed: {allowed}"
|
|
184
|
+
)
|
|
185
|
+
return indexer
|
|
186
|
+
|
|
187
|
+
@asynccontextmanager
|
|
188
|
+
async def lifespan(_server: Any):
|
|
189
|
+
try:
|
|
190
|
+
if initial_sync != "off":
|
|
191
|
+
for indexer in indexers.values():
|
|
192
|
+
indexer.start(initial_sync=True)
|
|
193
|
+
if initial_sync == "blocking":
|
|
194
|
+
for indexer in indexers.values():
|
|
195
|
+
status = indexer.wait_until_ready(None)
|
|
196
|
+
if status == "error":
|
|
197
|
+
detail = indexer.status().get("error")
|
|
198
|
+
raise ClientConfigurationError(
|
|
199
|
+
f"initial workspace synchronization failed: {detail}"
|
|
200
|
+
)
|
|
201
|
+
yield
|
|
202
|
+
finally:
|
|
203
|
+
for indexer in indexers.values():
|
|
204
|
+
indexer.stop()
|
|
205
|
+
|
|
206
|
+
server = FastMCP("oce-client", lifespan=lifespan, log_level=log_level.upper())
|
|
207
|
+
|
|
208
|
+
async def codebase_retrieval(
|
|
209
|
+
information_request: str,
|
|
210
|
+
workspace_folder: str | None = None,
|
|
211
|
+
) -> dict[str, object]:
|
|
212
|
+
"""Retrieve code context after the background index reaches the latest change."""
|
|
213
|
+
if not information_request.strip():
|
|
214
|
+
raise ValueError("information_request must not be empty")
|
|
215
|
+
return await asyncio.to_thread(
|
|
216
|
+
indexer_for(workspace_folder).retrieve,
|
|
217
|
+
information_request,
|
|
218
|
+
ready_timeout,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# FastMCP derives JSON Schema descriptions from Pydantic Field metadata. Keep
|
|
222
|
+
# pydantic behind the optional MCP extra so the base CLI remains dependency-free.
|
|
223
|
+
from pydantic import Field
|
|
224
|
+
|
|
225
|
+
codebase_retrieval.__annotations__ = {
|
|
226
|
+
"information_request": Annotated[
|
|
227
|
+
str,
|
|
228
|
+
Field(description="A description of the information you need."),
|
|
229
|
+
],
|
|
230
|
+
"workspace_folder": Annotated[
|
|
231
|
+
str,
|
|
232
|
+
Field(
|
|
233
|
+
description=(
|
|
234
|
+
"Path to the workspace folder to search. Required when multiple "
|
|
235
|
+
"workspace folders are open. Use the folder paths shown in your "
|
|
236
|
+
"system prompt."
|
|
237
|
+
)
|
|
238
|
+
),
|
|
239
|
+
],
|
|
240
|
+
"return": dict[str, object],
|
|
241
|
+
}
|
|
242
|
+
server.tool(name="codebase-retrieval", description=TOOL_DESCRIPTION)(
|
|
243
|
+
codebase_retrieval
|
|
244
|
+
)
|
|
245
|
+
server._oce_indexers = indexers
|
|
246
|
+
return server
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _state_path(state_dir: Path, root: Path) -> Path:
|
|
250
|
+
identity = os.path.normcase(str(root.resolve())).encode("utf-8")
|
|
251
|
+
name = hashlib.sha256(identity).hexdigest()[:16]
|
|
252
|
+
return state_dir.expanduser().resolve() / f"{name}.sqlite3"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def run_mcp(
|
|
256
|
+
settings: ClientSettings,
|
|
257
|
+
*,
|
|
258
|
+
workspace_roots: Sequence[Path] | None = None,
|
|
259
|
+
state_dir: Path | None = None,
|
|
260
|
+
debounce_ms: int = 500,
|
|
261
|
+
initial_sync: str = "background",
|
|
262
|
+
ready_timeout: float = 3.0,
|
|
263
|
+
log_level: str = "WARNING",
|
|
264
|
+
) -> None:
|
|
265
|
+
server = create_server(
|
|
266
|
+
settings,
|
|
267
|
+
workspace_roots=workspace_roots,
|
|
268
|
+
state_dir=state_dir,
|
|
269
|
+
debounce_ms=debounce_ms,
|
|
270
|
+
initial_sync=initial_sync,
|
|
271
|
+
ready_timeout=ready_timeout,
|
|
272
|
+
log_level=log_level,
|
|
273
|
+
)
|
|
274
|
+
server.run(transport="stdio")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def run_mcp_configuration(configuration: McpConfiguration) -> None:
|
|
278
|
+
run_mcp(
|
|
279
|
+
configuration.client,
|
|
280
|
+
workspace_roots=configuration.workspace_roots,
|
|
281
|
+
state_dir=configuration.state_dir,
|
|
282
|
+
debounce_ms=configuration.debounce_ms,
|
|
283
|
+
initial_sync=configuration.initial_sync,
|
|
284
|
+
ready_timeout=configuration.ready_timeout,
|
|
285
|
+
log_level=configuration.log_level,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
290
|
+
parser = argparse.ArgumentParser(
|
|
291
|
+
prog="oce-client-mcp",
|
|
292
|
+
description="Run the OpenContextEngine MCP server over stdio.",
|
|
293
|
+
)
|
|
294
|
+
add_mcp_arguments(parser)
|
|
295
|
+
return parser
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
299
|
+
try:
|
|
300
|
+
args = build_parser().parse_args(argv)
|
|
301
|
+
run_mcp_configuration(mcp_configuration_from_args(args))
|
|
302
|
+
except (ClientConfigurationError, OSError, ValueError) as exc:
|
|
303
|
+
print(f"oce-client-mcp: {exc}", file=sys.stderr)
|
|
304
|
+
return 1
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
raise SystemExit(main())
|