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
+14 -3
View File
@@ -12,8 +12,8 @@ description: |
# Mattermost Search (MCP)
Search local Mattermost JSON exports produced by `mattermost-dl.py`. **Always use MCP
tools** (`mm_search`, `mm_resolve_user`, `mm_search_users`, `mm_user_directory_refresh`,
`mm_search_index_status`) as the default path.
tools** (`mm_search`, `mm_search_tag`, `mm_resolve_user`, `mm_search_users`,
`mm_user_directory_refresh`, `mm_search_index_status`) as the default path.
## Mandatory rules
@@ -45,13 +45,22 @@ My username: `sergey.marinkevich`. Resolve others via `mm_resolve_user(ref="Ци
- SQLite index `mattermost_index.sqlite` (or `MM_SEARCH_DB`) for fast search and
**required** for attachment search. Build/update: `python mm_index.py`.
Server env (set in `mcp.json` `cwd` / `env`):
Prefer **one shared HTTP instance** (not stdio per agent). User systemd unit:
`mm-search-mcp.service` → `http://127.0.0.1:8766/mcp`. In `~/.cursor/mcp.json`:
```json
"mattermost-search": { "url": "http://127.0.0.1:8766/mcp" }
```
Server env (HTTP service / stdio `env`):
| Variable | Default |
|---|---|
| `MM_SEARCH_ROOT` | `results` |
| `MM_SEARCH_DB` | `mattermost_index.sqlite` |
| `MM_CONFIG_PATH` | `config.json` (for `mm_user_directory_refresh(from_api=true)`) |
| `MM_MCP_TRANSPORT` | `stdio` (`http` for shared server) |
| `MM_MCP_HOST` / `MM_MCP_PORT` | `127.0.0.1` / `8766` |
## Quick start
@@ -65,6 +74,7 @@ Server env (set in `mcp.json` `cwd` / `env`):
| Need | Tool |
|---|---|
| Search messages / attachments | `mm_search(query, …)` |
| `#plan` / `#report` in a date range | `mm_search_tag(keyword, after, before, …)` |
| Resolve display name → username | `mm_resolve_user(ref)` |
| Search users by name | `mm_search_users(query)` |
| Refresh user directory | `mm_user_directory_refresh(from_api=true)` |
@@ -82,6 +92,7 @@ usernames from indexed posts.
| Attachment filename | `file_name` | Index **required**; no `regex` |
| Text inside attachment | `file_content` | Index **required**; UTF-8 text files only |
| Regex in message | `message` + `regex=true` | **User permission required** — scans JSON, skips index |
| Tag `#plan` / `#report` by date | use `mm_search_tag` | Required `after`/`before`; substring `#keyword`; newest first |
## mm_search parameters
+9 -2
View File
@@ -73,10 +73,17 @@
## Поиск в экспорте Mattermost
Через MCP `mm_search` (индекс, без обхода):
Для `#plan` / `#report` за период — **`mm_search_tag`** (обязательны `after`/`before`,
подстрока `#keyword`, самые свежие сверху):
```
mm_search_tag(keyword="report", after="2026-07-28", before="2026-08-01", channel="System/HW", limit=50)
mm_search_tag(keyword="plan", after="2026-08-04", before="2026-08-04", user="sergey.marinkevich")
```
Произвольный текст — `mm_search` (FTS):
```
mm_search(query="#report", channel="System/HW", limit=30)
mm_search(query="Оформление плана и отчета за день", limit=3)
```
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Mattermost Search MCP (HTTP)
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/grayhook/Documents/mattermost-dl
Environment=MM_MCP_TRANSPORT=http
Environment=MM_MCP_HOST=127.0.0.1
Environment=MM_MCP_PORT=8766
Environment=MM_MCP_PATH=/mcp
Environment=MM_SEARCH_ROOT=results
Environment=MM_SEARCH_DB=mattermost_index.sqlite
ExecStart=/home/grayhook/Documents/mattermost-dl/.venv/bin/python /home/grayhook/Documents/mattermost-dl/mm_search_mcp.py
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
+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
+103 -12
View File
@@ -1,13 +1,31 @@
#!/usr/bin/env python3
"""MCP server (stdio) exposing mm_search.py search capabilities.
"""MCP server exposing mm_search.py search capabilities.
Cursor / Claude Desktop config example (~/.cursor/mcp.json):
Prefer a single shared HTTP instance when multiple Cursor agents use this MCP
(stdio spawns one process per agent and they contend on the sqlite index).
HTTP (recommended for multi-agent):
MM_MCP_TRANSPORT=http MM_MCP_HOST=127.0.0.1 MM_MCP_PORT=8766 \\
/path/to/.venv/bin/python /path/to/mm_search_mcp.py
Cursor ~/.cursor/mcp.json:
{
"mcpServers": {
"mattermost-search": {
"command": "/path/to/mattermost-dl/.venv/bin/python",
"args": ["/path/to/mattermost-dl/mm_search_mcp.py"],
"url": "http://127.0.0.1:8766/mcp"
}
}
}
Stdio (default, one process per client):
{
"mcpServers": {
"mattermost-search": {
"command": "/path/to/.venv/bin/python",
"args": ["/path/to/mm_search_mcp.py"],
"cwd": "/path/to/mattermost-dl",
"env": {
"MM_SEARCH_ROOT": "results",
@@ -298,6 +316,66 @@ def mm_user_directory_refresh(
)
@mcp.tool
def mm_search_tag(
keyword: str,
after: str,
before: str,
user: str = None,
channel: str = None,
team: str = None,
limit: int = 50,
db: str = None,
) -> dict[str, Any]:
"""Find posts containing #keyword in a required date range (newest first).
Plain SQL substring match — no FTS/rank. Use for daily tags like plan/report
when you have after/before (e.g. one day or one week).
Args:
keyword: Tag without or with leading # (e.g. "plan", "report", "#plan").
after: Inclusive start date (YYYY-MM-DD or ISO) — required.
before: Inclusive end date (YYYY-MM-DD or ISO) — required.
user: Filter by username (exact match).
channel: Filter by channel display_name or name (exact match).
team: Filter by team name (exact match).
limit: Max matches (default: 50).
db: SQLite index path (default: MM_SEARCH_DB).
"""
resolved_db = db or _env_db()
parsed_after = _parse_date(after)
parsed_before = _parse_date(before)
# Date-only before → end of that UTC day so a single day is inclusive.
if "T" not in before.strip():
parsed_before = parsed_before + dt.timedelta(days=1) - dt.timedelta(microseconds=1)
matches = list(
mm_search_lib.search_tag(
resolved_db,
keyword,
parsed_after,
parsed_before,
user=user,
channel=channel,
team=team,
limit=limit,
)
)
tag = keyword.strip()
if not tag.startswith("#"):
tag = f"#{tag}"
return {
"count": len(matches),
"used_index": True,
"search_mode": "tag",
"tag": tag,
"after": after,
"before": before,
"db": resolved_db,
"matches": matches,
}
@mcp.tool
def mm_search_index_status(
db: Optional[str] = None,
@@ -316,13 +394,12 @@ def mm_search_index_status(
user_count = 0
if db_exists:
try:
import sqlite3
conn = sqlite3.connect(resolved_db)
mm_users.ensure_users_schema(conn)
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
conn.close()
except sqlite3.Error:
conn = mm_search_lib.open_db(resolved_db, readonly=True)
try:
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
finally:
conn.close()
except Exception:
user_count = None
return {
@@ -337,4 +414,18 @@ def mm_search_index_status(
if __name__ == "__main__":
mcp.run(transport="stdio")
transport = os.environ.get("MM_MCP_TRANSPORT", "stdio").strip().lower()
if transport in ("http", "streamable-http", "sse"):
host = os.environ.get("MM_MCP_HOST", "127.0.0.1")
port = int(os.environ.get("MM_MCP_PORT", "8766"))
path = os.environ.get("MM_MCP_PATH", "/mcp")
# Stateless HTTP: safe for multiple Cursor agents against one process.
mcp.run(
transport="http" if transport == "streamable-http" else transport,
host=host,
port=port,
path=path,
stateless_http=True,
)
else:
mcp.run(transport="stdio")
+23 -13
View File
@@ -160,7 +160,9 @@ def refresh_user_directory(
config_path: Optional[str] = None,
from_api: bool = False,
) -> Dict[str, Any]:
conn = sqlite3.connect(db_path)
from mm_search import open_db
conn = open_db(db_path, readonly=False, timeout=30.0)
ensure_users_schema(conn)
sources: List[str] = []
profiles: List[Dict[str, str]] = []
@@ -256,12 +258,16 @@ def search_users(
if not ref:
return []
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
ensure_users_schema(conn)
rows = conn.execute(
"SELECT username, first_name, last_name, nickname, full_name FROM users"
).fetchall()
from mm_search import open_db
conn = open_db(db_path, readonly=True)
try:
rows = conn.execute(
"SELECT username, first_name, last_name, nickname, full_name FROM users"
).fetchall()
except sqlite3.Error:
conn.close()
return []
conn.close()
matched = [
@@ -288,12 +294,16 @@ def resolve_user(db_path: str, ref: str) -> Dict[str, Any]:
if not ref:
raise ValueError("ref must be non-empty")
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
ensure_users_schema(conn)
rows = conn.execute(
"SELECT username, first_name, last_name, nickname, full_name FROM users"
).fetchall()
from mm_search import open_db
conn = open_db(db_path, readonly=True)
try:
rows = conn.execute(
"SELECT username, first_name, last_name, nickname, full_name FROM users"
).fetchall()
except sqlite3.Error:
conn.close()
return {"found": False, "ref": ref, "matches": [], "error": "users table missing; run mm_user_directory_refresh"}
conn.close()
best = [row for row in rows if _match_score(ref, row) > 0]