cfabric-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfabric-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Context Fabric corpus operations
5
+ Author: Cody Kingham
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: context-fabric>=0.2.0
14
+ Requires-Dist: mcp<2,>=1.0
15
+ Requires-Dist: uvicorn>=0.32.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7.0; extra == "dev"
18
+ Requires-Dist: pytest-asyncio; extra == "dev"
19
+
20
+ # Context-Fabric MCP Server
21
+
22
+ Query Context-Fabric corpora via MCP (Model Context Protocol).
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install cfabric-mcp
28
+ ```
29
+
30
+ ## Transports
31
+
32
+ The server supports three transport modes:
33
+
34
+ | Transport | Flag | Use Case |
35
+ |-----------|------|----------|
36
+ | stdio | (default) | Claude Desktop, local MCP clients |
37
+ | SSE | `--sse PORT` | Cursor, remote MCP clients |
38
+ | Streamable HTTP | `--http PORT` | Production deployments |
39
+
40
+ ## Usage
41
+
42
+ ```bash
43
+ # stdio transport (Claude Desktop)
44
+ cfabric-mcp --corpus /path/to/bhsa
45
+
46
+ # SSE transport (Cursor, remote clients)
47
+ cfabric-mcp --corpus /path/to/bhsa --sse 8000
48
+
49
+ # Streamable HTTP transport (production)
50
+ cfabric-mcp --corpus /path/to/bhsa --http 8000
51
+ ```
52
+
53
+ ### Options
54
+
55
+ ```bash
56
+ --corpus PATH # Corpus to load (required, repeatable)
57
+ --corpus name=PATH # Named corpus
58
+ --sse PORT # SSE transport on port (endpoint: /sse)
59
+ --http PORT # Streamable HTTP transport on port (endpoint: /mcp)
60
+ --host HOST # Host to bind (default: 0.0.0.0)
61
+ --features "sp lex" # Load only specific features
62
+ --verbose # Debug logging
63
+ ```
64
+
65
+ ## Tools
66
+
67
+ The server exposes 10 MCP tools organized in layers for efficient discovery.
68
+
69
+ > **Note:** A usage guide is automatically provided to clients via the MCP `instructions` field at connection time.
70
+
71
+ ### Discovery Tools
72
+
73
+ | Tool | Description |
74
+ |------|-------------|
75
+ | `list_corpora` | List loaded corpora |
76
+ | `describe_corpus` | Get corpus structure (node types, sections) |
77
+ | `list_features` | Browse features with optional node_type filter |
78
+ | `describe_feature` | Get feature details with sample values |
79
+ | `get_text_formats` | Get text encoding samples (cached) |
80
+
81
+ ### Search Tools
82
+
83
+ | Tool | Description |
84
+ |------|-------------|
85
+ | `search` | Pattern search (results/count/statistics/passages) |
86
+ | `search_continue` | Paginate search results |
87
+ | `search_syntax_guide` | Search syntax docs (section-based) |
88
+
89
+ ### Data Access Tools
90
+
91
+ | Tool | Description |
92
+ |------|-------------|
93
+ | `get_passages` | Get text by section references |
94
+ | `get_node_features` | Get feature values for nodes |
95
+
96
+ ## Discovery Flow
97
+
98
+ The tools are designed for hierarchical, iterative discovery:
99
+
100
+ ```
101
+ describe_corpus()
102
+ │
103
+ ├─► list_features() → Browse all features
104
+ │ └─► describe_feature("sp") → Full details + samples
105
+ │
106
+ ├─► list_features(node_types=["word"]) → Filter by node type
107
+ │ └─► describe_feature(...)
108
+ │
109
+ └─► get_text_formats() → When encoding matters
110
+
111
+ ↓
112
+ search() → Execute queries
113
+ ```
114
+
115
+ **Typical workflow:**
116
+ 1. `describe_corpus()` - Get structure overview
117
+ 2. `list_features()` or `list_features(node_types=["word"])` - Browse available features
118
+ 3. `describe_feature("sp")` - Deep dive into specific feature with sample values
119
+ 4. `search_syntax_guide()` - Get search syntax help (section-based)
120
+ 5. `search()` - Execute search queries
121
+
122
+ ### Search Return Types
123
+
124
+ | Type | Description |
125
+ |------|-------------|
126
+ | `results` | Paginated node info with cursor (default) |
127
+ | `count` | Total count only |
128
+ | `statistics` | Feature value distributions |
129
+ | `passages` | Formatted text passages |
130
+
131
+ ## Client Configuration
132
+
133
+ ### Claude Desktop (stdio)
134
+
135
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
136
+
137
+ ```json
138
+ {
139
+ "mcpServers": {
140
+ "context-fabric": {
141
+ "command": "/path/to/venv/bin/cfabric-mcp",
142
+ "args": ["--corpus", "/path/to/bhsa"]
143
+ }
144
+ }
145
+ }
146
+ ```
147
+
148
+ ### Cursor (SSE)
149
+
150
+ Start server:
151
+ ```bash
152
+ cfabric-mcp --corpus /path/to/bhsa --sse 8000
153
+ ```
154
+
155
+ Configure Cursor to connect to: `http://localhost:8000/sse`
156
+
157
+ ### Custom MCP Clients
158
+
159
+ For SSE transport, connect to `http://host:port/sse`
160
+ For Streamable HTTP, connect to `http://host:port/mcp`
161
+
162
+ ## License
163
+
164
+ MIT
@@ -0,0 +1,145 @@
1
+ # Context-Fabric MCP Server
2
+
3
+ Query Context-Fabric corpora via MCP (Model Context Protocol).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install cfabric-mcp
9
+ ```
10
+
11
+ ## Transports
12
+
13
+ The server supports three transport modes:
14
+
15
+ | Transport | Flag | Use Case |
16
+ |-----------|------|----------|
17
+ | stdio | (default) | Claude Desktop, local MCP clients |
18
+ | SSE | `--sse PORT` | Cursor, remote MCP clients |
19
+ | Streamable HTTP | `--http PORT` | Production deployments |
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ # stdio transport (Claude Desktop)
25
+ cfabric-mcp --corpus /path/to/bhsa
26
+
27
+ # SSE transport (Cursor, remote clients)
28
+ cfabric-mcp --corpus /path/to/bhsa --sse 8000
29
+
30
+ # Streamable HTTP transport (production)
31
+ cfabric-mcp --corpus /path/to/bhsa --http 8000
32
+ ```
33
+
34
+ ### Options
35
+
36
+ ```bash
37
+ --corpus PATH # Corpus to load (required, repeatable)
38
+ --corpus name=PATH # Named corpus
39
+ --sse PORT # SSE transport on port (endpoint: /sse)
40
+ --http PORT # Streamable HTTP transport on port (endpoint: /mcp)
41
+ --host HOST # Host to bind (default: 0.0.0.0)
42
+ --features "sp lex" # Load only specific features
43
+ --verbose # Debug logging
44
+ ```
45
+
46
+ ## Tools
47
+
48
+ The server exposes 10 MCP tools organized in layers for efficient discovery.
49
+
50
+ > **Note:** A usage guide is automatically provided to clients via the MCP `instructions` field at connection time.
51
+
52
+ ### Discovery Tools
53
+
54
+ | Tool | Description |
55
+ |------|-------------|
56
+ | `list_corpora` | List loaded corpora |
57
+ | `describe_corpus` | Get corpus structure (node types, sections) |
58
+ | `list_features` | Browse features with optional node_type filter |
59
+ | `describe_feature` | Get feature details with sample values |
60
+ | `get_text_formats` | Get text encoding samples (cached) |
61
+
62
+ ### Search Tools
63
+
64
+ | Tool | Description |
65
+ |------|-------------|
66
+ | `search` | Pattern search (results/count/statistics/passages) |
67
+ | `search_continue` | Paginate search results |
68
+ | `search_syntax_guide` | Search syntax docs (section-based) |
69
+
70
+ ### Data Access Tools
71
+
72
+ | Tool | Description |
73
+ |------|-------------|
74
+ | `get_passages` | Get text by section references |
75
+ | `get_node_features` | Get feature values for nodes |
76
+
77
+ ## Discovery Flow
78
+
79
+ The tools are designed for hierarchical, iterative discovery:
80
+
81
+ ```
82
+ describe_corpus()
83
+ │
84
+ ├─► list_features() → Browse all features
85
+ │ └─► describe_feature("sp") → Full details + samples
86
+ │
87
+ ├─► list_features(node_types=["word"]) → Filter by node type
88
+ │ └─► describe_feature(...)
89
+ │
90
+ └─► get_text_formats() → When encoding matters
91
+
92
+ ↓
93
+ search() → Execute queries
94
+ ```
95
+
96
+ **Typical workflow:**
97
+ 1. `describe_corpus()` - Get structure overview
98
+ 2. `list_features()` or `list_features(node_types=["word"])` - Browse available features
99
+ 3. `describe_feature("sp")` - Deep dive into specific feature with sample values
100
+ 4. `search_syntax_guide()` - Get search syntax help (section-based)
101
+ 5. `search()` - Execute search queries
102
+
103
+ ### Search Return Types
104
+
105
+ | Type | Description |
106
+ |------|-------------|
107
+ | `results` | Paginated node info with cursor (default) |
108
+ | `count` | Total count only |
109
+ | `statistics` | Feature value distributions |
110
+ | `passages` | Formatted text passages |
111
+
112
+ ## Client Configuration
113
+
114
+ ### Claude Desktop (stdio)
115
+
116
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
117
+
118
+ ```json
119
+ {
120
+ "mcpServers": {
121
+ "context-fabric": {
122
+ "command": "/path/to/venv/bin/cfabric-mcp",
123
+ "args": ["--corpus", "/path/to/bhsa"]
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ ### Cursor (SSE)
130
+
131
+ Start server:
132
+ ```bash
133
+ cfabric-mcp --corpus /path/to/bhsa --sse 8000
134
+ ```
135
+
136
+ Configure Cursor to connect to: `http://localhost:8000/sse`
137
+
138
+ ### Custom MCP Clients
139
+
140
+ For SSE transport, connect to `http://host:port/sse`
141
+ For Streamable HTTP, connect to `http://host:port/mcp`
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,38 @@
1
+ """Context-Fabric MCP Server.
2
+
3
+ This package provides an MCP (Model Context Protocol) server that exposes
4
+ Context-Fabric corpus operations to AI agents.
5
+
6
+ Usage:
7
+ # Run as CLI
8
+ cfabric-mcp
9
+
10
+ # Or import and run programmatically
11
+ from cfabric_mcp import mcp
12
+ mcp.run(transport="stdio")
13
+ """
14
+
15
+ import logging
16
+ import sys
17
+
18
+ # Configure logging for MCP server
19
+ # Use stderr since stdout is reserved for MCP protocol messages
20
+ _handler = logging.StreamHandler(sys.stderr)
21
+ _handler.setFormatter(
22
+ logging.Formatter(
23
+ fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
24
+ datefmt="%Y-%m-%d %H:%M:%S",
25
+ )
26
+ )
27
+
28
+ # Create package logger
29
+ logger = logging.getLogger("cfabric_mcp")
30
+ logger.addHandler(_handler)
31
+ logger.setLevel(logging.INFO)
32
+
33
+ from cfabric_mcp.server import mcp, main
34
+ from cfabric_mcp.corpus_manager import corpus_manager, CorpusManager
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = ["mcp", "main", "corpus_manager", "CorpusManager", "logger", "__version__"]
@@ -0,0 +1,298 @@
1
+ """Search result caching for Context-Fabric MCP server.
2
+
3
+ Caches search results by (corpus, template) to avoid re-running expensive queries.
4
+ All return_types (results, count, statistics, passages) can draw from the same cache.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Callable
15
+
16
+ logger = logging.getLogger("cfabric_mcp.cache")
17
+
18
+
19
+ @dataclass
20
+ class CachedSearchResult:
21
+ """A cached search result.
22
+
23
+ Attributes:
24
+ cursor_id: UUID for pagination/lookup
25
+ corpus: The corpus name
26
+ template: The search template
27
+ results: Raw search results (list of node ID tuples)
28
+ created_at: Unix timestamp when cached
29
+ expires_at: Unix timestamp when cache entry expires
30
+ last_accessed: Unix timestamp of last access (for LRU)
31
+ """
32
+
33
+ cursor_id: str
34
+ corpus: str
35
+ template: str
36
+ results: list[tuple[int, ...]]
37
+ created_at: float
38
+ expires_at: float
39
+ last_accessed: float = field(default_factory=time.time)
40
+
41
+ @property
42
+ def is_expired(self) -> bool:
43
+ """Check if this cache entry has expired."""
44
+ return time.time() > self.expires_at
45
+
46
+ def touch(self) -> None:
47
+ """Update last accessed time."""
48
+ self.last_accessed = time.time()
49
+
50
+
51
+ class SearchCache:
52
+ """In-memory cache for search results.
53
+
54
+ Caches by (corpus, template) key. Different return_types and parameters
55
+ can reuse the same cached results.
56
+
57
+ Thread-safe for concurrent access.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ default_ttl: int = 300, # 5 minutes
63
+ max_entries: int = 100,
64
+ max_results_per_entry: int = 10000,
65
+ ) -> None:
66
+ """Initialize the search cache.
67
+
68
+ Args:
69
+ default_ttl: Default time-to-live in seconds
70
+ max_entries: Maximum number of cached searches
71
+ max_results_per_entry: Maximum results to cache per search
72
+ """
73
+ self.default_ttl = default_ttl
74
+ self.max_entries = max_entries
75
+ self.max_results_per_entry = max_results_per_entry
76
+
77
+ # Cache storage: key -> CachedSearchResult
78
+ self._cache: dict[tuple[str, str], CachedSearchResult] = {}
79
+
80
+ # Also index by cursor_id for pagination
81
+ self._by_cursor: dict[str, CachedSearchResult] = {}
82
+
83
+ # Lock for thread safety
84
+ self._lock = threading.RLock()
85
+
86
+ def get_or_execute(
87
+ self,
88
+ corpus: str,
89
+ template: str,
90
+ search_fn: Callable[[], list[tuple[int, ...]]],
91
+ ttl: int | None = None,
92
+ ) -> CachedSearchResult:
93
+ """Get cached result or execute search and cache it.
94
+
95
+ Args:
96
+ corpus: Corpus name
97
+ template: Search template
98
+ search_fn: Function to execute search if not cached
99
+ ttl: Optional TTL override
100
+
101
+ Returns:
102
+ CachedSearchResult with the search results
103
+ """
104
+ key = (corpus, template.strip())
105
+ ttl = ttl or self.default_ttl
106
+
107
+ with self._lock:
108
+ # Check cache
109
+ cached = self._cache.get(key)
110
+ if cached and not cached.is_expired:
111
+ logger.debug(
112
+ "Cache hit for template: %s...",
113
+ template[:50].replace("\n", " ")
114
+ )
115
+ cached.touch()
116
+ return cached
117
+
118
+ # Cache miss or expired - execute search
119
+ logger.debug(
120
+ "Cache miss for template: %s...",
121
+ template[:50].replace("\n", " ")
122
+ )
123
+
124
+ # Run the search
125
+ results = search_fn()
126
+
127
+ # Sort by node-tuples for consistent ordering
128
+ results = sorted(results)
129
+
130
+ # Limit results to max_results_per_entry
131
+ if len(results) > self.max_results_per_entry:
132
+ logger.warning(
133
+ "Truncating cache entry from %d to %d results",
134
+ len(results),
135
+ self.max_results_per_entry,
136
+ )
137
+ results = results[: self.max_results_per_entry]
138
+
139
+ # Create cache entry
140
+ now = time.time()
141
+ cursor_id = str(uuid.uuid4())
142
+ entry = CachedSearchResult(
143
+ cursor_id=cursor_id,
144
+ corpus=corpus,
145
+ template=template,
146
+ results=results,
147
+ created_at=now,
148
+ expires_at=now + ttl,
149
+ last_accessed=now,
150
+ )
151
+
152
+ # Evict old entries if needed
153
+ self._evict_if_needed()
154
+
155
+ # Store in cache
156
+ self._cache[key] = entry
157
+ self._by_cursor[cursor_id] = entry
158
+
159
+ return entry
160
+
161
+ def get_by_cursor(self, cursor_id: str) -> CachedSearchResult | None:
162
+ """Get cached result by cursor ID.
163
+
164
+ Args:
165
+ cursor_id: The cursor ID from a previous search
166
+
167
+ Returns:
168
+ CachedSearchResult if found and not expired, None otherwise
169
+ """
170
+ with self._lock:
171
+ entry = self._by_cursor.get(cursor_id)
172
+ if entry and not entry.is_expired:
173
+ entry.touch()
174
+ return entry
175
+ return None
176
+
177
+ def get_page(
178
+ self,
179
+ cursor_id: str,
180
+ offset: int,
181
+ limit: int,
182
+ ) -> tuple[list[tuple[int, ...]], bool, int] | None:
183
+ """Get a page of results from a cached search.
184
+
185
+ Args:
186
+ cursor_id: The cursor ID
187
+ offset: Number of results to skip
188
+ limit: Maximum results to return
189
+
190
+ Returns:
191
+ Tuple of (results, has_more, total_count) or None if not found
192
+ """
193
+ entry = self.get_by_cursor(cursor_id)
194
+ if not entry:
195
+ return None
196
+
197
+ total = len(entry.results)
198
+ page = entry.results[offset : offset + limit]
199
+ has_more = offset + limit < total
200
+
201
+ return page, has_more, total
202
+
203
+ def _evict_if_needed(self) -> None:
204
+ """Evict entries if cache is at capacity.
205
+
206
+ Uses LRU eviction strategy.
207
+ """
208
+ # First, remove expired entries
209
+ self.cleanup_expired()
210
+
211
+ # If still at capacity, evict LRU entries
212
+ while len(self._cache) >= self.max_entries:
213
+ # Find least recently used entry
214
+ lru_key = None
215
+ lru_time = float("inf")
216
+
217
+ for key, entry in self._cache.items():
218
+ if entry.last_accessed < lru_time:
219
+ lru_time = entry.last_accessed
220
+ lru_key = key
221
+
222
+ if lru_key:
223
+ entry = self._cache.pop(lru_key)
224
+ self._by_cursor.pop(entry.cursor_id, None)
225
+ logger.debug("Evicted LRU cache entry: %s", lru_key[1][:30])
226
+
227
+ def cleanup_expired(self) -> int:
228
+ """Remove all expired cache entries.
229
+
230
+ Returns:
231
+ Number of entries removed
232
+ """
233
+ with self._lock:
234
+ expired_keys = [
235
+ key for key, entry in self._cache.items() if entry.is_expired
236
+ ]
237
+
238
+ for key in expired_keys:
239
+ entry = self._cache.pop(key)
240
+ self._by_cursor.pop(entry.cursor_id, None)
241
+
242
+ if expired_keys:
243
+ logger.debug("Cleaned up %d expired cache entries", len(expired_keys))
244
+
245
+ return len(expired_keys)
246
+
247
+ def clear(self) -> None:
248
+ """Clear all cache entries."""
249
+ with self._lock:
250
+ self._cache.clear()
251
+ self._by_cursor.clear()
252
+ logger.debug("Cache cleared")
253
+
254
+ def stats(self) -> dict[str, Any]:
255
+ """Get cache statistics.
256
+
257
+ Returns:
258
+ Dictionary with cache statistics
259
+ """
260
+ with self._lock:
261
+ now = time.time()
262
+ entries = list(self._cache.values())
263
+
264
+ return {
265
+ "total_entries": len(entries),
266
+ "max_entries": self.max_entries,
267
+ "expired_entries": sum(1 for e in entries if e.is_expired),
268
+ "total_results_cached": sum(len(e.results) for e in entries),
269
+ "oldest_entry_age": (
270
+ max(now - e.created_at for e in entries) if entries else 0
271
+ ),
272
+ }
273
+
274
+
275
+ # Global cache instance
276
+ _cache: SearchCache | None = None
277
+
278
+
279
+ def get_cache() -> SearchCache:
280
+ """Get the global search cache instance.
281
+
282
+ Creates a new instance if none exists.
283
+ """
284
+ global _cache
285
+ if _cache is None:
286
+ _cache = SearchCache()
287
+ return _cache
288
+
289
+
290
+ def reset_cache() -> None:
291
+ """Reset the global cache instance.
292
+
293
+ Useful for testing.
294
+ """
295
+ global _cache
296
+ if _cache:
297
+ _cache.clear()
298
+ _cache = None