Files
mattermost-dl/mm_search_mcp.py
T
Сергей Маринкевич b83d4f8b9b add user index
2026-06-05 17:19:12 +07:00

341 lines
10 KiB
Python

#!/usr/bin/env python3
"""MCP server (stdio) exposing mm_search.py search capabilities.
Cursor / Claude Desktop config example (~/.cursor/mcp.json):
{
"mcpServers": {
"mattermost-search": {
"command": "/path/to/mattermost-dl/.venv/bin/python",
"args": ["/path/to/mattermost-dl/mm_search_mcp.py"],
"cwd": "/path/to/mattermost-dl",
"env": {
"MM_SEARCH_ROOT": "results",
"MM_SEARCH_DB": "mattermost_index.sqlite"
}
}
}
}
Requires: pip install fastmcp
"""
from __future__ import annotations
import datetime as dt
import os
from typing import Any, Literal, Optional
from fastmcp import FastMCP
import mm_search as mm_search_lib
import mm_users
mcp = FastMCP("mattermost-search")
SearchMode = Literal["message", "file_name", "file_content"]
def _env_root() -> str:
return os.environ.get("MM_SEARCH_ROOT", "results")
def _env_db() -> str:
return os.environ.get("MM_SEARCH_DB", "mattermost_index.sqlite")
def _parse_date(value: str) -> dt.datetime:
try:
return mm_search_lib.parse_iso_date(value)
except Exception as exc:
raise ValueError(f"invalid date '{value}': {exc}") from exc
def _collect_matches(
*,
query: str,
root: str,
user: str,
channel: str,
team: str,
after: dt.datetime,
before: dt.datetime,
regex: bool,
search_mode: SearchMode,
ignore_case: bool,
limit: int = 10,
db: str,
no_index: bool,
) -> list[dict[str, Any]]:
if search_mode != "message" and regex:
raise ValueError("search_mode file_name/file_content do not support regex=True")
effective_ignore_case = ignore_case if ignore_case is not None else (not regex)
matcher = mm_search_lib.make_matcher(query, regex, effective_ignore_case)
use_index = (not no_index) and os.path.exists(db)
if search_mode in ("file_name", "file_content"):
if not use_index:
raise ValueError(
"Attachment search (file_name/file_content) requires sqlite index. "
"Create it with mm_index.py and do not set no_index=True."
)
mode = "name" if search_mode == "file_name" else "content"
matches_iter = mm_search_lib.search_attachments_index(
db_path=db,
query=query,
user=user,
channel=channel,
team=team,
after=after,
before=before,
limit=limit,
mode=mode,
)
output_limit = None
elif use_index and not regex:
matches_iter = mm_search_lib.search_index(
db_path=db,
query=query,
user=user,
channel=channel,
team=team,
after=after,
before=before,
limit=limit,
ignore_case=effective_ignore_case,
)
output_limit = None
else:
files = list(mm_search_lib.iter_result_files(root))
matches_iter = (
m
for path in files
for m in mm_search_lib.search_file(
path=path,
matcher=matcher,
user=user,
channel=channel,
team=team,
after=after,
before=before,
)
)
output_limit = limit
out: list[dict[str, Any]] = []
count = 0
for match in matches_iter:
if output_limit is not None and count >= output_limit:
break
out.append(match)
count += 1
return out
def _format_human(matches: list[dict[str, Any]], show_file: bool) -> str:
lines: list[str] = []
for m in matches:
p = m["post"]
created = p.get("created", "")
username = p.get("username", "")
msg = (p.get("message") or "").replace("\n", "\\n")
attachment = m.get("attachment")
attachment_suffix = ""
if isinstance(attachment, dict) and attachment.get("filename"):
attachment_suffix = f" [file: {attachment['filename']}]"
if show_file:
file_rel = os.path.relpath(m["file"])
idx = p.get("idx", "?")
prefix = f"{file_rel}:{idx}: "
else:
prefix = ""
channel = m.get("channel") or "?"
team = m.get("team") or ""
team_prefix = f"[{team}]" if team else ""
lines.append(f"{prefix}{team_prefix}[{channel}] {created} {username}: {msg}{attachment_suffix}")
return "\n".join(lines)
@mcp.tool
def mm_search(
query: str,
root: str = None,
user: str = None,
channel: str = None,
team: str = None,
after: str = None,
before: str = None,
regex: bool = False,
search_mode: SearchMode = "message",
ignore_case: bool = None,
limit: int = 10,
db: str = None,
no_index: bool = False,
output: Literal["json", "human"] = "json",
show_file: bool = False,
) -> dict[str, Any]:
"""Search Mattermost export JSON files produced by mattermost-dl.py.
Mirrors mm_search.py CLI. Returns structured matches or human-readable lines.
Args:
query: Search string or regex (when regex=True).
root: Root directory with export results (default: MM_SEARCH_ROOT or "results").
user: Filter by username (exact match).
channel: Filter by channel display_name or name (exact match).
team: Filter by team name (exact match).
after: Only messages on/after this date (YYYY-MM-DD or ISO).
before: Only messages on/before this date (YYYY-MM-DD or ISO).
regex: Treat query as regular expression (message scan only).
search_mode: "message" (default), "file_name", or "file_content".
ignore_case: Case-insensitive search. Default: True for substring, False for regex.
limit: Maximum number of matches to return (default: 10).
db: SQLite index path (default: MM_SEARCH_DB or "mattermost_index.sqlite").
no_index: Do not use sqlite index even if DB exists.
output: "json" returns match objects; "human" returns CLI-style text lines.
show_file: Include source filename and per-file index in human output.
"""
resolved_root = root or _env_root()
resolved_db = db or _env_db()
parsed_after = _parse_date(after) if after else None
parsed_before = _parse_date(before) if before else None
matches = _collect_matches(
query=query,
root=resolved_root,
user=user,
channel=channel,
team=team,
after=parsed_after,
before=parsed_before,
regex=regex,
search_mode=search_mode,
ignore_case=ignore_case,
limit=limit,
db=resolved_db,
no_index=no_index,
)
result: dict[str, Any] = {
"count": len(matches),
"used_index": (not no_index)
and os.path.exists(resolved_db)
and (search_mode in ("file_name", "file_content") or not regex),
"search_mode": search_mode,
"root": resolved_root,
"db": resolved_db,
}
if output == "human":
result["text"] = _format_human(matches, show_file=show_file)
else:
result["matches"] = matches
return result
@mcp.tool
def mm_resolve_user(
ref: str,
db: str = None,
) -> dict[str, Any]:
"""Resolve display name, FIO, nickname, or username to Mattermost username.
Args:
ref: User reference — username, first/last name, nickname, or FIO
(e.g. "Цисык", "vladislav.tsisyk").
db: SQLite index path (default: MM_SEARCH_DB or "mattermost_index.sqlite").
"""
resolved_db = db or _env_db()
return mm_users.resolve_user(resolved_db, ref)
@mcp.tool
def mm_search_users(
query: str,
limit: int = 10,
db: str = None,
) -> dict[str, Any]:
"""Search users in the local directory by name or username.
Args:
query: Substring to match against username, first/last name, nickname, full name.
limit: Maximum matches (default: 10).
db: SQLite index path (default: MM_SEARCH_DB or "mattermost_index.sqlite").
"""
resolved_db = db or _env_db()
matches = mm_users.search_users(resolved_db, query, limit=limit)
return {
"query": query,
"count": len(matches),
"db": resolved_db,
"matches": matches,
}
@mcp.tool
def mm_user_directory_refresh(
root: str = None,
db: str = None,
from_api: bool = False,
config_path: str = None,
) -> dict[str, Any]:
"""Rebuild the local user directory from users.json, Mattermost API, and post usernames.
Args:
root: Export root (default: MM_SEARCH_ROOT or "results").
db: SQLite index path (default: MM_SEARCH_DB).
from_api: Also fetch profiles from Mattermost API (needs config.json with token).
config_path: Path to Mattermost config (default: MM_CONFIG_PATH or "config.json").
"""
resolved_root = root or _env_root()
resolved_db = db or _env_db()
return mm_users.refresh_user_directory(
resolved_db,
resolved_root,
config_path=config_path,
from_api=from_api,
)
@mcp.tool
def mm_search_index_status(
db: Optional[str] = None,
root: Optional[str] = None,
) -> dict[str, Any]:
"""Report whether the sqlite index and export root are available."""
resolved_db = db or _env_db()
resolved_root = root or _env_root()
db_exists = os.path.exists(resolved_db)
root_exists = os.path.isdir(resolved_root)
json_count = 0
if root_exists:
json_count = sum(1 for _ in mm_search_lib.iter_result_files(resolved_root))
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:
user_count = None
return {
"db": resolved_db,
"db_exists": db_exists,
"root": resolved_root,
"root_exists": root_exists,
"json_files": json_count,
"users": user_count,
"index_required_for": ["file_name", "file_content"],
}
if __name__ == "__main__":
mcp.run(transport="stdio")