Skip to content

AI Chat with MCP Integration — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add an AI-powered chat to the admin panel that connects to the existing MCP server, giving admins a conversational interface to manage their community.

Architecture: A Django streaming endpoint (/api/v1/ai/chat/) acts as a proxy between the admin frontend and the MCP server. It connects to the local MCP server as a client, attaches all 35 MCP tools to an LLM, and streams the response back via SSE. The admin frontend uses Vercel AI SDK's useChat hook with a custom transport to consume the stream.

Tech Stack: Python (FastMCP client, OpenAI SDK, Django StreamingHttpResponse), TypeScript (Vercel AI SDK @ai-sdk/react, useChat, DefaultChatTransport), shadcn/ui components.


File Structure

Backend (new Django app: apps/ai_chat/)

FilePurpose
apps/ai_chat/__init__.pyPackage init
apps/ai_chat/apps.pyDjango AppConfig
apps/ai_chat/views.pyStreaming chat endpoint (SSE)
apps/ai_chat/urls.pyURL routing
apps/ai_chat/mcp_client.pyMCP client wrapper (connect to local MCP server)

Frontend (admin SPA)

FilePurpose
frontend/apps/admin/src/routes/ai-chat.tsxTanStack route for /ai-chat
frontend/apps/admin/src/components/AiChat/ChatPanel.tsxMain chat UI with useChat
frontend/apps/admin/src/components/AiChat/MessageBubble.tsxMessage rendering (text + tool calls)
frontend/apps/admin/src/components/AiChat/ToolCallDisplay.tsxMCP tool invocation display
frontend/apps/admin/src/components/AiChat/ChatInput.tsxInput area with send button

Config changes

FileChange
project/settings.pyAdd apps.ai_chat to PROJECT_APPS
project/urls.pyAdd api/v1/ai/ URL include
frontend/apps/admin/package.jsonAdd @ai-sdk/react + ai dependencies
frontend/apps/admin/src/components/layout/data/sidebar-data.tsAdd "AI Chat" nav item

Task 1: Backend — Create ai_chat Django App Scaffold

Files:

  • Create: apps/ai_chat/__init__.py

  • Create: apps/ai_chat/apps.py

  • Create: apps/ai_chat/urls.py

  • Create: apps/ai_chat/views.py (placeholder)

  • Modify: project/settings.py:85-94 (add to PROJECT_APPS)

  • Modify: project/urls.py:67-71 (add URL include)

  • [ ] Step 1: Create app directory and files

bash
mkdir -p apps/ai_chat
  • [ ] Step 2: Create apps/ai_chat/__init__.py

Empty file.

  • [ ] Step 3: Create apps/ai_chat/apps.py
python
from django.apps import AppConfig


class AiChatConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "apps.ai_chat"
    verbose_name = "AI Chat"
  • [ ] Step 4: Create apps/ai_chat/urls.py
python
from django.urls import path

from apps.ai_chat.views import chat_stream

app_name = "ai_chat"

urlpatterns = [
    path("chat/", chat_stream, name="chat-stream"),
]
  • [ ] Step 5: Create placeholder apps/ai_chat/views.py
python
from django.http import StreamingHttpResponse


def chat_stream(request):
    """Streaming AI chat endpoint — implemented in Task 2."""
    return StreamingHttpResponse(iter(["Not implemented"]), content_type="text/event-stream")
  • [ ] Step 6: Register app in project/settings.py

Add to PROJECT_APPS list (after "apps.mcp_server.apps.MCPServerConfig"):

python
    "apps.ai_chat.apps.AiChatConfig",
  • [ ] Step 7: Add URL include in project/urls.py

Add after line 71 (path("api/v1/", include("apps.drive.urls"))):

python
    path("api/v1/", include("apps.ai_chat.urls")),
  • [ ] Step 8: Verify Django starts

Run: make django (or python manage.py check) Expected: No errors.


Task 2: Backend — MCP Client Wrapper

Files:

  • Create: apps/ai_chat/mcp_client.py

  • [ ] Step 1: Create apps/ai_chat/mcp_client.py

python
"""MCP client that connects to the local FastMCP server and exposes its tools."""

import json
import os
from typing import Any

import httpx
from openai import OpenAI


MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://localhost:8000/mcp")


