mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
Implement MCP comment attachment access
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+126
-17
@@ -1,13 +1,86 @@
|
||||
import mimetypes
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.cases.models import Case
|
||||
from apps.comments.models import Comment
|
||||
|
||||
DEFAULT_COMMENTS_LIMIT = 20
|
||||
MAX_COMMENTS_LIMIT = 50
|
||||
|
||||
|
||||
def _dt(value):
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def normalize_comments_limit(value):
|
||||
return max(1, min(int(value or DEFAULT_COMMENTS_LIMIT), MAX_COMMENTS_LIMIT))
|
||||
|
||||
|
||||
def _request_header(request, header_name):
|
||||
scope = getattr(request, "scope", {}) if request is not None else {}
|
||||
expected = header_name.lower().encode()
|
||||
for key, value in scope.get("headers", []):
|
||||
if key.lower() == expected:
|
||||
return value.decode("utf-8", errors="ignore")
|
||||
return ""
|
||||
|
||||
|
||||
def _absolute_url(path, request=None):
|
||||
if request is None:
|
||||
return path
|
||||
|
||||
scope = getattr(request, "scope", {})
|
||||
scheme = (_request_header(request, "x-forwarded-proto").split(",", 1)[0].strip()
|
||||
or scope.get("scheme")
|
||||
or "http")
|
||||
host = (_request_header(request, "x-forwarded-host").split(",", 1)[0].strip()
|
||||
or _request_header(request, "host"))
|
||||
if not host:
|
||||
server = scope.get("server")
|
||||
if server:
|
||||
host = f"{server[0]}:{server[1]}"
|
||||
if not host:
|
||||
return path
|
||||
return f"{scheme}://{host}{path}"
|
||||
|
||||
|
||||
def serialize_attachment(attachment, *, request=None):
|
||||
path = reverse("attachment-download", kwargs={"access_key": attachment.access_key})
|
||||
return {
|
||||
"file_key": str(attachment.access_key),
|
||||
"filename": attachment.filename,
|
||||
"size": attachment.size,
|
||||
"content_type": mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream",
|
||||
"download_url": _absolute_url(path, request=request),
|
||||
}
|
||||
|
||||
|
||||
def _record_content_type(record):
|
||||
return ContentType.objects.get_for_model(record, for_concrete_model=False)
|
||||
|
||||
|
||||
def _comments_for_record(record, *, comments_limit=DEFAULT_COMMENTS_LIMIT):
|
||||
content_type = _record_content_type(record)
|
||||
comments = list(
|
||||
Comment.objects
|
||||
.filter(content_type=content_type, object_id=str(record.pk))
|
||||
.select_related("author", "parent")
|
||||
.prefetch_related("attachments")
|
||||
.order_by("-created_at", "-id")[:normalize_comments_limit(comments_limit)]
|
||||
)
|
||||
return reversed(comments)
|
||||
|
||||
|
||||
def _add_comments(data, record, *, include_comments=False, comments_limit=DEFAULT_COMMENTS_LIMIT, request=None):
|
||||
if include_comments:
|
||||
data["comments"] = [
|
||||
serialize_comment(comment, request=request)
|
||||
for comment in _comments_for_record(record, comments_limit=comments_limit)
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
def serialize_enrichment(enrichment):
|
||||
return {
|
||||
"enrichment_id": enrichment.enrichment_id,
|
||||
@@ -22,7 +95,14 @@ def serialize_enrichment(enrichment):
|
||||
}
|
||||
|
||||
|
||||
def serialize_artifact(artifact, *, include_enrichments=True):
|
||||
def serialize_artifact(
|
||||
artifact,
|
||||
*,
|
||||
include_enrichments=True,
|
||||
include_comments=False,
|
||||
comments_limit=DEFAULT_COMMENTS_LIMIT,
|
||||
request=None,
|
||||
):
|
||||
data = {
|
||||
"artifact_id": artifact.artifact_id,
|
||||
"name": artifact.name,
|
||||
@@ -33,10 +113,17 @@ def serialize_artifact(artifact, *, include_enrichments=True):
|
||||
}
|
||||
if include_enrichments:
|
||||
data["enrichments"] = [serialize_enrichment(item) for item in artifact.enrichments.all()[:20]]
|
||||
return data
|
||||
return _add_comments(data, artifact, include_comments=include_comments, comments_limit=comments_limit, request=request)
|
||||
|
||||
|
||||
def serialize_alert(alert, *, include_related=False):
|
||||
def serialize_alert(
|
||||
alert,
|
||||
*,
|
||||
include_related=False,
|
||||
include_comments=False,
|
||||
comments_limit=DEFAULT_COMMENTS_LIMIT,
|
||||
request=None,
|
||||
):
|
||||
data = {
|
||||
"alert_id": alert.alert_id,
|
||||
"case_id": alert.case.case_id if alert.case_id else "",
|
||||
@@ -53,19 +140,32 @@ def serialize_alert(alert, *, include_related=False):
|
||||
if include_related:
|
||||
data["artifacts"] = [serialize_artifact(item, include_enrichments=False) for item in alert.artifacts.all()[:50]]
|
||||
data["enrichments"] = [serialize_enrichment(item) for item in alert.enrichments.all()[:20]]
|
||||
return data
|
||||
return _add_comments(data, alert, include_comments=include_comments, comments_limit=comments_limit, request=request)
|
||||
|
||||
|
||||
def serialize_comment(comment):
|
||||
def serialize_comment(comment, *, request=None):
|
||||
return {
|
||||
"id": comment.id,
|
||||
"body": comment.body,
|
||||
"author": comment.author.username if comment.author else "",
|
||||
"created_at": _dt(comment.created_at),
|
||||
"updated_at": _dt(comment.updated_at),
|
||||
"parent_id": comment.parent_id,
|
||||
"attachments": [
|
||||
serialize_attachment(item, request=request)
|
||||
for item in comment.attachments.all()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def serialize_case(case, *, include_related=True):
|
||||
def serialize_case(
|
||||
case,
|
||||
*,
|
||||
include_related=True,
|
||||
include_comments=False,
|
||||
comments_limit=DEFAULT_COMMENTS_LIMIT,
|
||||
request=None,
|
||||
):
|
||||
data = {
|
||||
"case_id": case.case_id,
|
||||
"title": case.title,
|
||||
@@ -86,18 +186,20 @@ def serialize_case(case, *, include_related=True):
|
||||
"created_at": _dt(case.created_at),
|
||||
}
|
||||
if include_related:
|
||||
content_type = ContentType.objects.get_for_model(Case)
|
||||
data["alerts"] = [serialize_alert(alert, include_related=True) for alert in case.alerts.all()[:50]]
|
||||
data["enrichments"] = [serialize_enrichment(item) for item in case.enrichments.all()[:20]]
|
||||
data["comments"] = [
|
||||
serialize_comment(item)
|
||||
for item in Comment.objects.filter(content_type=content_type, object_id=str(case.id)).order_by("created_at")[:50]
|
||||
]
|
||||
data["playbooks"] = [serialize_playbook(item) for item in case.playbooks.all()[:20]]
|
||||
return data
|
||||
return _add_comments(data, case, include_comments=include_comments, comments_limit=comments_limit, request=request)
|
||||
|
||||
|
||||
def serialize_playbook(playbook, *, include_related=False):
|
||||
def serialize_playbook(
|
||||
playbook,
|
||||
*,
|
||||
include_related=False,
|
||||
include_comments=False,
|
||||
comments_limit=DEFAULT_COMMENTS_LIMIT,
|
||||
request=None,
|
||||
):
|
||||
data = {
|
||||
"playbook_id": playbook.playbook_id,
|
||||
"case_id": playbook.case.case_id if playbook.case_id else "",
|
||||
@@ -110,11 +212,17 @@ def serialize_playbook(playbook, *, include_related=False):
|
||||
}
|
||||
if include_related and playbook.case_id:
|
||||
data["case"] = serialize_case(playbook.case, include_related=False)
|
||||
return data
|
||||
return _add_comments(data, playbook, include_comments=include_comments, comments_limit=comments_limit, request=request)
|
||||
|
||||
|
||||
def serialize_knowledge(knowledge):
|
||||
return {
|
||||
def serialize_knowledge(
|
||||
knowledge,
|
||||
*,
|
||||
include_comments=False,
|
||||
comments_limit=DEFAULT_COMMENTS_LIMIT,
|
||||
request=None,
|
||||
):
|
||||
data = {
|
||||
"knowledge_id": knowledge.knowledge_id,
|
||||
"title": knowledge.title,
|
||||
"body": knowledge.body,
|
||||
@@ -124,3 +232,4 @@ def serialize_knowledge(knowledge):
|
||||
"case_id": knowledge.case.case_id if knowledge.case_id else "",
|
||||
"created_at": _dt(knowledge.created_at),
|
||||
}
|
||||
return _add_comments(data, knowledge, include_comments=include_comments, comments_limit=comments_limit, request=request)
|
||||
|
||||
+159
-17
@@ -2,11 +2,15 @@ import json
|
||||
from datetime import timezone as datetime_timezone
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
from uuid import UUID
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from apps.accounts.permissions import is_business_writer
|
||||
from apps.agentic.services.playbooks import (
|
||||
create_pending_playbook_run,
|
||||
list_playbook_definitions as list_playbook_definition_records,
|
||||
@@ -14,12 +18,17 @@ from apps.agentic.services.playbooks import (
|
||||
from apps.audit.context import audit_actor
|
||||
from apps.alerts.models import Alert
|
||||
from apps.artifacts.models import Artifact
|
||||
from apps.attachments.models import Attachment
|
||||
from apps.cases.models import Case
|
||||
from apps.comments.models import Comment
|
||||
from apps.comments.services import create_record_comment
|
||||
from apps.common.redis_stream import RedisStreamClient
|
||||
from apps.enrichments.models import Enrichment, EnrichmentProvider
|
||||
from apps.knowledge.models import Knowledge
|
||||
from apps.mcp.serializers import (
|
||||
DEFAULT_COMMENTS_LIMIT,
|
||||
normalize_comments_limit,
|
||||
serialize_attachment,
|
||||
serialize_alert,
|
||||
serialize_artifact,
|
||||
serialize_case,
|
||||
@@ -85,6 +94,22 @@ def _bool(value, default=True):
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _current_request(ctx):
|
||||
if ctx is None:
|
||||
return None
|
||||
request_context = getattr(ctx, "request_context", None)
|
||||
return getattr(request_context, "request", None)
|
||||
|
||||
|
||||
def _comments_kwargs(include_comments, comments_limit, ctx):
|
||||
include = _bool(include_comments, default=False)
|
||||
return {
|
||||
"include_comments": include,
|
||||
"comments_limit": normalize_comments_limit(comments_limit) if include else DEFAULT_COMMENTS_LIMIT,
|
||||
"request": _current_request(ctx),
|
||||
}
|
||||
|
||||
|
||||
def _json_object(value, field_name):
|
||||
if value in (None, ""):
|
||||
return {}
|
||||
@@ -185,7 +210,7 @@ def _find_comment_target(target_id):
|
||||
def _current_user(ctx):
|
||||
if ctx is None:
|
||||
raise ValueError("MCP write tool requires an authenticated MCP request")
|
||||
request = ctx.request_context.request
|
||||
request = _current_request(ctx)
|
||||
scope = getattr(request, "scope", {}) if request is not None else {}
|
||||
user = scope.get("user")
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
@@ -193,8 +218,91 @@ def _current_user(ctx):
|
||||
return user
|
||||
|
||||
|
||||
def _current_business_writer(ctx):
|
||||
user = _current_user(ctx)
|
||||
if not is_business_writer(user):
|
||||
raise ValueError("MCP write tool requires an admin or user role")
|
||||
return user
|
||||
|
||||
|
||||
def _normalize_file_key(file_key):
|
||||
text = str(file_key or "").strip()
|
||||
if not text:
|
||||
raise ValueError("file_key is required")
|
||||
try:
|
||||
return UUID(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid file_key: {text}") from exc
|
||||
|
||||
|
||||
def _find_attachment_by_file_key(file_key):
|
||||
normalized_key = _normalize_file_key(file_key)
|
||||
try:
|
||||
return Attachment.objects.get(access_key=normalized_key)
|
||||
except Attachment.DoesNotExist as exc:
|
||||
raise ValueError(f"Attachment not found for file_key: {normalized_key}") from exc
|
||||
|
||||
|
||||
def _attachments_from_file_keys(file_keys):
|
||||
normalized_keys = []
|
||||
seen_keys = set()
|
||||
for item in _list(file_keys):
|
||||
key = _normalize_file_key(item)
|
||||
if key not in seen_keys:
|
||||
normalized_keys.append(key)
|
||||
seen_keys.add(key)
|
||||
if not normalized_keys:
|
||||
return []
|
||||
|
||||
attachments = {
|
||||
item.access_key: item
|
||||
for item in Attachment.objects.filter(access_key__in=normalized_keys)
|
||||
}
|
||||
missing_keys = [str(key) for key in normalized_keys if key not in attachments]
|
||||
if missing_keys:
|
||||
raise ValueError(f"Attachment not found for file_key: {', '.join(missing_keys)}")
|
||||
return [attachments[key] for key in normalized_keys]
|
||||
|
||||
|
||||
def _content_type_for_record(record):
|
||||
return ContentType.objects.get_for_model(record, for_concrete_model=False)
|
||||
|
||||
|
||||
def _parent_comment_for_target(content_object, parent_id):
|
||||
if parent_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
parent = Comment.objects.get(pk=int(parent_id))
|
||||
except (TypeError, ValueError, Comment.DoesNotExist) as exc:
|
||||
raise ValueError(f"Parent comment not found: {parent_id}") from exc
|
||||
|
||||
content_type = _content_type_for_record(content_object)
|
||||
if parent.content_type_id != content_type.id or parent.object_id != str(content_object.pk):
|
||||
raise ValueError("parent_id must belong to the same target")
|
||||
return parent
|
||||
|
||||
|
||||
def _mention_users(mentions):
|
||||
users = []
|
||||
seen_ids = set()
|
||||
user_model = get_user_model()
|
||||
for item in _list(mentions):
|
||||
text = str(item or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
user = user_model.objects.filter(username=text).first()
|
||||
if user is None and text.isdigit():
|
||||
user = user_model.objects.filter(pk=int(text)).first()
|
||||
if user is None:
|
||||
raise ValueError(f"Mention user not found: {text}")
|
||||
if user.id not in seen_ids:
|
||||
users.append(user)
|
||||
seen_ids.add(user.id)
|
||||
return users
|
||||
|
||||
|
||||
def list_cases(case_id=None, status=None, severity=None, confidence=None, verdict=None, correlation_uid=None, title=None, tags=None, include_related=True,
|
||||
limit=10):
|
||||
limit=10, include_comments=False, comments_limit=DEFAULT_COMMENTS_LIMIT, ctx: Context = None):
|
||||
queryset = Case.objects.all().order_by("-created_at")
|
||||
if case_id:
|
||||
queryset = queryset.filter(case_id=_record_id(case_id))
|
||||
@@ -213,7 +321,11 @@ def list_cases(case_id=None, status=None, severity=None, confidence=None, verdic
|
||||
if tag_values := _list(tags):
|
||||
for tag in tag_values:
|
||||
queryset = queryset.filter(tags__contains=[tag])
|
||||
return [serialize_case(item, include_related=_bool(include_related)) for item in queryset[:_limit(limit)]]
|
||||
comments_kwargs = _comments_kwargs(include_comments, comments_limit, ctx)
|
||||
return [
|
||||
serialize_case(item, include_related=_bool(include_related), **comments_kwargs)
|
||||
for item in queryset[:_limit(limit)]
|
||||
]
|
||||
|
||||
|
||||
def update_case(case_id, severity_ai=None, confidence_ai=None, impact_ai=None, priority_ai=None, verdict_ai=None, summary=None, ctx: Context = None):
|
||||
@@ -236,19 +348,30 @@ def update_case(case_id, severity_ai=None, confidence_ai=None, impact_ai=None, p
|
||||
return serialize_case(case, include_related=True)
|
||||
|
||||
|
||||
def add_comment(target_id, body, ctx: Context):
|
||||
if not str(body or "").strip():
|
||||
raise ValueError("body is required")
|
||||
def get_file(file_key, ctx: Context = None):
|
||||
return serialize_attachment(_find_attachment_by_file_key(file_key), request=_current_request(ctx))
|
||||
|
||||
|
||||
def add_comment(target_id, body="", file_keys=None, parent_id=None, mentions=None, ctx: Context = None):
|
||||
user = _current_business_writer(ctx)
|
||||
content_object = _find_comment_target(target_id)
|
||||
attachments = _attachments_from_file_keys(file_keys)
|
||||
body_text = str(body or "")
|
||||
if not body_text.strip() and not attachments:
|
||||
raise ValueError("body or file_keys are required")
|
||||
comment = create_record_comment(
|
||||
author=_current_user(ctx),
|
||||
author=user,
|
||||
content_object=content_object,
|
||||
body=str(body),
|
||||
body=body_text,
|
||||
parent=_parent_comment_for_target(content_object, parent_id),
|
||||
mentions=_mention_users(mentions),
|
||||
attachments=attachments,
|
||||
)
|
||||
return serialize_comment(comment)
|
||||
return serialize_comment(comment, request=_current_request(ctx))
|
||||
|
||||
|
||||
def list_alerts(alert_id=None, status=None, severity=None, confidence=None, correlation_uid=None, include_related=False, limit=10):
|
||||
def list_alerts(alert_id=None, status=None, severity=None, confidence=None, correlation_uid=None, include_related=False,
|
||||
limit=10, include_comments=False, comments_limit=DEFAULT_COMMENTS_LIMIT, ctx: Context = None):
|
||||
include_related = _bool(include_related, default=False)
|
||||
queryset = Alert.objects.select_related("case").all().order_by("-created_at")
|
||||
if include_related:
|
||||
@@ -263,10 +386,15 @@ def list_alerts(alert_id=None, status=None, severity=None, confidence=None, corr
|
||||
queryset = queryset.filter(confidence__in=confidence_values)
|
||||
if correlation_uid:
|
||||
queryset = queryset.filter(correlation_uid=correlation_uid)
|
||||
return [serialize_alert(item, include_related=include_related) for item in queryset[:_limit(limit)]]
|
||||
comments_kwargs = _comments_kwargs(include_comments, comments_limit, ctx)
|
||||
return [
|
||||
serialize_alert(item, include_related=include_related, **comments_kwargs)
|
||||
for item in queryset[:_limit(limit)]
|
||||
]
|
||||
|
||||
|
||||
def list_artifacts(artifact_id=None, type=None, role=None, value=None, include_related=False, limit=10):
|
||||
def list_artifacts(artifact_id=None, type=None, role=None, value=None, include_related=False, limit=10,
|
||||
include_comments=False, comments_limit=DEFAULT_COMMENTS_LIMIT, ctx: Context = None):
|
||||
include_related = _bool(include_related, default=False)
|
||||
queryset = Artifact.objects.all().order_by("-created_at")
|
||||
if include_related:
|
||||
@@ -279,7 +407,11 @@ def list_artifacts(artifact_id=None, type=None, role=None, value=None, include_r
|
||||
queryset = queryset.filter(role__in=role_values)
|
||||
if value:
|
||||
queryset = queryset.filter(value=value)
|
||||
return [serialize_artifact(item, include_enrichments=include_related) for item in queryset[:_limit(limit)]]
|
||||
comments_kwargs = _comments_kwargs(include_comments, comments_limit, ctx)
|
||||
return [
|
||||
serialize_artifact(item, include_enrichments=include_related, **comments_kwargs)
|
||||
for item in queryset[:_limit(limit)]
|
||||
]
|
||||
|
||||
|
||||
def create_enrichment(target_id, name="", type="Other", value="", uid="", desc="", data="", ctx: Context = None):
|
||||
@@ -319,7 +451,8 @@ def execute_playbook(name, case_id, user_input=None, ctx: Context = None):
|
||||
return serialize_playbook(playbook)
|
||||
|
||||
|
||||
def list_playbooks(playbook_id=None, job_status=None, case_id=None, include_related=False, limit=10):
|
||||
def list_playbooks(playbook_id=None, job_status=None, case_id=None, include_related=False, limit=10,
|
||||
include_comments=False, comments_limit=DEFAULT_COMMENTS_LIMIT, ctx: Context = None):
|
||||
include_related = _bool(include_related, default=False)
|
||||
queryset = Playbook.objects.select_related("case").all().order_by("-created_at")
|
||||
if playbook_id:
|
||||
@@ -328,7 +461,11 @@ def list_playbooks(playbook_id=None, job_status=None, case_id=None, include_rela
|
||||
queryset = queryset.filter(job_status__in=job_status_values)
|
||||
if case_id:
|
||||
queryset = queryset.filter(case__case_id=_record_id(case_id))
|
||||
return [serialize_playbook(item, include_related=include_related) for item in queryset[:_limit(limit)]]
|
||||
comments_kwargs = _comments_kwargs(include_comments, comments_limit, ctx)
|
||||
return [
|
||||
serialize_playbook(item, include_related=include_related, **comments_kwargs)
|
||||
for item in queryset[:_limit(limit)]
|
||||
]
|
||||
|
||||
|
||||
def update_knowledge(knowledge_id, title=None, body=None, expires_at=None, tags=None, ctx: Context = None):
|
||||
@@ -347,14 +484,18 @@ def update_knowledge(knowledge_id, title=None, body=None, expires_at=None, tags=
|
||||
return serialize_knowledge(knowledge)
|
||||
|
||||
|
||||
def search_knowledge(keyword, limit=10):
|
||||
def search_knowledge(keyword, limit=10, include_comments=False, comments_limit=DEFAULT_COMMENTS_LIMIT, ctx: Context = None):
|
||||
keywords = _list(keyword)
|
||||
queryset = Knowledge.objects.none()
|
||||
for item in keywords:
|
||||
queryset = queryset | Knowledge.objects.filter(title__icontains=item)
|
||||
queryset = queryset | Knowledge.objects.filter(body__icontains=item)
|
||||
queryset = queryset | Knowledge.objects.filter(tags__contains=[item])
|
||||
return [serialize_knowledge(item) for item in queryset.order_by("-created_at")[:_limit(limit)]]
|
||||
comments_kwargs = _comments_kwargs(include_comments, comments_limit, ctx)
|
||||
return [
|
||||
serialize_knowledge(item, **comments_kwargs)
|
||||
for item in queryset.order_by("-created_at")[:_limit(limit)]
|
||||
]
|
||||
|
||||
|
||||
def read_stream_message_by_id(stream_name, message_id):
|
||||
@@ -460,6 +601,7 @@ MCP_TOOL_FUNCTIONS = [
|
||||
read_stream_head,
|
||||
list_cases,
|
||||
update_case,
|
||||
get_file,
|
||||
add_comment,
|
||||
list_alerts,
|
||||
list_artifacts,
|
||||
|
||||
Reference in New Issue
Block a user