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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user