class MCPProxy:
    """Connects to the local MCP server via HTTP and forwards tool calls."""

    def __init__(self, mcp_api_key: str):
        self.api_key = mcp_api_key
        self.base_url = MCP_SERVER_URL
        self.headers = {
            "Authorization": f"Bearer {mcp_api_key}",
            "Content-Type": "application/json",
        }
        self._tools_cache: list[dict] | None = None

    def _rpc(self, method: str, params: dict | None = None) -> Any:
        """Send a JSON-RPC request to the MCP server."""
        payload = {
            "jsonrpc": "2.0",
            "id": 1,
            "method": method,
            "params": params or {},
        }
        with httpx.Client(timeout=30) as client:
            resp = client.post(self.base_url, json=payload, headers=self.headers)
            resp.raise_for_status()
            result = resp.json()
            if "error" in result:
                raise RuntimeError(f"MCP error: {result['error']}")
            return result.get("result")

    def list_tools(self) -> list[dict]:
        """List all available MCP tools and convert to OpenAI function format."""
        if self._tools_cache is not None:
            return self._tools_cache

        result = self._rpc("tools/list")
        tools = []
        for tool in result.get("tools", []):
            openai_tool = {
                "type": "function",
                "function": {
                    "name": tool["name"],
                    "description": tool.get("description", ""),
                    "parameters": tool.get("inputSchema", {"type": "object", "properties": {}}),
                },
            }
            tools.append(openai_tool)

        self._tools_cache = tools
        return tools

    def call_tool(self, name: str, arguments: dict) -> str:
        """Call an MCP tool and return the result as a string."""
        result = self._rpc("tools/call", {"name": name, "arguments": arguments})
        content = result.get("content", [])
        texts = [item.get("text", "") for item in content if item.get("type") == "text"]
        return "\n".join(texts) if texts else json.dumps(result)
  • [ ] Step 2: Verify syntax

Run: python -c "from apps.ai_chat.mcp_client import MCPProxy; print('OK')" Expected: OK


Task 3: Backend — Streaming Chat View

Files:

  • Modify: apps/ai_chat/views.py

  • [ ] Step 1: Implement the streaming chat view

Replace the placeholder in apps/ai_chat/views.py with:

python
import json
import os
from typing import Any

from django.http import StreamingHttpResponse
from django.views.decorators.http import require_POST
from openai import OpenAI

from apps.ai_chat.mcp_client import MCPProxy
from apps.users.models import CustomUser

OPENAI_MODEL = os.environ.get("LLM_MODEL", "gpt-4o")
SYSTEM_PROMPT = (
    "You are an AI assistant for a community management platform. "
    "You have access to MCP tools to manage the community: posts, comments, "
    "events, courses, chat, moderation, members, and more. "
    "Use the available tools to help the admin manage their community. "
    "Always confirm destructive actions (ban, delete, mute) before executing them. "
    "Respond in the same language the user writes in."
)


def _get_mcp_api_key_for_user(user: CustomUser) -> str | None:
    """Find or create an MCP API key for this user's active membership."""
    from apps.mcp_server.models import MCPApiKey
    from apps.communities.models import Membership

    membership = Membership.objects.filter(
        user=user, status="active", role__in=("admin", "moderator")
    ).first()
    if not membership:
        return None

    existing_key = MCPApiKey.objects.filter(
        membership=membership, revoked_at__isnull=True
    ).first()
    if existing_key:
        return None

    _, raw_key = MCPApiKey.create_key(membership=membership, name=f"AI Chat ({user.email})")
    return raw_key


def _get_or_create_mcp_key(user: CustomUser) -> str | None:
    """Get an existing MCP API key or create one for the user's admin/mod membership."""
    from apps.mcp_server.models import MCPApiKey
    from apps.communities.models import Membership

    membership = Membership.objects.filter(
        user=user, status="active", role__in=("admin", "moderator")
    ).first()
    if not membership:
        return None

    existing = MCPApiKey.objects.filter(
        membership=membership, revoked_at__isnull=True
    ).first()
    if existing:
        raw_key = MCPApiKey.create_key(membership=membership, name=f"AI Chat ({user.email})")
        return raw_key[1]

    _, raw_key = MCPApiKey.create_key(membership=membership, name=f"AI Chat ({user.email})")
    return raw_key


def _format_messages(messages: list[dict]) -> list[dict]:
    """Convert frontend message format to OpenAI format."""
    formatted = []
    for msg in messages:
        role = msg.get("role", "user")
        content = msg.get("content", "")
        if role in ("user", "assistant") and content:
            formatted.append({"role": role, "content": content})
    return formatted


