Run mattermost-search MCP over shared HTTP and fix FTS hangs.

Avoid per-agent stdio contention on the sqlite index, sort FTS by rank with created tie-break, and add mm_search_tag for dated #plan/#report substring search.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Сергей Маринкевич
2026-08-04 15:05:01 +07:00
parent 273c487558
commit 6f8648f7fa
6 changed files with 285 additions and 64 deletions
+117 -34
View File
@@ -10,6 +10,18 @@ from typing import Any, Dict, Iterable, List, Optional
import mm_users
def open_db(db_path: str, *, readonly: bool = False, timeout: float = 5.0) -> sqlite3.Connection:
"""Open sqlite index with busy timeout; readonly avoids write locks on shared DB."""
if readonly:
abs_path = os.path.abspath(db_path)
conn = sqlite3.connect(f"file:{abs_path}?mode=ro", uri=True, timeout=timeout)
else:
conn = sqlite3.connect(db_path, timeout=timeout)
conn.row_factory = sqlite3.Row
conn.execute(f"PRAGMA busy_timeout={int(timeout * 1000)}")
return conn
def iter_result_files(root: str) -> Iterable[str]:
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
@@ -60,6 +72,101 @@ def build_fts_query(query: str) -> Optional[str]:
return " AND ".join(f"{tok}*" for tok in tokens)
def _escape_like(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _post_row_to_match(row: sqlite3.Row) -> Dict[str, Any]:
files: Optional[List[str]] = None
if row["files_json"]:
try:
maybe_files = json.loads(row["files_json"])
if isinstance(maybe_files, list):
files = maybe_files
except json.JSONDecodeError:
files = None
post: Dict[str, Any] = {
"id": row["post_id"],
"idx": row["idx"],
"created": row["created"].replace("+00:00", "Z"),
"username": row["username"],
"message": row["message"],
}
if files:
post["files"] = files
return {
"file": row["source_path"],
"channel": row["channel_display_name"] or row["channel_name"],
"team": row["team"],
"post": post,
}
def search_tag(
db_path: str,
keyword: str,
after: dt.datetime,
before: dt.datetime,
*,
user: Optional[str] = None,
channel: Optional[str] = None,
team: Optional[str] = None,
limit: Optional[int] = 50,
) -> Iterable[Dict[str, Any]]:
"""Substring search for #keyword within a required created date range (newest first).
Uses plain SQL LIKE — no FTS/rank. Intended for tags like plan/report over a day/week.
"""
raw = (keyword or "").strip()
if raw.startswith("#"):
raw = raw[1:].strip()
if not raw:
raise ValueError("keyword must be non-empty (e.g. 'plan' or 'report')")
if after is None or before is None:
raise ValueError("after and before are required")
if after > before:
raise ValueError("after must be <= before")
tag = f"#{raw}"
needle = f"%{_escape_like(tag)}%"
conn = open_db(db_path, readonly=True)
try:
filters = [
"p.created >= ?",
"p.created <= ?",
"p.message LIKE ? ESCAPE '\\'",
]
params: List[Any] = [after.isoformat(), before.isoformat(), needle]
if user:
filters.append("p.username = ?")
params.append(user)
if channel:
filters.append("(p.channel_display_name = ? OR p.channel_name = ?)")
params.extend([channel, channel])
if team:
filters.append("p.team = ?")
params.append(team)
sql = f"""
SELECT
p.post_id, p.idx, p.created, p.username, p.message, p.files_json,
p.channel_display_name, p.channel_name, p.team, p.source_path
FROM posts p
WHERE {' AND '.join(filters)}
ORDER BY p.created DESC
"""
if limit is not None:
sql += " LIMIT ?"
params.append(limit)
for row in conn.execute(sql, params).fetchall():
yield _post_row_to_match(row)
finally:
conn.close()
def search_index(
db_path: str,
query: str,
@@ -71,8 +178,7 @@ def search_index(
limit: Optional[int],
ignore_case: bool,
) -> Iterable[Dict[str, Any]]:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn = open_db(db_path, readonly=True)
try:
filters = []
params: List[Any] = []
@@ -96,15 +202,16 @@ def search_index(
where_filters = f"AND {' AND '.join(filters)}" if filters else ""
fts_q = build_fts_query(query)
if fts_q:
# Primary: FTS relevance (rank). Tie-break: newest first.
sql = f"""
SELECT
p.post_id, p.idx, p.created, p.username, p.message, p.files_json,
p.channel_display_name, p.channel_name, p.team, p.source_path
FROM posts p
JOIN posts_fts fts ON fts.post_id = p.post_id
FROM posts_fts fts
JOIN posts p ON p.post_id = fts.post_id
WHERE fts.message MATCH ?
{where_filters}
ORDER BY p.created DESC
ORDER BY rank, p.created DESC
"""
params = [fts_q] + params
else:
@@ -127,30 +234,7 @@ def search_index(
rows = conn.execute(sql, params).fetchall()
for row in rows:
files: Optional[List[str]] = None
if row["files_json"]:
try:
maybe_files = json.loads(row["files_json"])
if isinstance(maybe_files, list):
files = maybe_files
except json.JSONDecodeError:
files = None
post: Dict[str, Any] = {
"id": row["post_id"],
"idx": row["idx"],
"created": row["created"].replace("+00:00", "Z"),
"username": row["username"],
"message": row["message"],
}
if files:
post["files"] = files
yield {
"file": row["source_path"],
"channel": row["channel_display_name"] or row["channel_name"],
"team": row["team"],
"post": post,
}
yield _post_row_to_match(row)
finally:
conn.close()
@@ -171,8 +255,7 @@ def search_attachments_index(
mode: "name" to search by file name, "content" to search by text content.
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn = open_db(db_path, readonly=True)
try:
filters = []
params: List[Any] = []
@@ -215,12 +298,12 @@ def search_attachments_index(
a.filename AS attachment_filename,
a.filepath AS attachment_path,
a.is_text AS attachment_is_text
FROM attachments a
JOIN attachments_fts aft ON aft.attachment_id = a.id
FROM attachments_fts aft
JOIN attachments a ON a.id = aft.attachment_id
JOIN posts p ON p.post_id = a.post_id
WHERE aft.{fts_column} MATCH ?
{where_filters}
ORDER BY p.created DESC
ORDER BY rank, p.created DESC
"""
params = [fts_q] + params