contextual-engine 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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""MCP tool definitions for Phase 2 docs search.
|
|
2
|
+
|
|
3
|
+
Registers three tools onto the shared FastMCP instance:
|
|
4
|
+
docs_search — hybrid BM25 + vector search over indexed docs
|
|
5
|
+
docs_get_section — retrieve exact section by heading path (5-tier resolver)
|
|
6
|
+
docs_list_files — enumerate indexed doc files with optional heading tree
|
|
7
|
+
|
|
8
|
+
Feature-flag gate: tools are registered ONLY when the env var
|
|
9
|
+
CONTEXTUAL_DOCS_ENABLED=1
|
|
10
|
+
is set. The existing Phase 1 codebase.* tools are unaffected regardless.
|
|
11
|
+
|
|
12
|
+
Import side-effects:
|
|
13
|
+
This module MUST be imported by ``contextual/mcp/tools.py`` (or server.py)
|
|
14
|
+
AFTER the shared ``mcp`` FastMCP instance is created. It imports ``mcp``
|
|
15
|
+
from the sibling module — zero circular-import risk because tools.py does
|
|
16
|
+
not import from here.
|
|
17
|
+
|
|
18
|
+
Usage in server.py / tools.py::
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
if os.getenv("CONTEXTUAL_DOCS_ENABLED", "0") == "1":
|
|
22
|
+
import contextual.mcp.docs_tools # noqa: F401 — registers tools as side-effect
|
|
23
|
+
|
|
24
|
+
Never write to stdout — this server runs on stdio MCP transport.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from dataclasses import asdict
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import structlog
|
|
34
|
+
|
|
35
|
+
# Import the shared FastMCP instance and pool helper from the existing tools module.
|
|
36
|
+
# tools.py owns the singleton _pool and _get_pool(); we reuse them directly.
|
|
37
|
+
from contextual.mcp.tools import _get_pool # noqa: PLC2701 (internal import intentional)
|
|
38
|
+
from contextual.mcp.server import mcp
|
|
39
|
+
from contextual.embedding.embedder import get_docs_embedder, get_embedder
|
|
40
|
+
from contextual.docs.retrieval import (
|
|
41
|
+
DocSearchHit,
|
|
42
|
+
DocSection,
|
|
43
|
+
DocFileInfo,
|
|
44
|
+
docs_search,
|
|
45
|
+
docs_get_section,
|
|
46
|
+
docs_list_files,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logger = structlog.get_logger(__name__)
|
|
50
|
+
|
|
51
|
+
# Confirm gate at import time so the log is visible in server startup output
|
|
52
|
+
_DOCS_ENABLED = os.getenv("CONTEXTUAL_DOCS_ENABLED", "0") == "1"
|
|
53
|
+
logger.info("Docs tools module loaded", enabled=_DOCS_ENABLED)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Serialisation helpers
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def _hit_to_dict(hit: DocSearchHit) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"chunk_id": hit.chunk_id,
|
|
63
|
+
"file_path": hit.file_path,
|
|
64
|
+
"content": hit.content,
|
|
65
|
+
"heading_path": hit.heading_path,
|
|
66
|
+
"heading_level":hit.heading_level,
|
|
67
|
+
"chunk_type": hit.chunk_type,
|
|
68
|
+
"start_line": hit.start_line,
|
|
69
|
+
"end_line": hit.end_line,
|
|
70
|
+
"score": hit.score,
|
|
71
|
+
"bm25_rank": hit.bm25_rank,
|
|
72
|
+
"vector_rank": hit.vector_rank,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _section_to_dict(sec: DocSection) -> dict:
|
|
77
|
+
d = {
|
|
78
|
+
"file_path": sec.file_path,
|
|
79
|
+
"heading_path": sec.heading_path,
|
|
80
|
+
"heading_level": sec.heading_level,
|
|
81
|
+
"content": sec.content,
|
|
82
|
+
"start_line": sec.start_line,
|
|
83
|
+
"end_line": sec.end_line,
|
|
84
|
+
}
|
|
85
|
+
if sec.subsections:
|
|
86
|
+
d["subsections"] = [_section_to_dict(s) for s in sec.subsections]
|
|
87
|
+
return d
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fileinfo_to_dict(fi: DocFileInfo) -> dict:
|
|
91
|
+
d = {"file_path": fi.file_path, "chunk_count": fi.chunk_count}
|
|
92
|
+
if fi.headings:
|
|
93
|
+
d["headings"] = fi.headings
|
|
94
|
+
return d
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Tool 1: docs_search
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@mcp.tool()
|
|
102
|
+
def docs_search_tool(
|
|
103
|
+
query: str,
|
|
104
|
+
k: int = 10,
|
|
105
|
+
source_filter: str | None = None,
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Semantically search indexed documentation and markdown files.
|
|
108
|
+
|
|
109
|
+
Performs hybrid BM25 + vector search (RRF fusion) over all docs indexed
|
|
110
|
+
by Contextual (README, ADRs, wikis, changelogs, any .md/.mdx/.rst/.txt).
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
query: Natural-language question or keyword string.
|
|
114
|
+
k: Number of results to return (1-50).
|
|
115
|
+
source_filter: Optional JSON string with filter keys:
|
|
116
|
+
repo list[str] — restrict to files under this path prefix
|
|
117
|
+
file list[str] — exact file paths to restrict to
|
|
118
|
+
heading str — LIKE pattern on heading_path
|
|
119
|
+
source_type list[str] — "docs" | "docs_code" | both
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
JSON array of search hits, each with: file_path, content, heading_path,
|
|
123
|
+
heading_level, chunk_type, start_line, end_line, score.
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
docs_search_tool("how does the file watcher work")
|
|
127
|
+
docs_search_tool("installation prerequisites", k=5)
|
|
128
|
+
docs_search_tool("ADR decisions", source_filter='{"repo": ["docs/adr"]}')
|
|
129
|
+
"""
|
|
130
|
+
if not _DOCS_ENABLED:
|
|
131
|
+
return json.dumps({"error": "Docs indexing is disabled. Set CONTEXTUAL_DOCS_ENABLED=1."})
|
|
132
|
+
|
|
133
|
+
k = max(1, min(k, 50))
|
|
134
|
+
|
|
135
|
+
parsed_filter: dict | None = None
|
|
136
|
+
if source_filter:
|
|
137
|
+
try:
|
|
138
|
+
parsed_filter = json.loads(source_filter)
|
|
139
|
+
except json.JSONDecodeError as exc:
|
|
140
|
+
return json.dumps({"error": f"Invalid source_filter JSON: {exc}"})
|
|
141
|
+
|
|
142
|
+
pool = _get_pool()
|
|
143
|
+
embedder = get_embedder()
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
with pool.reader() as conn:
|
|
147
|
+
hits = docs_search(
|
|
148
|
+
query=query,
|
|
149
|
+
conn=conn,
|
|
150
|
+
embedder=embedder,
|
|
151
|
+
k=k,
|
|
152
|
+
source_filter=parsed_filter,
|
|
153
|
+
)
|
|
154
|
+
except Exception as exc: # noqa: BLE001
|
|
155
|
+
logger.error("docs_search_tool failed", query=query, error=str(exc))
|
|
156
|
+
return json.dumps({"error": str(exc)})
|
|
157
|
+
|
|
158
|
+
logger.info("docs_search_tool", query=query[:80], k=k, hits=len(hits))
|
|
159
|
+
return json.dumps([_hit_to_dict(h) for h in hits], ensure_ascii=False, indent=2)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Tool 2: docs_get_section
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
@mcp.tool()
|
|
167
|
+
def docs_get_section_tool(
|
|
168
|
+
file: str,
|
|
169
|
+
heading_path: str,
|
|
170
|
+
include_subsections: bool = False,
|
|
171
|
+
fuzzy: bool = True,
|
|
172
|
+
) -> str:
|
|
173
|
+
"""Retrieve an exact section from an indexed document by heading path.
|
|
174
|
+
|
|
175
|
+
Uses a 5-tier resolution strategy:
|
|
176
|
+
1. Exact heading_path match
|
|
177
|
+
2. GitHub-slug match (case/punctuation insensitive)
|
|
178
|
+
3. Suffix/leaf match (just the last heading segment)
|
|
179
|
+
4. Fuzzy match via RapidFuzz WRatio ≥ 72
|
|
180
|
+
5. FTS5 content search fallback
|
|
181
|
+
|
|
182
|
+
On miss, returns a JSON error object with ``did_you_mean`` suggestions.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
file: Repo-relative file path (e.g. "docs/README.md").
|
|
186
|
+
heading_path: Section heading or breadcrumb path.
|
|
187
|
+
Accepts: "Installation > macOS > Homebrew"
|
|
188
|
+
"installation/macos/homebrew"
|
|
189
|
+
"Homebrew" (leaf-only)
|
|
190
|
+
include_subsections: If True, child sections are included in the response.
|
|
191
|
+
fuzzy: Enable tiers 3-5 of resolver (default True).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
JSON object with: file_path, heading_path, heading_level, content,
|
|
195
|
+
start_line, end_line, and optionally subsections[].
|
|
196
|
+
On failure: {"error": "heading_not_found", "did_you_mean": [...]}
|
|
197
|
+
|
|
198
|
+
Examples:
|
|
199
|
+
docs_get_section_tool("docs/README.md", "Installation > macOS")
|
|
200
|
+
docs_get_section_tool("PHASE_1.md", "Success Criteria", include_subsections=True)
|
|
201
|
+
docs_get_section_tool("docs/adr/ADR-004.md", "Decision", fuzzy=False)
|
|
202
|
+
"""
|
|
203
|
+
if not _DOCS_ENABLED:
|
|
204
|
+
return json.dumps({"error": "Docs indexing is disabled. Set CONTEXTUAL_DOCS_ENABLED=1."})
|
|
205
|
+
|
|
206
|
+
pool = _get_pool()
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
with pool.reader() as conn:
|
|
210
|
+
result = docs_get_section(
|
|
211
|
+
file=file,
|
|
212
|
+
heading_path=heading_path,
|
|
213
|
+
conn=conn,
|
|
214
|
+
include_subsections=include_subsections,
|
|
215
|
+
fuzzy=fuzzy,
|
|
216
|
+
)
|
|
217
|
+
except Exception as exc: # noqa: BLE001
|
|
218
|
+
logger.error("docs_get_section_tool failed", file=file, heading=heading_path, error=str(exc))
|
|
219
|
+
return json.dumps({"error": str(exc)})
|
|
220
|
+
|
|
221
|
+
if isinstance(result, dict):
|
|
222
|
+
# Error object from resolver
|
|
223
|
+
return json.dumps(result, ensure_ascii=False)
|
|
224
|
+
|
|
225
|
+
logger.info(
|
|
226
|
+
"docs_get_section_tool",
|
|
227
|
+
file=file,
|
|
228
|
+
heading=heading_path,
|
|
229
|
+
resolved=result.heading_path,
|
|
230
|
+
)
|
|
231
|
+
return json.dumps(_section_to_dict(result), ensure_ascii=False, indent=2)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Tool 3: docs_list_files
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
@mcp.tool()
|
|
239
|
+
def docs_list_files_tool(
|
|
240
|
+
repo: str | None = None,
|
|
241
|
+
glob: str | None = None,
|
|
242
|
+
include_headings: bool = False,
|
|
243
|
+
) -> str:
|
|
244
|
+
"""List all documentation files currently indexed by Contextual.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
repo: Optional path prefix to restrict results
|
|
248
|
+
(e.g. "docs" returns only files under docs/).
|
|
249
|
+
glob: fnmatch glob pattern on file path
|
|
250
|
+
(e.g. "docs/adr/*.md").
|
|
251
|
+
include_headings: If True, return heading_path list per file.
|
|
252
|
+
Useful to understand a file's structure before
|
|
253
|
+
calling docs_get_section.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
JSON array of file info objects with: file_path, chunk_count,
|
|
257
|
+
and optionally headings[].
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
docs_list_files_tool()
|
|
261
|
+
docs_list_files_tool(repo="docs/adr", include_headings=True)
|
|
262
|
+
docs_list_files_tool(glob="**/*.md")
|
|
263
|
+
"""
|
|
264
|
+
if not _DOCS_ENABLED:
|
|
265
|
+
return json.dumps({"error": "Docs indexing is disabled. Set CONTEXTUAL_DOCS_ENABLED=1."})
|
|
266
|
+
|
|
267
|
+
pool = _get_pool()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
with pool.reader() as conn:
|
|
271
|
+
files = docs_list_files(
|
|
272
|
+
conn=conn,
|
|
273
|
+
repo=repo,
|
|
274
|
+
glob=glob,
|
|
275
|
+
include_headings=include_headings,
|
|
276
|
+
)
|
|
277
|
+
except Exception as exc: # noqa: BLE001
|
|
278
|
+
logger.error("docs_list_files_tool failed", error=str(exc))
|
|
279
|
+
return json.dumps({"error": str(exc)})
|
|
280
|
+
|
|
281
|
+
logger.info("docs_list_files_tool", count=len(files), repo=repo, glob=glob)
|
|
282
|
+
return json.dumps(
|
|
283
|
+
[_fileinfo_to_dict(f) for f in files],
|
|
284
|
+
ensure_ascii=False,
|
|
285
|
+
indent=2,
|
|
286
|
+
)
|
contextual/mcp/server.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""FastMCP server for Contextual codebase tools.
|
|
2
|
+
|
|
3
|
+
This module initializes the FastMCP server and registers all tools.
|
|
4
|
+
Tools are defined in tools.py and registered here.
|
|
5
|
+
|
|
6
|
+
Server configuration:
|
|
7
|
+
- Name: "contextual"
|
|
8
|
+
- Transport: stdio (MCP standard)
|
|
9
|
+
- Structured logging to stderr (never stdout in stdio mode)
|
|
10
|
+
- Graceful shutdown on SIGTERM/SIGINT
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# Suppress warnings BEFORE any other imports to prevent stdout pollution
|
|
16
|
+
import warnings
|
|
17
|
+
warnings.filterwarnings('ignore')
|
|
18
|
+
|
|
19
|
+
import signal
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from fastmcp import FastMCP
|
|
24
|
+
|
|
25
|
+
from contextual.observability.logging import configure_logging, get_logger
|
|
26
|
+
|
|
27
|
+
# Configure logging IMMEDIATELY so structlog routes to stderr, not stdout.
|
|
28
|
+
# structlog's default PrintLoggerFactory writes to stdout which breaks MCP stdio.
|
|
29
|
+
configure_logging()
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
# ============================================================================
|
|
34
|
+
# SERVER INITIALIZATION
|
|
35
|
+
# ============================================================================
|
|
36
|
+
|
|
37
|
+
# Create FastMCP server instance
|
|
38
|
+
mcp = FastMCP("contextual")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ============================================================================
|
|
42
|
+
# GRACEFUL SHUTDOWN
|
|
43
|
+
# ============================================================================
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _handle_shutdown(signum: int, frame: Any) -> None:
|
|
47
|
+
"""Handle shutdown signals gracefully.
|
|
48
|
+
|
|
49
|
+
On SIGTERM/SIGINT:
|
|
50
|
+
1. Log shutdown
|
|
51
|
+
2. Close database connections
|
|
52
|
+
3. Flush logs
|
|
53
|
+
4. Exit cleanly
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
signum: Signal number.
|
|
57
|
+
frame: Current stack frame.
|
|
58
|
+
"""
|
|
59
|
+
logger.info("Shutdown signal received", signal=signum)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
# Close database pools
|
|
63
|
+
from contextual.mcp.tools import _pool
|
|
64
|
+
if _pool is not None:
|
|
65
|
+
_pool.close()
|
|
66
|
+
logger.debug("Database pool closed")
|
|
67
|
+
|
|
68
|
+
logger.info("Shutdown complete")
|
|
69
|
+
sys.exit(0)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error("Shutdown error", error=str(e))
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Register signal handlers
|
|
76
|
+
signal.signal(signal.SIGTERM, _handle_shutdown)
|
|
77
|
+
signal.signal(signal.SIGINT, _handle_shutdown)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ============================================================================
|
|
81
|
+
# TOOL REGISTRATION
|
|
82
|
+
# ============================================================================
|
|
83
|
+
|
|
84
|
+
# Import tools module to register @mcp.tool() decorated functions
|
|
85
|
+
# MUST come after mcp is created but before main() runs
|
|
86
|
+
from contextual.mcp import tools # noqa: F401, E402
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ============================================================================
|
|
90
|
+
# SERVER ENTRYPOINT
|
|
91
|
+
# ============================================================================
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> None:
|
|
95
|
+
"""Run the MCP server.
|
|
96
|
+
|
|
97
|
+
This is the main entry point called by the contextual CLI.
|
|
98
|
+
Starts the FastMCP server in stdio mode.
|
|
99
|
+
"""
|
|
100
|
+
logger.info("Starting Contextual MCP server")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Run the server (blocking)
|
|
104
|
+
mcp.run()
|
|
105
|
+
except KeyboardInterrupt:
|
|
106
|
+
logger.info("Server interrupted")
|
|
107
|
+
sys.exit(0)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error("Server error", error=str(e), exc_info=True)
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
|
|
112
|
+
import os
|
|
113
|
+
if os.getenv("CONTEXTUAL_DOCS_ENABLED", "0") == "1":
|
|
114
|
+
import contextual.mcp.docs_tools # noqa: F401
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|