@require_POST
def chat_stream(request):
    """Stream AI chat response with MCP tool integration."""
    try:
        body = json.loads(request.body)
    except json.JSONDecodeError:
        return StreamingHttpResponse(
            iter(["data: {}\n\n".format(json.dumps({"error": "Invalid JSON"}))]),
            content_type="text/event-stream",
        )

    messages = body.get("messages", [])
    user = request.user

    if not user.is_authenticated:
        return StreamingHttpResponse(
            iter(["data: {}\n\n".format(json.dumps({"error": "Authentication required"}))]),
            content_type="text/event-stream",
        )

    mcp_key = _get_or_create_mcp_key(user)
    if not mcp_key:
        return StreamingHttpResponse(
            iter(["data: {}\n\n".format(json.dumps({
                "error": "No active admin/moderator membership found. Create an MCP API key in the admin panel first."
            }))]),
            content_type="text/event-stream",
        )

    def generate():
        client = OpenAI()
        mcp = MCPProxy(mcp_api_key=mcp_key)

        try:
            tools = mcp.list_tools()
        except Exception as e:
            yield f"data: {json.dumps({'error': f'Failed to connect to MCP server: {e}'})}\n\n"
            return

        formatted_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
        formatted_messages.extend(_format_messages(messages))

        try:
            response = client.chat.completions.create(
                model=OPENAI_MODEL,
                messages=formatted_messages,
                tools=tools if tools else None,
                stream=True,
            )

            current_tool_calls: dict[int, dict] = {}

            for chunk in response:
                if not chunk.choices:
                    continue

                delta = chunk.choices[0].delta

                if delta.content:
                    yield f"data: {json.dumps({'type': 'text', 'content': delta.content})}\n\n"

                if delta.tool_calls:
                    for tc in delta.tool_calls:
                        idx = tc.index
                        if idx not in current_tool_calls:
                            current_tool_calls[idx] = {
                                "id": tc.id or "",
                                "name": "",
                                "arguments": "",
                            }
                        if tc.id:
                            current_tool_calls[idx]["id"] = tc.id
                        if tc.function:
                            if tc.function.name:
                                current_tool_calls[idx]["name"] = tc.function.name
                            if tc.function.arguments:
                                current_tool_calls[idx]["arguments"] += tc.function.arguments

                if chunk.choices[0].finish_reason == "tool_calls":
                    yield f"data: {json.dumps({'type': 'tool_calls_start', 'count': len(current_tool_calls)})}\n\n"

                    tool_results = []
                    for idx in sorted(current_tool_calls.keys()):
                        tc = current_tool_calls[idx]
                        tool_name = tc["name"]
                        try:
                            tool_args = json.loads(tc["arguments"]) if tc["arguments"] else {}
                        except json.JSONDecodeError:
                            tool_args = {}

                        yield f"data: {json.dumps({'type': 'tool_call', 'id': tc['id'], 'name': tool_name, 'arguments': tool_args})}\n\n"

                        try:
                            result = mcp.call_tool(tool_name, tool_args)
                        except Exception as e:
                            result = f"Error calling tool: {e}"

                        yield f"data: {json.dumps({'type': 'tool_result', 'id': tc['id'], 'name': tool_name, 'result': result})}\n\n"

                        tool_results.append({
                            "role": "tool",
                            "tool_call_id": tc["id"],
                            "content": result,
                        })

                    formatted_messages.append({
                        "role": "assistant",
                        "content": None,
                        "tool_calls": [
                            {
                                "id": tc["id"],
                                "type": "function",
                                "function": {
                                    "name": tc["name"],
                                    "arguments": tc["arguments"],
                                },
                            }
                            for tc in current_tool_calls.values()
                        ],
                    })
                    formatted_messages.extend(tool_results)

                    current_tool_calls = {}

                    second_response = client.chat.completions.create(
                        model=OPENAI_MODEL,
                        messages=formatted_messages,
                        tools=tools if tools else None,
                        stream=True,
                    )

                    for chunk2 in second_response:
                        if not chunk2.choices:
                            continue
                        delta2 = chunk2.choices[0].delta
                        if delta2.content:
                            yield f"data: {json.dumps({'type': 'text', 'content': delta2.content})}\n\n"

            yield f"data: {json.dumps({'type': 'done'})}\n\n"

        except Exception as e:
            yield f"data: {json.dumps({'error': str(e)})}\n\n"

    return StreamingHttpResponse(generate(), content_type="text/event-stream")
  • [ ] Step 2: Verify syntax

Run: python -c "from apps.ai_chat.views import chat_stream; print('OK')" Expected: OK


Task 4: Frontend — Install Vercel AI SDK Dependencies

Files:

  • Modify: frontend/apps/admin/package.json

  • [ ] Step 1: Install AI SDK packages

Run (in frontend/apps/admin/):

bash
bun add ai @ai-sdk/react @ai-sdk/openai
  • [ ] Step 2: Verify installation

Run: bun run tsc --noEmit (or just check package.json has the deps) Expected: No errors.


