Multi Agent Design
Multi Agent Design
1. Overview
Problem
Erlang code
Neo4j graph (9 layers) erlang_code_graph MCP server (10 tools)
structure
Function ChromaDB
erlang_code_graph MCP semantic_search tool
descriptions (erlang_function_summaries)
ChromaDB
JIRA tickets (jira_embeddings_12_10_t1) + rag-research Hybrid search inside RAGChat
REST API
ChromaDB
Confluence docs rag-research Hybrid search inside RAGChat
(confluence_embeddings_12_10_t1)
Users can only query JIRA + Confluence through the chat-ui. Code graph requires a separate MCP client. There is no
unified interface that can reason across all sources simultaneously.
Solution
An MCP-first multi-agent architecture where: - Every data source is an MCP server exposing well-defined tools
- A LangGraph orchestrator acts as an MCP client to all servers - An intent router classifies queries and activates
the right agent(s) - A synthesizer merges multi-source results into a coherent streamed response - The existing
chat-ui connects to the orchestrator with zero protocol changes
2. Architecture
+-----------------------+
| chat-ui |
| (React 19 + SSE) |
+----------+------------+
|
POST /chat/query/stream
|
+----------v------------+
| LangGraph |
| Orchestrator |
| (FastAPI + MCP |
| Client) |
+----------+------------+
|
+----------------+----------------+
| | |
+------v------+ +-----v-------+ +-----v--------+
| JIRA MCP | | Confluence | | Code Graph |
| Server | | MCP Server | | MCP Server |
| (NEW) | | (NEW) | | (EXISTING+5) |
+------+------+ +-----+-------+ +-----+--------+
| | |
+------v------+ +-----v-------+ +-----v--------+
| ChromaDB | | ChromaDB | | Neo4j + |
| (JIRA coll) | | (Confluence | | ChromaDB |
| + REST API | | coll) | | (summaries) |
+-------------+ +-------------+ +--------------+
Transport & Deployment
SSE transport chosen over stdio because: - Works over HTTP (Docker service discovery, health checks) - MCP SDK
supports it natively via sse_client() - Allows multiple clients (monitoring, testing) - Proper process isolation and
crash recovery
Location: mcp-servers/jira/jira_mcp_server.py
Tools
{metadata,
comments[],
get_ticket_details ticket_id: str extract_detailed_ticket_data() from same file
changelog[],
success}
{query, total,
query: str, results[{id, ChromaDB jira_embeddings_12_10_t1 collection vector
search_jira_semantic
top_k: int=10 document, metadata, search
relevance_score}]}
jql: str,
{success,
max_results:
execute_jql tickets[], Direct JIRA REST API via jira library
int=10, fields:
total_found}
list[str]
Implementation Notes
Uses FastMCP from [Link] (same pattern as existing code graph server)
Requires: JIRA_SERVER, JIRA_API_TOKEN, GEMINI_API_KEY, CHROMA_DB_HOST, CHROMA_DB_PORT
search_jira is the primary tool – it generates JQL via Gemini Flash, executes search, enriches results
search_jira_semantic provides vector-based fallback when JQL generation fails or for similarity queries
execute_jql is for advanced users / when the router agent wants precise control
Location: mcp-servers/confluence/confluence_mcp_server.py
Tools
{query, total,
query: str, results[{id,
top_k: document,
[Link]() from rag-
search_confluence int=10, metadata,
research/src/providers/retrieval/enhanced_hybrid_search.py
expand_query: rrf_score,
bool=False bm25_score,
semantic_score}]}
{query, total,
results[{id,
query: str, document, [Link]() from rag-
search_confluence_reranked
top_k: int=10 metadata, research/src/providers/retrieval/[Link]
rerank_score,
hybrid_score}]}
{id, document,
get_page_content page_id: str ChromaDB [Link](ids=[page_id])
metadata}
Implementation Notes
Loads ChromaDB collection and builds BM25 index at startup (30-60s one-time cost)
Uses text-embedding-004 (768-dim) for retrieval queries (matches existing collection)
search_confluence is the primary tool (BM25 + semantic + RRF + MMR)
search_confluence_reranked adds cross-encoder reranking (ms-marco-MiniLM-L-6-v2) for precision
Requires: CHROMA_DB_HOST, CHROMA_DB_PORT, GEMINI_API_KEY (for embeddings)
{
"source_type": "confluence",
"page_id": "123456789",
"space_key": "DEV",
"rrf_score": 0.85
}
@[Link]()
def get_endpoints_for_query(
query: str, # Module name, URL path fragment, or keyword
include_storage: bool = True # Include storage (Mnesia/ETS/Postgres) touched by endpoint
) -> str:
"""Find HTTP/REST endpoints defined in the codebase, optionally showing which databases they touch."""
Cypher query:
MATCH (e:Endpoint)<-[:EXPOSES]-(s:APIService)
WHERE [Link] CONTAINS $query OR e.handler_module CONTAINS $query
OPTIONAL MATCH (e)-[:HANDLED_BY]->(f:Function)-[:CALLS*1..3]->(f2:Function)-[r:READS_FROM|WRITES_TO]->(db)
RETURN [Link], [Link], e.handler_module, e.handler_function,
collect(DISTINCT {name: [Link], type: labels(db)[0], access: type(r)}) AS storage
@[Link]()
def get_functions_by_storage(
storage_name: str, # Table/index name (e.g., "ppsbinrec", "order_items")
operation: str = "both" # "read", "write", or "both"
) -> str:
"""Find all functions that read from or write to a specific database table, ETS table, or Elastic index."""
Cypher query:
MATCH (f:Function)-[r:READS_FROM|WRITES_TO]->(db)
WHERE [Link] CONTAINS $storage_name
AND ($operation = 'both' OR
($operation = 'read' AND type(r) = 'READS_FROM') OR
($operation = 'write' AND type(r) = 'WRITES_TO'))
RETURN [Link], [Link], type(r) AS access_type, [Link] AS storage
@[Link]()
def get_git_history_for_function(
function: str, # MFA string
max_commits: int = 20
) -> str:
"""Get git commit history for a specific function, with JIRA ticket extraction from commit messages."""
@[Link]()
def get_message_flow(
target: str, # Module name or MFA string
include_kafka: bool = True
) -> str:
"""Trace internal Erlang message passing and Kafka topic flows for a module or function."""
// Internal messaging
MATCH (f:Function {module: $module})-[:EMITS]->(mt:MessageType)
MATCH (f2:Function)-[:HANDLES_MSG]->(mt)
RETURN [Link], [Link] AS emitter, [Link] AS handler
// Kafka topics
MATCH (f:Function {module: $module})-[:PUBLISHES_TO]->(t:Topic)<-[:HAS_TOPIC]-(mb:MessageBroker)
RETURN [Link], [Link] AS publisher, [Link] AS broker
@[Link]()
def hybrid_semantic_structural_search(
query: str, # Natural language description
module_filter: str = None, # Restrict to module
has_storage: str = None, # Must access this storage
is_otp_callback: bool = None, # Must be OTP callback
top_k: int = 10
) -> str:
"""Combine semantic search (ChromaDB) with Neo4j structural filters. Find functions matching a description that
also satisfy structural constraints."""
4. LangGraph Orchestrator
# orchestrator/graph/[Link]
class AgentState(TypedDict):
# ── Input (set by entry node) ──
user_query: str
chat_id: str
user_email: str
chat_history: list[dict] # [{role, content}] from MySQL
# ── Router output ──
rewritten_query: str
intent: str # "jira" | "confluence" | "code" | "multi" | "general"
selected_servers: list[str] # ["jira", "confluence", "code"]
routing_reasoning: str
# ── Synthesizer output ──
answer: str
metadata: list[dict] # Flat source list for UI
message_id: Optional[int]
# ── Error tracking ──
errors: list[dict] # [{server, error, timestamp}]
File: orchestrator/graph/nodes/[Link]
Responsibilities: 1. Create or validate chat session in MySQL (reuse MySQLConnection from rag-
File: orchestrator/graph/nodes/[Link]
Single LLM call to Gemini 2.5 Flash (fast, cheap) with structured JSON output:
Router Prompt
You are a query router for a knowledge platform with three data sources:
3. **Code Graph** - Erlang/OTP codebase structure, functions, modules, call chains, state machines, impact analysis
Trigger keywords: function, module, handle_call, gen_server, OTP, Erlang, call chain, impact, state machine,
database table, API endpoint, code
Return JSON:
{
"intent": "jira|confluence|code|multi|general",
"selected_servers": ["jira", "confluence", "code"],
"rewritten_query": "optimized search query",
"reasoning": "brief explanation"
}
Each agent node: 1. Connects to its MCP server via SSE client session 2. Dynamically discovers available tools via
session.list_tools() 3. Uses a ReAct loop (LLM decides which tool to call, processes result, decides next action) 4.
Max 3 iterations to bound latency 5. Returns structured results + tool call trace
class MCPSessionManager:
"""Manages persistent MCP SSE client sessions to all servers."""
async def call_tool(self, server_name: str, tool_name: str, arguments: dict) -> str:
session = await self.get_session(server_name)
result = await session.call_tool(tool_name, arguments)
return [Link][0].text # MCP returns TextContent
# orchestrator/mcp_client/tool_adapter.py
class MCPToolAdapter(BaseTool):
"""Wraps an MCP tool as a LangChain tool for use in LangGraph agents."""
name: str
description: str
mcp_session_manager: MCPSessionManager
server_name: str
mcp_tool_name: str
input_schema: dict # JSON Schema from MCP tool definition
# orchestrator/graph/nodes/jira_agent.py
session_manager = config["configurable"]["mcp_sessions"]
tools = await get_tools_for_server(session_manager, "jira")
The synthesizer is the only node that calls the main LLM (Gemini 2.5 Pro) and the only node that streams.
Multi-Source Prompt
Previous conversation:
{chat_history}
Abbreviations: {abbreviations}
Instructions:
- Synthesize information from ALL provided source sections
- When citing code structure, reference function names in module:function/arity format
- When citing documentation, mention the Confluence page
- When citing tickets, mention the JIRA issue key
- If a data source was unavailable, mention it briefly
- Use GitHub-Flavored Markdown formatting
- Be concise and direct
Metadata Assembly
The synthesizer merges sources from all agents into a flat list with source_type discriminant:
all_sources = []
if [Link]("jira_results"):
all_sources.extend(state["jira_results"]["sources"])
if [Link]("confluence_results"):
all_sources.extend(state["confluence_results"]["sources"])
if [Link]("code_results"):
all_sources.extend(state["code_results"]["sources"])
Source type values: - "jira" / "jira_mcp" – JIRA tickets (existing) - "confluence" – Confluence pages (existing) -
"code_graph_function" – Individual function info (new) - "code_graph_module" – Module overview (new) -
"code_graph_chain" – Call chain result (new) - "code_graph_impact" – Impact analysis (new)
File: orchestrator/graph/nodes/[Link]
The current chat-ui SSE parser ( [Link] lines 324-351) only checks for [Link] , [Link] , parsed.chat_id ,
[Link] , parsed.message_id . Unknown keys like agent_status are silently ignored. Zero frontend changes
required for basic functionality.
Streaming Implementation
# orchestrator/[Link]
@[Link]("/chat/query/stream")
async def query_stream(request: ChatQueryRequest, user = Depends(get_current_user)):
async def generate():
# 1. Entry: session setup
session_id = await ensure_session(request.chat_id, [Link])
yield sse_event({"chat_id": session_id})
Health Checks
Each MCP server exposes a health endpoint. The orchestrator checks on startup and periodically:
// Extended request
export type ChatRequest = {
query: string;
chat_id?: string;
include_jira_source?: boolean;
include_code_graph?: boolean; // NEW toggle
};
// In postChatStream signature:
onAgentStatus?: (status: AgentStatus) => void;
orchestrator/[Link]
mcp_servers:
jira:
url: "[Link]
enabled: true
timeout_seconds: 30
confluence:
url: "[Link]
enabled: true
timeout_seconds: 30
code_graph:
url: "[Link]
enabled: true
timeout_seconds: 30
langgraph:
router_model: "gemini-2.5-flash"
synthesis_model: "gemini-2.5-pro"
max_agent_iterations: 3
parallel_agents: true
query_rewriting: true
session:
mysql_host: "${MYSQL_DB_HOST}"
mysql_port: 3306
mysql_user: "${MYSQL_DB_USER}"
mysql_password: "${MYSQL_DB_PASSWORD}"
mysql_database: "butler_ai"
auth:
jwt_secret: "${JWT_SECRET}"
allowed_email_domain: "[Link]"
Environment Variables
# LLM
GEMINI_API_KEY=...
# Session DB
MYSQL_DB_HOST=[Link]
MYSQL_DB_PASSWORD=...
JWT_SECRET=...
9. File Structure
greymatter-ai/
mcp-servers/ # NEW
jira/
jira_mcp_server.py # FastMCP server (4 tools)
[Link]
Dockerfile
.[Link]
confluence/
confluence_mcp_server.py # FastMCP server (3 tools)
[Link]
Dockerfile
.[Link]
orchestrator/ # NEW
[Link] # FastAPI entry point
[Link] # Config loader
[Link] # Configuration
graph/
__init__.py
[Link] # AgentState TypedDict
[Link] # StateGraph assembly
[Link] # Conditional routing logic
nodes/
__init__.py
[Link] # Session + user message persistence
[Link] # Intent classifier (Gemini Flash)
jira_agent.py # JIRA MCP client ReAct agent
confluence_agent.py # Confluence MCP client ReAct agent
code_agent.py # Code Graph MCP client ReAct agent
[Link] # Multi-source prompt + Gemini Pro streaming
[Link] # Assistant message persistence
mcp_client/
__init__.py
session_manager.py # MCP SSE client lifecycle
tool_adapter.py # MCP tool -> LangChain BaseTool
prompts/
__init__.py
router_prompt.py # Intent classification prompt
synthesis_prompt.py # Multi-source response prompt
agent_prompts.py # Per-agent ReAct system prompts
[Link]
Dockerfile
.[Link]
erlang_code_graph/
mcp_server.py # MODIFIED: add 5 new tools (#11-#15)
chat-ui/
src/[Link] # MODIFIED: add CodeGraphSource, AgentStatus
src/[Link] # MODIFIED: add onAgentStatus handler (2 lines)
src/[Link] # MODIFIED: add code graph toggle + source pills
confluence-mcp:
build: ./mcp-servers/confluence
ports: ["8082:8082"]
env_file: ./mcp-servers/confluence/.env
volumes:
- ./rag-research/databases/chromadb:/data/chromadb:ro
healthcheck:
test: ["CMD", "curl", "-f", "[Link]
interval: 30s
code-graph-mcp:
build: ./erlang_code_graph
ports: ["8080:8080"]
command: ["python", "mcp_server.py", "--transport", "sse", "--port", "8080"]
env_file: ./erlang_code_graph/.env
healthcheck:
test: ["CMD", "curl", "-f", "[Link]
interval: 30s
orchestrator:
build: ./orchestrator
ports: ["8060:8060"]
env_file: ./orchestrator/.env
depends_on:
jira-mcp: { condition: service_healthy }
confluence-mcp: { condition: service_healthy }
code-graph-mcp: { condition: service_healthy }
chat-ui:
build: ./chat-ui
ports: ["5713:5713"]
depends_on: [orchestrator]
Per-MCP-Server Testing
“What does
handle_call/3 in code code_graph_function
butler_server do?”
“Show me recent
jira jira tickets
critical bugs in BSS”
“How does the
confluence confluence pages
deployment process
work?”
“Which functions
touch the
order_items table
and is there multi (code + confluence) code_graph functions + confluence pages
documentation
about the order
flow?”
Latency Targets
Time to first SSE event (chat_id) < 200ms Entry node only
Time to first text chunk < 5s (single agent), < 8s (multi) Includes MCP tool calls + synthesis
SSE transport for MCP (not stdio) HTTP-based, multi-client, Docker-friendly Slightly more setup than stdio
Separate processes per MCP server Fault isolation, independent scaling Process management overhead
ReAct pattern for agents (not single Agents can make multi-step queries
Adds 1-2 LLM calls per agent
tool call) (search -> detail)
Gemini Flash for router + agents Fast (< 0.5s), cheap Less capable than Pro for edge cases
Gemini Pro for synthesis only Full capability where it matters most Single expensive call