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/)
| File | Purpose |
|---|---|
apps/ai_chat/__init__.py | Package init |
apps/ai_chat/apps.py | Django AppConfig |
apps/ai_chat/views.py | Streaming chat endpoint (SSE) |
apps/ai_chat/urls.py | URL routing |
apps/ai_chat/mcp_client.py | MCP client wrapper (connect to local MCP server) |
Frontend (admin SPA)
| File | Purpose |
|---|---|
frontend/apps/admin/src/routes/ai-chat.tsx | TanStack route for /ai-chat |
frontend/apps/admin/src/components/AiChat/ChatPanel.tsx | Main chat UI with useChat |
frontend/apps/admin/src/components/AiChat/MessageBubble.tsx | Message rendering (text + tool calls) |
frontend/apps/admin/src/components/AiChat/ToolCallDisplay.tsx | MCP tool invocation display |
frontend/apps/admin/src/components/AiChat/ChatInput.tsx | Input area with send button |
Config changes
| File | Change |
|---|---|
project/settings.py | Add apps.ai_chat to PROJECT_APPS |
project/urls.py | Add api/v1/ai/ URL include |
frontend/apps/admin/package.json | Add @ai-sdk/react + ai dependencies |
frontend/apps/admin/src/components/layout/data/sidebar-data.ts | Add "AI Chat" nav item |
Task 1: Backend — Create ai_chat Django App Scaffold
Files:
Create:
apps/ai_chat/__init__.pyCreate:
apps/ai_chat/apps.pyCreate:
apps/ai_chat/urls.pyCreate:
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
mkdir -p apps/ai_chat- [ ] Step 2: Create
apps/ai_chat/__init__.py
Empty file.
- [ ] Step 3: Create
apps/ai_chat/apps.py
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
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
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"):
"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"))):
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
"""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:
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/):
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.tsxCreate:
frontend/apps/admin/src/components/AiChat/MessageBubble.tsxCreate:
frontend/apps/admin/src/components/AiChat/ToolCallDisplay.tsxCreate:
frontend/apps/admin/src/components/AiChat/ChatInput.tsx[ ] Step 1: Create
ToolCallDisplay.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
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
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
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.tsxModify:
frontend/apps/admin/src/components/layout/data/sidebar-data.ts[ ] Step 1: Create
ai-chat.tsxroute
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):
import {
// ... existing imports ...
Bot,
} from 'lucide-react';Add to the "Sistema" nav group items (after the Settings item):
{ 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).