Task 5: Frontend — Chat Components

Files:

  • Create: frontend/apps/admin/src/components/AiChat/ChatPanel.tsx

  • Create: frontend/apps/admin/src/components/AiChat/MessageBubble.tsx

  • Create: frontend/apps/admin/src/components/AiChat/ToolCallDisplay.tsx

  • Create: frontend/apps/admin/src/components/AiChat/ChatInput.tsx

  • [ ] Step 1: Create ToolCallDisplay.tsx

tsx
import { useState } from 'react';
import { ChevronDown, ChevronRight, Wrench, CheckCircle, XCircle } from 'lucide-react';
import { Badge } from '@repo/ui';

interface ToolCallProps {
  name: string;
  arguments: Record<string, unknown>;
  result?: string;
}

export function ToolCallDisplay({ name, arguments: args, result }: ToolCallProps) {
  const [isOpen, setIsOpen] = useState(false);
  const hasError = result?.startsWith('Error') ?? false;

  return (
    <div className="my-2 rounded-lg border border-border bg-muted/50 text-sm">
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-muted/80 transition-colors"
      >
        {isOpen ? (
          <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
        ) : (
          <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
        )}
        <Wrench className="h-4 w-4 shrink-0 text-blue-500" />
        <span className="font-mono font-medium">{name}</span>
        {result !== undefined && (
          hasError ? (
            <XCircle className="ml-auto h-4 w-4 text-red-500" />
          ) : (
            <CheckCircle className="ml-auto h-4 w-4 text-green-500" />
          )
        )}
      </button>
      {isOpen && (
        <div className="border-t border-border px-3 py-2 space-y-2">
          {Object.keys(args).length > 0 && (
            <div>
              <span className="text-xs font-medium text-muted-foreground">Arguments:</span>
              <pre className="mt-1 overflow-x-auto rounded bg-background p-2 text-xs">
                {JSON.stringify(args, null, 2)}
              </pre>
            </div>
          )}
          {result !== undefined && (
            <div>
              <span className="text-xs font-medium text-muted-foreground">Result:</span>
              <pre className="mt-1 overflow-x-auto rounded bg-background p-2 text-xs max-h-48 overflow-y-auto">
                {result}
              </pre>
            </div>
          )}
        </div>
      )}
    </div>
  );
}
  • [ ] Step 2: Create MessageBubble.tsx
tsx
import { Bot, User } from 'lucide-react';
import type { UIMessage } from 'ai';
import { ToolCallDisplay } from './ToolCallDisplay';

interface MessageBubbleProps {
  message: UIMessage;
}

export function MessageBubble({ message }: MessageBubbleProps) {
  const isUser = message.role === 'user';

  return (
    <div className={`flex gap-3 ${isUser ? 'justify-end' : 'justify-start'}`}>
      {!isUser && (
        <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
          <Bot className="h-4 w-4" />
        </div>
      )}
      <div
        className={`max-w-[80%] rounded-xl px-4 py-3 ${
          isUser
            ? 'bg-primary text-primary-foreground'
            : 'bg-muted'
        }`}
      >
        {message.parts.map((part, index) => {
          if (part.type === 'text') {
            return (
              <div key={index} className="whitespace-pre-wrap text-sm leading-relaxed">
                {part.text}
              </div>
            );
          }
          if (part.type === 'tool-invocation') {
            return (
              <ToolCallDisplay
                key={index}
                name={part.toolInvocation.toolName}
                arguments={part.toolInvocation.args as Record<string, unknown>}
                result={
                  part.toolInvocation.state === 'result'
                    ? (part.toolInvocation.result as string) ?? JSON.stringify(part.toolInvocation.result)
                    : undefined
                }
              />
            );
          }
          return null;
        })}
      </div>
      {isUser && (
        <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
          <User className="h-4 w-4" />
        </div>
      )}
    </div>
  );
}
  • [ ] Step 3: Create ChatInput.tsx
tsx
import { useState, type KeyboardEvent } from 'react';
import { Send, Loader2 } from 'lucide-react';
import { Button, Textarea } from '@repo/ui';

interface ChatInputProps {
  onSend: (text: string) => void;
  disabled: boolean;
}

