6f8648f7fa
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>
432 lines
13 KiB
Python
432 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""MCP server exposing mm_search.py search capabilities.
|
|
|
|
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": {
|
|
"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",
|
|
"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_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,
|
|
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:
|
|
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 {
|
|
"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__":
|
|
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")
|