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/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
+ }
@@ -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())