-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcp_server.py
More file actions
239 lines (199 loc) · 7.41 KB
/
mcp_server.py
File metadata and controls
239 lines (199 loc) · 7.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
#!/usr/bin/env python3
"""recall MCP server — expose hybrid markdown search as MCP tools.
Run with:
python mcp_server.py # stdio transport (default, for Claude Code)
python mcp_server.py --transport http # HTTP transport on port 3000
fastmcp run mcp_server.py # via FastMCP CLI
Add to Claude Code:
claude mcp add recall -- python3 /home/dev/recall/mcp_server.py
"""
import json
import sys
from pathlib import Path
from typing import Optional
from fastmcp import FastMCP
# Import recall's core functions
sys.path.insert(0, str(Path(__file__).parent))
from recall import (
Document,
find_markdown_files,
hybrid_search,
load_document,
)
mcp = FastMCP(
name="recall",
instructions=(
"Use recall_search to find information in markdown knowledge bases. "
"Specify the directory to search. Results are ranked by relevance using "
"hybrid BM25 + semantic search with reciprocal rank fusion."
),
)
def _load_docs(directory: str) -> list[Document]:
"""Load all markdown documents from a directory."""
dir_path = Path(directory).expanduser().resolve()
if not dir_path.is_dir():
raise ValueError(f"Directory not found: {directory}")
files = find_markdown_files(dir_path)
docs = []
for f in files:
doc = load_document(f)
if doc:
docs.append(doc)
return docs
def _format_results(results: list[tuple[Document, float]], verbose: bool = False) -> str:
"""Format search results as readable text."""
if not results:
return "No results found."
lines = []
for i, (doc, score) in enumerate(results, 1):
# Snippet: first 200 chars of body
snippet = doc.body[:200].replace("\n", " ").strip()
if len(doc.body) > 200:
snippet += "..."
line = f"{i}. **{doc.title}** (score: {score:.4f})\n Path: {doc.path}\n {snippet}"
if verbose:
if doc.tags:
line += f"\n Tags: {', '.join(doc.tags)}"
if doc.meta.get("type"):
line += f"\n Type: {doc.meta['type']}"
if doc.meta.get("confidence"):
line += f"\n Confidence: {doc.meta['confidence']}"
lines.append(line)
return "\n\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
def recall_search(
query: str,
directory: str,
limit: int = 10,
mode: str = "bm25",
min_confidence: float = 0.0,
recency_boost: float = 0.0,
verbose: bool = False,
) -> str:
"""Search markdown files by keyword and meaning. Returns ranked results with snippets.
Uses hybrid BM25 keyword + semantic embedding search with reciprocal rank fusion.
BM25 mode is fast and needs no embeddings. Hybrid mode requires ChromaDB.
Args:
query: Search keywords or natural language query.
directory: Absolute path to the directory to search (e.g. "/home/dev/harness/memory").
limit: Max results to return (1-50, default 10).
mode: Search mode — "bm25" (keyword only, fast), "semantic" (embeddings only), or "hybrid" (both, best quality).
min_confidence: Minimum confidence threshold from frontmatter (0.0-1.0). Documents without confidence metadata are always included.
recency_boost: Boost recent documents (0.0=off, 0.1-0.5 recommended). Requires date metadata in frontmatter.
verbose: Include tags, type, and confidence in results.
"""
if limit < 1:
limit = 1
elif limit > 50:
limit = 50
if mode not in ("bm25", "semantic", "hybrid"):
return f"Invalid mode '{mode}'. Use 'bm25', 'semantic', or 'hybrid'."
docs = _load_docs(directory)
if not docs:
return f"No markdown files found in {directory}."
results = hybrid_search(
documents=docs,
query=query,
limit=limit,
mode=mode,
min_confidence=min_confidence,
recency_boost=recency_boost,
)
header = f"Found {len(results)} result(s) searching {len(docs)} files in {directory}:"
return header + "\n\n" + _format_results(results, verbose=verbose)
@mcp.tool(annotations={"readOnlyHint": True})
def recall_search_json(
query: str,
directory: str,
limit: int = 10,
mode: str = "bm25",
min_confidence: float = 0.0,
recency_boost: float = 0.0,
) -> str:
"""Search markdown files and return structured JSON results. Use recall_search for human-readable output.
Args:
query: Search keywords or natural language query.
directory: Absolute path to the directory to search.
limit: Max results (1-50).
mode: "bm25", "semantic", or "hybrid".
min_confidence: Minimum confidence threshold (0.0-1.0).
recency_boost: Boost recent documents (0.0=off).
"""
if limit < 1:
limit = 1
elif limit > 50:
limit = 50
if mode not in ("bm25", "semantic", "hybrid"):
return json.dumps({"error": f"Invalid mode '{mode}'"})
try:
docs = _load_docs(directory)
except ValueError as e:
return json.dumps({"error": str(e), "results": []})
if not docs:
return json.dumps({"error": f"No markdown files found in {directory}", "results": []})
results = hybrid_search(
documents=docs,
query=query,
limit=limit,
mode=mode,
min_confidence=min_confidence,
recency_boost=recency_boost,
)
output = {
"query": query,
"directory": directory,
"total_files": len(docs),
"results": [
{
"title": doc.title,
"path": doc.path,
"score": round(score, 4),
"snippet": doc.body[:300].replace("\n", " ").strip(),
"tags": doc.tags,
"meta": {k: v for k, v in doc.meta.items() if k in ("type", "confidence", "domain", "last_updated")},
}
for doc, score in results
],
}
return json.dumps(output, indent=2)
@mcp.tool(annotations={"readOnlyHint": True})
def recall_stats(directory: str) -> str:
"""Show statistics about markdown files in a directory — file count, total tokens, tags, types.
Args:
directory: Absolute path to the directory to analyze.
"""
docs = _load_docs(directory)
if not docs:
return f"No markdown files found in {directory}."
total_tokens = sum(d.token_count() for d in docs)
all_tags = set()
types = set()
domains = set()
for d in docs:
all_tags.update(d.tags)
if d.meta.get("type"):
types.add(d.meta["type"])
if d.meta.get("domain"):
domains.add(d.meta["domain"])
lines = [
f"Directory: {directory}",
f"Files: {len(docs)}",
f"Total tokens: {total_tokens:,}",
]
if types:
lines.append(f"Types: {', '.join(sorted(types))}")
if domains:
lines.append(f"Domains: {', '.join(sorted(domains))}")
if all_tags:
lines.append(f"Tags: {', '.join(sorted(all_tags))}")
return "\n".join(lines)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="recall MCP server")
parser.add_argument("--transport", choices=["stdio", "http"], default="stdio")
parser.add_argument("--port", type=int, default=3000)
args = parser.parse_args()
if args.transport == "http":
mcp.run(transport="http", host="0.0.0.0", port=args.port)
else:
mcp.run(transport="stdio")