diff --git a/mattermost-search/SKILL.md b/mattermost-search/SKILL.md index c043968..9e308ee 100644 --- a/mattermost-search/SKILL.md +++ b/mattermost-search/SKILL.md @@ -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 diff --git a/mattermost-search/everyday.md b/mattermost-search/everyday.md index 41ac880..d6221cc 100644 --- a/mattermost-search/everyday.md +++ b/mattermost-search/everyday.md @@ -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) ``` diff --git a/mm-search-mcp.service b/mm-search-mcp.service new file mode 100644 index 0000000..505cc09 --- /dev/null +++ b/mm-search-mcp.service @@ -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 diff --git a/mm_search.py b/mm_search.py index 61a3dcf..7b81009 100644 --- a/mm_search.py +++ b/mm_search.py @@ -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 diff --git a/mm_search_mcp.py b/mm_search_mcp.py index e351695..946d60f 100644 --- a/mm_search_mcp.py +++ b/mm_search_mcp.py @@ -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") diff --git a/mm_users.py b/mm_users.py index a371e9c..11f379a 100644 --- a/mm_users.py +++ b/mm_users.py @@ -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]