From e279a81f76aec21105d27511ce5abdbd964cd7aa Mon Sep 17 00:00:00 2001 From: funnywolf Date: Wed, 1 Jul 2026 20:19:57 +0800 Subject: [PATCH] Implement MCP comment attachment access Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- TODO.md | 1 - backend/apps/mcp/serializers.py | 143 +++++++++++++++++++++++--- backend/apps/mcp/tools.py | 176 +++++++++++++++++++++++++++++--- 3 files changed, 285 insertions(+), 35 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 161630e..0000000 --- a/TODO.md +++ /dev/null @@ -1 +0,0 @@ -固定列配置用户可配置 \ No newline at end of file diff --git a/backend/apps/mcp/serializers.py b/backend/apps/mcp/serializers.py index 324a9f9..4ed3278 100644 --- a/backend/apps/mcp/serializers.py +++ b/backend/apps/mcp/serializers.py @@ -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) diff --git a/backend/apps/mcp/tools.py b/backend/apps/mcp/tools.py index e40a499..7df6e92 100644 --- a/backend/apps/mcp/tools.py +++ b/backend/apps/mcp/tools.py @@ -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,