export function ChatInput({ onSend, disabled }: ChatInputProps) {
  const [input, setInput] = useState('');

  const handleSubmit = () => {
    if (input.trim() && !disabled) {
      onSend(input.trim());
      setInput('');
    }
  };

  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSubmit();
    }
  };

  return (
    <div className="flex items-end gap-2 border-t border-border p-4">
      <Textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Pergunte algo sobre sua comunidade..."
        disabled={disabled}
        rows={1}
        className="min-h-[44px] max-h-32 resize-none"
      />
      <Button
        onClick={handleSubmit}
        disabled={disabled || !input.trim()}
        size="icon"
        className="h-[44px] w-[44px] shrink-0"
      >
        {disabled ? (
          <Loader2 className="h-4 w-4 animate-spin" />
        ) : (
          <Send className="h-4 w-4" />
        )}
      </Button>
    </div>
  );
}
  • [ ] Step 4: Create ChatPanel.tsx
tsx
import { useRef, useEffect } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { getAdminAccessToken } from '@/contexts/AuthContext';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { Bot, Trash2 } from 'lucide-react';
import { Button } from '@repo/ui';

export function ChatPanel() {
  const scrollRef = useRef<HTMLDivElement>(null);

  const { messages, sendMessage, status, setMessages } = useChat({
    transport: new DefaultChatTransport({
      api: '/api/v1/ai/chat/',
      headers: () => {
        const token = getAdminAccessToken();
        return token ? { Authorization: `Bearer ${token}` } : {};
      },
    }),
  });

  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages]);

  const handleSend = (text: string) => {
    sendMessage({ text });
  };

  const handleClear = () => {
    setMessages([]);
  };

  return (
    <div className="flex h-full flex-col">
      <div className="flex items-center justify-between border-b border-border px-4 py-3">
        <div className="flex items-center gap-2">
          <Bot className="h-5 w-5 text-primary" />
          <h2 className="text-sm font-semibold">AI Assistant</h2>
        </div>
        {messages.length > 0 && (
          <Button
            variant="ghost"
            size="sm"
            onClick={handleClear}
            className="h-8 text-muted-foreground"
          >
            <Trash2 className="h-4 w-4 mr-1" />
            Limpar
          </Button>
        )}
      </div>

      <div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.length === 0 && (
          <div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground">
            <Bot className="h-12 w-12 mb-4 opacity-50" />
            <h3 className="text-lg font-medium mb-1">Como posso ajudar?</h3>
            <p className="text-sm max-w-sm">
              Pergunte sobre sua comunidade, gerencie membros, crie posts,
              ou qualquer coisa que precise.
            </p>
          </div>
        )}
        {messages.map((message) => (
          <MessageBubble key={message.id} message={message} />
        ))}
      </div>

      <ChatInput onSend={handleSend} disabled={status === 'streaming' || status === 'submitted'} />
    </div>
  );
}

Task 6: Frontend — Route and Navigation

Files:

  • Create: frontend/apps/admin/src/routes/ai-chat.tsx

  • Modify: frontend/apps/admin/src/components/layout/data/sidebar-data.ts

  • [ ] Step 1: Create ai-chat.tsx route

tsx
import { createFileRoute } from '@tanstack/react-router';
import { ChatPanel } from '@/components/AiChat/ChatPanel';

export const Route = createFileRoute('/ai-chat')({
  component: AiChatPage,
});

function AiChatPage() {
  return (
    <div className="h-[calc(100vh-4rem)]">
      <ChatPanel />
    </div>
  );
}
  • [ ] Step 2: Add nav item to sidebar

In frontend/apps/admin/src/components/layout/data/sidebar-data.ts, add import for Bot from lucide-react and add the nav item.

Add to imports (line 1-21):

typescript
import {
  // ... existing imports ...
  Bot,
} from 'lucide-react';

Add to the "Sistema" nav group items (after the Settings item):

typescript
        { title: 'AI Chat', url: '/ai-chat', icon: Bot },
  • [ ] Step 3: Verify route generation

Run (in frontend/apps/admin/): bun run tsc --noEmit Expected: No errors.


Task 7: Verify End-to-End

  • [ ] Step 1: Start Django and verify endpoint exists

Run: make django Test: curl -X POST http://localhost:8000/api/v1/ai/chat/ -H "Content-Type: application/json" -d '{"messages":[]}' Expected: 401 (unauthenticated) or streaming response.

  • [ ] Step 2: Start admin frontend and verify route

Run: make f-dev (or bun run dev in frontend/apps/admin/) Navigate to: http://localhost:3001/ai-chat Expected: Chat UI renders with empty state.

  • [ ] Step 3: Test chat flow (requires LLM_API_KEY env var)

Set OPENAI_API_KEY in .env, send a message in the chat. Expected: Streaming response with AI text, possible tool calls displayed.

  • [ ] Step 4: Run Django tests

Run: make test ARGS='apps.ai_chat' Expected: All tests pass (or no tests yet — that's fine for initial scaffold).

Strum — Documentação.