from __future__ import annotations import datetime import json import time from pathlib import Path from typing import Any from asgiref.sync import async_to_sync from django.conf import settings from django.db.models import Q from django.utils import timezone from django.utils.dateparse import parse_datetime from django.utils.text import slugify from core.memory.pipeline import ( create_memory_change_request, review_memory_change_request, suggest_memories_from_recent_messages, ) from core.memory.retrieval import retrieve_memories_for_prompt from core.memory.search_backend import backend_status, get_memory_search_backend from core.models import ( DerivedTask, DerivedTaskEvent, KnowledgeArticle, KnowledgeRevision, MCPToolAuditLog, MemoryChangeRequest, MemoryItem, TaskArtifactLink, TaskEpic, TaskProject, User, WorkspaceConversation, ) from core.tasks.engine import create_task_record_and_sync, mark_task_completed_and_sync from core.util import logs log = logs.get_logger("mcp-tools") def _safe_limit(value: Any, default: int, low: int, high: int) -> int: try: parsed = int(value) except (TypeError, ValueError): parsed = default return max(low, min(high, parsed)) def _coerce_statuses( value: Any, default: tuple[str, ...] = ("active",), ) -> tuple[str, ...]: if isinstance(value, (list, tuple, set)): statuses = [str(item or "").strip().lower() for item in value] else: statuses = [item.strip().lower() for item in str(value or "").split(",")] cleaned = tuple(item for item in statuses if item) return cleaned or default def _coerce_tags(value: Any) -> list[str]: if isinstance(value, (list, tuple, set)): tags = [str(item or "").strip() for item in value] else: tags = [item.strip() for item in str(value or "").split(",")] seen = set() ordered: list[str] = [] for tag in tags: if not tag: continue key = tag.lower() if key in seen: continue seen.add(key) ordered.append(tag) return ordered def _as_iso(value: Any) -> str: return value.isoformat() if value else "" def _task_payload(task: DerivedTask) -> dict[str, Any]: return { "id": str(task.id), "title": str(task.title or ""), "status_snapshot": str(task.status_snapshot or ""), "reference_code": str(task.reference_code or ""), "external_key": str(task.external_key or ""), "source_service": str(task.source_service or ""), "source_channel": str(task.source_channel or ""), "project_id": str(task.project_id or ""), "project_name": str(getattr(task.project, "name", "") or ""), "epic_id": str(task.epic_id or ""), "epic_name": str(getattr(task.epic, "name", "") or ""), "created_at": _as_iso(task.created_at), "immutable_payload": task.immutable_payload or {}, } def _event_payload(event: DerivedTaskEvent) -> dict[str, Any]: return { "id": str(event.id), "task_id": str(event.task_id), "event_type": str(event.event_type or ""), "actor_identifier": str(event.actor_identifier or ""), "source_message_id": str(event.source_message_id or ""), "payload": event.payload or {}, "created_at": _as_iso(event.created_at), } def _artifact_payload(link: TaskArtifactLink) -> dict[str, Any]: return { "id": str(link.id), "task_id": str(link.task_id), "kind": str(link.kind or ""), "uri": str(link.uri or ""), "path": str(link.path or ""), "summary": str(link.summary or ""), "created_by_identifier": str(link.created_by_identifier or ""), "created_at": _as_iso(link.created_at), } def _article_payload(article: KnowledgeArticle) -> dict[str, Any]: return { "id": str(article.id), "user_id": int(article.user_id), "related_task_id": str(article.related_task_id or ""), "title": str(article.title or ""), "slug": str(article.slug or ""), "markdown": str(article.markdown or ""), "tags": list(article.tags or []), "status": str(article.status or ""), "owner_identifier": str(article.owner_identifier or ""), "created_at": _as_iso(article.created_at), "updated_at": _as_iso(article.updated_at), } def _revision_payload(revision: KnowledgeRevision) -> dict[str, Any]: return { "id": str(revision.id), "article_id": str(revision.article_id), "revision": int(revision.revision), "author_tool": str(revision.author_tool or ""), "author_identifier": str(revision.author_identifier or ""), "summary": str(revision.summary or ""), "markdown": str(revision.markdown or ""), "created_at": _as_iso(revision.created_at), } def _memory_change_payload(req: MemoryChangeRequest) -> dict[str, Any]: return { "id": str(req.id), "user_id": int(req.user_id), "memory_id": str(req.memory_id or ""), "conversation_id": str(req.conversation_id or ""), "person_id": str(req.person_id or ""), "action": str(req.action or ""), "status": str(req.status or ""), "proposed_memory_kind": str(req.proposed_memory_kind or ""), "proposed_content": req.proposed_content or {}, "proposed_confidence_score": ( float(req.proposed_confidence_score) if req.proposed_confidence_score is not None else None ), "proposed_expires_at": _as_iso(req.proposed_expires_at), "reason": str(req.reason or ""), "requested_by_identifier": str(req.requested_by_identifier or ""), "reviewed_by_identifier": str(req.reviewed_by_identifier or ""), "reviewed_at": _as_iso(req.reviewed_at), "created_at": _as_iso(req.created_at), "updated_at": _as_iso(req.updated_at), } def _parse_iso_datetime(value: Any) -> str: raw = str(value or "").strip() if not raw: return "" parsed = parse_datetime(raw) if parsed is None: raise ValueError("invalid ISO datetime") if parsed.tzinfo is None: parsed = timezone.make_aware(parsed, timezone.get_current_timezone()) return parsed.isoformat() def _resolve_task(arguments: dict[str, Any]) -> DerivedTask: task_id = str(arguments.get("task_id") or "").strip() if not task_id: raise ValueError("task_id is required") user_id_raw = str(arguments.get("user_id") or "").strip() queryset = DerivedTask.objects.select_related("project", "epic") if user_id_raw: queryset = queryset.filter(user_id=int(user_id_raw)) return queryset.get(id=task_id) def _get_article_for_user(arguments: dict[str, Any]) -> KnowledgeArticle: user_id = int(arguments.get("user_id")) article_id = str(arguments.get("article_id") or "").strip() slug = str(arguments.get("slug") or "").strip() queryset = KnowledgeArticle.objects.filter(user_id=user_id) if article_id: return queryset.get(id=article_id) if slug: return queryset.get(slug=slug) raise ValueError("article_id or slug is required") def _next_unique_slug(*, user_id: int, requested_slug: str) -> str: base = slugify(requested_slug)[:255].strip("-") if not base: raise ValueError("slug cannot be empty") candidate = base idx = 2 while KnowledgeArticle.objects.filter( user_id=int(user_id), slug=candidate ).exists(): suffix = f"-{idx}" candidate = f"{base[: max(1, 255 - len(suffix))]}{suffix}" idx += 1 return candidate def _create_revision( *, article: KnowledgeArticle, markdown: str, author_tool: str, author_identifier: str, summary: str, ) -> KnowledgeRevision: last = article.revisions.order_by("-revision").first() revision_no = int(last.revision if last else 0) + 1 return KnowledgeRevision.objects.create( article=article, revision=revision_no, author_tool=str(author_tool or "mcp").strip(), author_identifier=str(author_identifier or "").strip(), summary=str(summary or "").strip(), markdown=str(markdown or ""), ) def _preview_meta(payload: Any) -> dict[str, Any]: if isinstance(payload, dict): keys = list(payload.keys())[:24] meta = {"keys": keys} if "count" in payload: meta["count"] = payload.get("count") if "id" in payload: meta["id"] = payload.get("id") return meta return {"preview": str(payload)[:500]} def _audit_user_from_args(arguments: dict[str, Any]) -> User | None: user_id_raw = str(arguments.get("user_id") or "").strip() if not user_id_raw: return None try: user_id = int(user_id_raw) except ValueError: return None return User.objects.filter(id=user_id).first() def tool_manticore_status(arguments: dict[str, Any]) -> dict[str, Any]: _ = arguments status = backend_status() status["ts_ms"] = int(time.time() * 1000) return status def tool_manticore_query(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) query = str(arguments.get("query") or "").strip() conversation_id = str(arguments.get("conversation_id") or "").strip() limit = _safe_limit(arguments.get("limit"), default=20, low=1, high=100) statuses = _coerce_statuses(arguments.get("statuses"), default=("active",)) if not query: raise ValueError("query is required") backend = get_memory_search_backend() hits = backend.search( user_id=user_id, query=query, conversation_id=conversation_id, limit=limit, include_statuses=statuses, ) return { "backend": getattr(backend, "name", "unknown"), "query": query, "user_id": user_id, "conversation_id": conversation_id, "statuses": list(statuses), "count": len(hits), "hits": [ { "memory_id": item.memory_id, "score": item.score, "summary": item.summary, "payload": item.payload, } for item in hits ], } def tool_manticore_reindex(arguments: dict[str, Any]) -> dict[str, Any]: user_id_raw = str(arguments.get("user_id") or "").strip() user_id = int(user_id_raw) if user_id_raw else None limit = _safe_limit(arguments.get("limit"), default=2000, low=1, high=20000) statuses = _coerce_statuses(arguments.get("statuses"), default=("active",)) backend = get_memory_search_backend() result = backend.reindex(user_id=user_id, include_statuses=statuses, limit=limit) return { "backend": getattr(backend, "name", "unknown"), "user_id": user_id, "statuses": list(statuses), "limit": limit, "result": result, } def tool_memory_list(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) query = str(arguments.get("query") or "").strip() person_id = str(arguments.get("person_id") or "").strip() conversation_id = str(arguments.get("conversation_id") or "").strip() statuses = _coerce_statuses(arguments.get("statuses"), default=("active",)) limit = _safe_limit(arguments.get("limit"), default=30, low=1, high=200) rows = retrieve_memories_for_prompt( user_id=user_id, query=query, person_id=person_id, conversation_id=conversation_id, statuses=statuses, limit=limit, ) return { "user_id": user_id, "query": query, "person_id": person_id, "conversation_id": conversation_id, "statuses": list(statuses), "count": len(rows), "items": rows, } def tool_memory_propose(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) conversation_id = str(arguments.get("conversation_id") or "").strip() person_id = str(arguments.get("person_id") or "").strip() memory_kind = str(arguments.get("memory_kind") or "fact").strip().lower() content_raw = arguments.get("content") if isinstance(content_raw, dict): content = dict(content_raw) else: content = {"text": str(content_raw or "").strip()} if not content: raise ValueError("content is required") confidence_score = float(arguments.get("confidence_score") or 0.5) expires_at = _parse_iso_datetime(arguments.get("expires_at")) reason = str(arguments.get("reason") or "").strip() requested_by = str(arguments.get("requested_by_identifier") or "").strip() conversation = WorkspaceConversation.objects.filter( user_id=user_id, id=conversation_id, ).first() if conversation is None: raise ValueError("conversation_id is required and must exist") item = MemoryItem.objects.create( user_id=user_id, conversation=conversation, person_id=person_id or None, memory_kind=memory_kind, status="proposed", content=content, provenance={"source": "mcp.memory.propose"}, confidence_score=confidence_score, expires_at=parse_datetime(expires_at) if expires_at else None, ) req = create_memory_change_request( user_id=user_id, action="create", conversation_id=conversation_id, person_id=person_id, memory_id=str(item.id), memory_kind=memory_kind, content=content, confidence_score=confidence_score, expires_at=expires_at, reason=reason, requested_by_identifier=requested_by, ) return { "ok": True, "memory_id": str(item.id), "request": _memory_change_payload(req), } def tool_memory_pending(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) limit = _safe_limit(arguments.get("limit"), default=50, low=1, high=500) rows = ( MemoryChangeRequest.objects.filter(user_id=user_id, status="pending") .select_related("memory") .order_by("created_at")[:limit] ) items = [_memory_change_payload(item) for item in rows] return {"count": len(items), "items": items} def tool_memory_review(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) request_id = str(arguments.get("request_id") or "").strip() decision = str(arguments.get("decision") or "").strip().lower() reviewer_identifier = str(arguments.get("reviewer_identifier") or "").strip() note = str(arguments.get("note") or "").strip() if not request_id: raise ValueError("request_id is required") req = review_memory_change_request( user_id=user_id, request_id=request_id, decision=decision, reviewer_identifier=reviewer_identifier, note=note, ) memory = req.memory return { "request": _memory_change_payload(req), "memory": ( { "id": str(memory.id), "status": str(memory.status), "memory_kind": str(memory.memory_kind), "content": memory.content or {}, "updated_at": _as_iso(memory.updated_at), } if memory is not None else None ), } def tool_memory_suggest(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) limit_messages = _safe_limit( arguments.get("limit_messages"), default=300, low=1, high=2000, ) max_items = _safe_limit(arguments.get("max_items"), default=30, low=1, high=500) result = suggest_memories_from_recent_messages( user_id=user_id, limit_messages=limit_messages, max_items=max_items, ) return { "user_id": user_id, "limit_messages": limit_messages, "max_items": max_items, "result": result, } def tool_tasks_list(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) status = str(arguments.get("status") or "").strip().lower() project_id = str(arguments.get("project_id") or "").strip() query = str(arguments.get("query") or "").strip() limit = _safe_limit(arguments.get("limit"), default=30, low=1, high=200) queryset = ( DerivedTask.objects.filter(user_id=user_id) .select_related("project", "epic") .order_by("-created_at") ) if status: queryset = queryset.filter(status_snapshot__iexact=status) if project_id: queryset = queryset.filter(project_id=project_id) if query: queryset = queryset.filter( Q(title__icontains=query) | Q(reference_code__icontains=query) | Q(external_key__icontains=query) ) rows = [_task_payload(item) for item in queryset[:limit]] return {"count": len(rows), "items": rows} def tool_tasks_search(arguments: dict[str, Any]) -> dict[str, Any]: query = str(arguments.get("query") or "").strip() if not query: raise ValueError("query is required") return tool_tasks_list(arguments) def tool_tasks_create(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) project_id = str(arguments.get("project_id") or "").strip() title = str(arguments.get("title") or "").strip() if not project_id: raise ValueError("project_id is required") if not title: raise ValueError("title is required") project = TaskProject.objects.filter(user_id=user_id, id=project_id).first() if project is None: raise ValueError("project_id not found") epic_id = str(arguments.get("epic_id") or "").strip() epic = None if epic_id: epic = TaskEpic.objects.filter(project=project, id=epic_id).first() if epic is None: raise ValueError("epic_id not found for project") due_at = str(arguments.get("due_date") or "").strip() due_date = None if due_at: try: due_date = datetime.date.fromisoformat(due_at) except Exception as exc: raise ValueError("due_date must be YYYY-MM-DD") from exc task, event = async_to_sync(create_task_record_and_sync)( user=project.user, project=project, epic=epic, title=title, source_service=str(arguments.get("source_service") or "web").strip().lower() or "web", source_channel=str(arguments.get("source_channel") or "").strip(), actor_identifier=str(arguments.get("actor_identifier") or "").strip(), due_date=due_date, assignee_identifier=str(arguments.get("assignee_identifier") or "").strip(), immutable_payload={ "source": "mcp.tasks.create", "requested_by": str(arguments.get("actor_identifier") or "").strip(), }, event_payload={ "source": "mcp.tasks.create", "via": "mcp", }, ) return {"task": _task_payload(task), "event": _event_payload(event)} def tool_tasks_get(arguments: dict[str, Any]) -> dict[str, Any]: task = _resolve_task(arguments) payload = _task_payload(task) payload["artifact_links"] = [ _artifact_payload(item) for item in task.artifact_links.order_by("-created_at")[:40] ] payload["knowledge_articles"] = [ { "id": str(article.id), "slug": str(article.slug or ""), "title": str(article.title or ""), "status": str(article.status or ""), "updated_at": _as_iso(article.updated_at), } for article in task.knowledge_articles.order_by("-updated_at")[:40] ] return payload def tool_tasks_events(arguments: dict[str, Any]) -> dict[str, Any]: task = _resolve_task(arguments) limit = _safe_limit(arguments.get("limit"), default=50, low=1, high=200) rows = ( DerivedTaskEvent.objects.filter(task=task) .select_related("task") .order_by("-created_at")[:limit] ) items = [_event_payload(item) for item in rows] return {"count": len(items), "items": items} def tool_tasks_create_note(arguments: dict[str, Any]) -> dict[str, Any]: task = _resolve_task(arguments) note = str(arguments.get("note") or "").strip() actor_identifier = str(arguments.get("actor_identifier") or "").strip() if not note: raise ValueError("note is required") event = DerivedTaskEvent.objects.create( task=task, event_type="progress", actor_identifier=actor_identifier, payload={"note": note, "source": "mcp.tasks.create_note"}, ) return {"task": _task_payload(task), "event": _event_payload(event)} def tool_tasks_complete(arguments: dict[str, Any]) -> dict[str, Any]: task = _resolve_task(arguments) event = async_to_sync(mark_task_completed_and_sync)( task=task, actor_identifier=str(arguments.get("actor_identifier") or "").strip(), payload={"source": "mcp.tasks.complete", "via": "mcp"}, ) task.refresh_from_db() return {"task": _task_payload(task), "event": _event_payload(event)} def tool_tasks_link_artifact(arguments: dict[str, Any]) -> dict[str, Any]: task = _resolve_task(arguments) kind = str(arguments.get("kind") or "").strip() or "note" uri = str(arguments.get("uri") or "").strip() path = str(arguments.get("path") or "").strip() summary = str(arguments.get("summary") or "").strip() created_by_identifier = str(arguments.get("created_by_identifier") or "").strip() if not uri and not path: raise ValueError("uri or path is required") artifact = TaskArtifactLink.objects.create( task=task, kind=kind, uri=uri, path=path, summary=summary, created_by_identifier=created_by_identifier, ) return {"task_id": str(task.id), "artifact": _artifact_payload(artifact)} def tool_wiki_create_article(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) title = str(arguments.get("title") or "").strip() markdown = str(arguments.get("markdown") or "") related_task_id = str(arguments.get("related_task_id") or "").strip() status = str(arguments.get("status") or "draft").strip().lower() tags = _coerce_tags(arguments.get("tags")) owner_identifier = str(arguments.get("owner_identifier") or "").strip() author_identifier = str(arguments.get("author_identifier") or "").strip() summary = str(arguments.get("summary") or "Initial revision.").strip() if not title: raise ValueError("title is required") if status not in {"draft", "published", "archived"}: raise ValueError("status must be draft/published/archived") requested_slug = str(arguments.get("slug") or "").strip() or title slug = _next_unique_slug(user_id=user_id, requested_slug=requested_slug) related_task = None if related_task_id: related_task = DerivedTask.objects.filter( user_id=user_id, id=related_task_id, ).first() if related_task is None: raise ValueError("related_task_id not found") article = KnowledgeArticle.objects.create( user_id=user_id, related_task=related_task, title=title, slug=slug, markdown=markdown, tags=tags, status=status, owner_identifier=owner_identifier, ) revision = _create_revision( article=article, markdown=markdown, author_tool="mcp", author_identifier=author_identifier, summary=summary, ) return { "article": _article_payload(article), "revision": _revision_payload(revision), } def tool_wiki_update_article(arguments: dict[str, Any]) -> dict[str, Any]: article = _get_article_for_user(arguments) title = str(arguments.get("title") or "").strip() markdown_marker = "markdown" in arguments markdown = str(arguments.get("markdown") or "") tags_marker = "tags" in arguments status_marker = "status" in arguments status = str(arguments.get("status") or "").strip().lower() related_task_id = str(arguments.get("related_task_id") or "").strip() summary = str(arguments.get("summary") or "Updated via MCP").strip() author_identifier = str(arguments.get("author_identifier") or "").strip() approve_overwrite = bool(arguments.get("approve_overwrite")) approve_archive = bool(arguments.get("approve_archive")) if markdown_marker and article.markdown and article.markdown != markdown: if not approve_overwrite: raise ValueError( "approve_overwrite=true is required to overwrite existing markdown" ) if status_marker and status == "archived" and article.status != "archived": if not approve_archive: raise ValueError("approve_archive=true is required to archive an article") if title: article.title = title if markdown_marker: article.markdown = markdown if tags_marker: article.tags = _coerce_tags(arguments.get("tags")) if status_marker: if status not in {"draft", "published", "archived"}: raise ValueError("status must be draft/published/archived") article.status = status if related_task_id: task = DerivedTask.objects.filter( user_id=article.user_id, id=related_task_id, ).first() if task is None: raise ValueError("related_task_id not found") article.related_task = task article.save() revision = _create_revision( article=article, markdown=article.markdown, author_tool="mcp", author_identifier=author_identifier, summary=summary, ) return { "article": _article_payload(article), "revision": _revision_payload(revision), } def tool_wiki_list(arguments: dict[str, Any]) -> dict[str, Any]: user_id = int(arguments.get("user_id")) status = str(arguments.get("status") or "").strip().lower() tag = str(arguments.get("tag") or "").strip().lower() related_task_id = str(arguments.get("related_task_id") or "").strip() query = str(arguments.get("query") or "").strip() limit = _safe_limit(arguments.get("limit"), default=50, low=1, high=500) queryset = KnowledgeArticle.objects.filter(user_id=user_id).order_by("-updated_at") if status: queryset = queryset.filter(status__iexact=status) if related_task_id: queryset = queryset.filter(related_task_id=related_task_id) if tag: queryset = queryset.filter(tags__icontains=tag) if query: queryset = queryset.filter(Q(title__icontains=query) | Q(slug__icontains=query)) rows = [_article_payload(item) for item in queryset[:limit]] return {"count": len(rows), "items": rows} def tool_wiki_get(arguments: dict[str, Any]) -> dict[str, Any]: article = _get_article_for_user(arguments) include_revisions = bool(arguments.get("include_revisions")) revision_limit = _safe_limit( arguments.get("revision_limit"), default=20, low=1, high=200 ) payload = {"article": _article_payload(article)} if include_revisions: revisions = article.revisions.order_by("-revision")[:revision_limit] payload["revisions"] = [_revision_payload(item) for item in revisions] return payload def tool_project_get_guidelines(arguments: dict[str, Any]) -> dict[str, Any]: max_chars = _safe_limit( arguments.get("max_chars"), default=16000, low=500, high=50000 ) base = Path(settings.BASE_DIR).resolve() file_names = ["AGENTS.md", "LLM_CODING_STANDARDS.md", "INSTALL.md", "README.md"] payload = [] total = 0 for name in file_names: path = (base / name).resolve() if not path.exists(): continue text = path.read_text(encoding="utf-8") remaining = max_chars - total if remaining <= 0: break selected = text[:remaining] total += len(selected) payload.append({"path": str(path), "content": selected}) return {"files": payload, "truncated": total >= max_chars} def tool_project_get_layout(arguments: dict[str, Any]) -> dict[str, Any]: max_entries = _safe_limit( arguments.get("max_entries"), default=300, low=50, high=4000 ) base = Path(settings.BASE_DIR).resolve() roots = ["app", "core", "scripts", "utilities", "artifacts"] items: list[str] = [] for root in roots: root_path = (base / root).resolve() if not root_path.exists(): continue items.append(f"{root}/") for path in sorted(root_path.rglob("*")): if len(items) >= max_entries: return {"base_dir": str(base), "items": items, "truncated": True} rel = path.relative_to(base) if len(rel.parts) > 4: continue items.append(f"{rel.as_posix()}/" if path.is_dir() else rel.as_posix()) return {"base_dir": str(base), "items": items, "truncated": False} def tool_project_get_runbook(arguments: dict[str, Any]) -> dict[str, Any]: max_chars = _safe_limit( arguments.get("max_chars"), default=16000, low=500, high=50000 ) base = Path(settings.BASE_DIR).resolve() file_names = [ "INSTALL.md", "README.md", "artifacts/mcp/manticore-mcp-server.md", "artifacts/plans/11-personal-ai-memory.md", "artifacts/plans/12-mcp-server-for-tasks-and-knowledge.md", ] payload = [] total = 0 for name in file_names: path = (base / name).resolve() if not path.exists(): continue text = path.read_text(encoding="utf-8") remaining = max_chars - total if remaining <= 0: break selected = text[:remaining] total += len(selected) payload.append({"path": str(path), "content": selected}) return {"files": payload, "truncated": total >= max_chars} def tool_docs_append_run_note(arguments: dict[str, Any]) -> dict[str, Any]: content = str(arguments.get("content") or "").strip() title = str(arguments.get("title") or "").strip() or "MCP Run Note" task_id = str(arguments.get("task_id") or "").strip() raw_path = str(arguments.get("path") or "").strip() if not content: raise ValueError("content is required") base = Path(settings.BASE_DIR).resolve() if not raw_path: path = Path("/tmp/gia-mcp-run-notes.md") else: candidate = Path(raw_path) path = ( candidate.resolve() if candidate.is_absolute() else (base / candidate).resolve() ) allowed_roots = [base, Path("/tmp").resolve()] if not any(str(path).startswith(str(root)) for root in allowed_roots): raise ValueError("path must be within project root or /tmp") path.parent.mkdir(parents=True, exist_ok=True) ts = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()) lines = [f"## {title}", "", f"- Timestamp: {ts}"] if task_id: lines.append(f"- Task ID: `{task_id}`") lines.extend(["", content, "", "---", ""]) text = "\n".join(lines) with path.open("a", encoding="utf-8") as handle: handle.write(text) return {"ok": True, "path": str(path), "bytes_written": len(text)} TOOL_DEFS: dict[str, dict[str, Any]] = { "manticore.status": { "description": "Report configured memory backend status (django or manticore).", "inputSchema": { "type": "object", "properties": {}, "additionalProperties": False, }, "handler": tool_manticore_status, }, "manticore.query": { "description": "Query memory index via configured backend.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "query": {"type": "string"}, "conversation_id": {"type": "string"}, "limit": {"type": "integer"}, "statuses": { "anyOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}}, ] }, }, "required": ["user_id", "query"], "additionalProperties": False, }, "handler": tool_manticore_query, }, "manticore.reindex": { "description": "Reindex memory rows into configured backend.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "limit": {"type": "integer"}, "statuses": { "anyOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}}, ] }, }, "additionalProperties": False, }, "handler": tool_manticore_reindex, }, "memory.list": { "description": "List approved memories for prompt usage.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "query": {"type": "string"}, "person_id": {"type": "string"}, "conversation_id": {"type": "string"}, "statuses": { "anyOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}}, ] }, "limit": {"type": "integer"}, }, "required": ["user_id"], "additionalProperties": False, }, "handler": tool_memory_list, }, "memory.propose": { "description": "Create a pending memory proposal requiring review.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "conversation_id": {"type": "string"}, "person_id": {"type": "string"}, "memory_kind": {"type": "string"}, "content": {"anyOf": [{"type": "object"}, {"type": "string"}]}, "confidence_score": {"type": "number"}, "expires_at": {"type": "string"}, "reason": {"type": "string"}, "requested_by_identifier": {"type": "string"}, }, "required": ["user_id", "conversation_id", "content"], "additionalProperties": False, }, "handler": tool_memory_propose, }, "memory.pending": { "description": "List pending memory change requests.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "limit": {"type": "integer"}, }, "required": ["user_id"], "additionalProperties": False, }, "handler": tool_memory_pending, }, "memory.review": { "description": "Approve or reject a pending memory request.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "request_id": {"type": "string"}, "decision": {"type": "string"}, "reviewer_identifier": {"type": "string"}, "note": {"type": "string"}, }, "required": ["user_id", "request_id", "decision"], "additionalProperties": False, }, "handler": tool_memory_review, }, "memory.suggest_from_messages": { "description": "Extract memory proposals from recent inbound messages.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "limit_messages": {"type": "integer"}, "max_items": {"type": "integer"}, }, "required": ["user_id"], "additionalProperties": False, }, "handler": tool_memory_suggest, }, "tasks.list": { "description": "List derived tasks for a user.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "status": {"type": "string"}, "project_id": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}, }, "required": ["user_id"], "additionalProperties": False, }, "handler": tool_tasks_list, }, "tasks.search": { "description": "Search derived tasks by free text for a user.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "query": {"type": "string"}, "status": {"type": "string"}, "project_id": {"type": "string"}, "limit": {"type": "integer"}, }, "required": ["user_id", "query"], "additionalProperties": False, }, "handler": tool_tasks_search, }, "tasks.create": { "description": "Create a canonical task inside GIA.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "project_id": {"type": "string"}, "epic_id": {"type": "string"}, "title": {"type": "string"}, "due_date": {"type": "string"}, "assignee_identifier": {"type": "string"}, "actor_identifier": {"type": "string"}, "source_service": {"type": "string"}, "source_channel": {"type": "string"}, }, "required": ["user_id", "project_id", "title"], "additionalProperties": False, }, "handler": tool_tasks_create, }, "tasks.get": { "description": "Get one derived task by ID, including links.", "inputSchema": { "type": "object", "properties": { "task_id": {"type": "string"}, "user_id": {"type": "integer"}, }, "required": ["task_id"], "additionalProperties": False, }, "handler": tool_tasks_get, }, "tasks.events": { "description": "List events for one derived task.", "inputSchema": { "type": "object", "properties": { "task_id": {"type": "string"}, "user_id": {"type": "integer"}, "limit": {"type": "integer"}, }, "required": ["task_id"], "additionalProperties": False, }, "handler": tool_tasks_events, }, "tasks.create_note": { "description": "Append an implementation/progress note to a task.", "inputSchema": { "type": "object", "properties": { "task_id": {"type": "string"}, "user_id": {"type": "integer"}, "note": {"type": "string"}, "actor_identifier": {"type": "string"}, }, "required": ["task_id", "note"], "additionalProperties": False, }, "handler": tool_tasks_create_note, }, "tasks.complete": { "description": "Mark a task completed and append a completion event.", "inputSchema": { "type": "object", "properties": { "task_id": {"type": "string"}, "user_id": {"type": "integer"}, "actor_identifier": {"type": "string"}, }, "required": ["task_id"], "additionalProperties": False, }, "handler": tool_tasks_complete, }, "tasks.link_artifact": { "description": "Link an artifact (URI/path) to a task.", "inputSchema": { "type": "object", "properties": { "task_id": {"type": "string"}, "user_id": {"type": "integer"}, "kind": {"type": "string"}, "uri": {"type": "string"}, "path": {"type": "string"}, "summary": {"type": "string"}, "created_by_identifier": {"type": "string"}, }, "required": ["task_id"], "additionalProperties": False, }, "handler": tool_tasks_link_artifact, }, "wiki.create_article": { "description": "Create a wiki article with initial revision.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "title": {"type": "string"}, "slug": {"type": "string"}, "markdown": {"type": "string"}, "tags": { "anyOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}}, ] }, "status": {"type": "string"}, "related_task_id": {"type": "string"}, "owner_identifier": {"type": "string"}, "author_identifier": {"type": "string"}, "summary": {"type": "string"}, }, "required": ["user_id", "title"], "additionalProperties": False, }, "handler": tool_wiki_create_article, }, "wiki.update_article": { "description": "Update wiki article and append a revision entry.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "article_id": {"type": "string"}, "slug": {"type": "string"}, "title": {"type": "string"}, "markdown": {"type": "string"}, "tags": { "anyOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}}, ] }, "status": {"type": "string"}, "related_task_id": {"type": "string"}, "summary": {"type": "string"}, "author_identifier": {"type": "string"}, "approve_overwrite": {"type": "boolean"}, "approve_archive": {"type": "boolean"}, }, "required": ["user_id"], "additionalProperties": False, }, "handler": tool_wiki_update_article, }, "wiki.list": { "description": "List wiki articles for a user with filters.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "status": {"type": "string"}, "tag": {"type": "string"}, "related_task_id": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}, }, "required": ["user_id"], "additionalProperties": False, }, "handler": tool_wiki_list, }, "wiki.get": { "description": "Get one wiki article by id/slug.", "inputSchema": { "type": "object", "properties": { "user_id": {"type": "integer"}, "article_id": {"type": "string"}, "slug": {"type": "string"}, "include_revisions": {"type": "boolean"}, "revision_limit": {"type": "integer"}, }, "required": ["user_id"], "additionalProperties": False, }, "handler": tool_wiki_get, }, "project.get_guidelines": { "description": "Load key project guideline documents.", "inputSchema": { "type": "object", "properties": {"max_chars": {"type": "integer"}}, "additionalProperties": False, }, "handler": tool_project_get_guidelines, }, "project.get_layout": { "description": "List major project files/directories for orientation.", "inputSchema": { "type": "object", "properties": {"max_entries": {"type": "integer"}}, "additionalProperties": False, }, "handler": tool_project_get_layout, }, "project.get_runbook": { "description": "Load operational runbook docs for agent continuity.", "inputSchema": { "type": "object", "properties": {"max_chars": {"type": "integer"}}, "additionalProperties": False, }, "handler": tool_project_get_runbook, }, "docs.append_run_note": { "description": "Append an implementation note markdown entry.", "inputSchema": { "type": "object", "properties": { "title": {"type": "string"}, "content": {"type": "string"}, "task_id": {"type": "string"}, "path": {"type": "string"}, }, "required": ["content"], "additionalProperties": False, }, "handler": tool_docs_append_run_note, }, } def tool_specs() -> list[dict[str, Any]]: return [ { "name": name, "description": definition["description"], "inputSchema": definition["inputSchema"], } for name, definition in TOOL_DEFS.items() ] def execute_tool(name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: entry = TOOL_DEFS.get(str(name or "").strip()) if not entry: raise ValueError(f"Unknown tool: {name}") args = arguments or {} handler = entry["handler"] started = time.time() audit_user = _audit_user_from_args(args) try: payload = handler(args) duration_ms = int((time.time() - started) * 1000) try: MCPToolAuditLog.objects.create( tool_name=str(name), user=audit_user, request_args=args, response_meta=_preview_meta(payload), ok=True, duration_ms=max(0, duration_ms), ) except Exception as exc: log.warning("failed writing MCP success audit log: %s", exc) return payload except Exception as exc: duration_ms = int((time.time() - started) * 1000) try: MCPToolAuditLog.objects.create( tool_name=str(name), user=audit_user, request_args=args, response_meta={}, ok=False, error=str(exc), duration_ms=max(0, duration_ms), ) except Exception as audit_exc: log.warning("failed writing MCP error audit log: %s", audit_exc) raise def format_tool_content(payload: dict[str, Any]) -> dict[str, Any]: return {"content": [{"type": "text", "text": json.dumps(payload, indent=2)}]}