from __future__ import annotations import json import sys from typing import Any, Dict, Optional from .models import SearchQuery, search_response class SemanticMCP: def __init__(self, search_service: Any, backfill_service: Optional[Any], store: Optional[Any] = None, refresh_service: Optional[Any] = None) -> None: self.search_service = search_service self.backfill_service = backfill_service self.store = store self.refresh_service = refresh_service def tools(self) -> Dict[str, Dict[str, str]]: return { "semantic_search": {"description": "Search the semantic index and return cited snippets."}, "semantic_get_document": {"description": "Fetch one indexed document by stable id."}, "semantic_list_projects": {"description": "List indexed project identifiers and document counts."}, "semantic_backfill_redmine_sample": {"description": "Rebuild the Redmine sample collection."}, "semantic_refresh_redmine": {"description": "Refresh recent Redmine issues without re-embedding unchanged documents."}, } def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: if name == "semantic_search": query = SearchQuery( text=arguments.get("query") or arguments.get("text") or "", source=arguments.get("source"), project_id=arguments.get("project_id"), project_identifier=arguments.get("project_identifier"), doc_type=arguments.get("doc_type"), issue_id=arguments.get("issue_id"), contact_id=arguments.get("contact_id"), contact_email=arguments.get("contact_email"), date_from=arguments.get("date_from"), date_to=arguments.get("date_to"), limit=int(arguments.get("limit", 10)), include_snippets=bool(arguments.get("include_snippets", True)), ) results = self.search_service.search(query) return search_response(query, results) if name == "semantic_get_document": return self.search_service.get_document(arguments["id"]) or {"error": "not_found", "id": arguments["id"]} if name == "semantic_list_projects": if self.store is None: return {"error": "project_listing_unavailable"} return {"projects": self.store.list_projects(source=arguments.get("source", "redmine"))} if name == "semantic_backfill_redmine_sample": if self.backfill_service is None: return {"error": "backfill_unavailable"} return self.backfill_service.backfill_redmine_sample(limit=int(arguments.get("limit", 500))) if name == "semantic_refresh_redmine": if self.refresh_service is None: return {"error": "refresh_unavailable"} project_limits = arguments.get("project_limits") if not project_limits: project = arguments.get("project_identifier") if not project: return {"error": "project_required"} project_limits = {project: int(arguments.get("limit", 500))} return self.refresh_service.refresh_redmine_project_limits( {str(project): int(limit) for project, limit in project_limits.items()}, dry_run=bool(arguments.get("dry_run", False)), force_rebuild=bool(arguments.get("force_rebuild", False)), overlap_minutes=int(arguments.get("overlap_minutes", 15)), ) raise ValueError(f"unknown tool: {name}") def serve_stdio(mcp: SemanticMCP) -> None: for line in sys.stdin: request = json.loads(line) try: result = mcp.call_tool(request["name"], request.get("arguments") or {}) response = {"id": request.get("id"), "result": result} except Exception as exc: response = {"id": request.get("id"), "error": str(exc)} print(json.dumps(response), flush=True)