1237 lines
43 KiB
Python
1237 lines
43 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
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,
|
|
User,
|
|
WorkspaceConversation,
|
|
)
|
|
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_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_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.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.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)}]}
|