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:
+117
-34
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user