A production-ready multi-agent system for answering questions about any Python codebase using the Model Context Protocol (MCP), Deep Agents orchestration, and Neo4j knowledge graphs. The system uses Retrieval-Augmented Generation (RAG) to retrieve code patterns from indexed repositories and generate accurate, context-aware responses.
┌─────────────────────────────────────────────────────────────────────────┐
│ USER │
│ "How do I create a FastMCP server?" │
└─────────────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ ORCHESTRATOR (Port 8000) │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Deep Agent (LangGraph ReAct) │ │
│ │ • Recursion limit: 50 (industry standard for multi-step agents) │ │
│ │ • RAG workflow: RETRIEVE → LEARN → GENERATE │ │
│ │ • Streaming: Real-time tool calls & responses via WebSocket │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────┼─────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Indexer Agent │ │ Graph Query │ │ Code Analyst │ │
│ │ (MCP Client) │ │ Agent (MCP) │ │ Agent (MCP) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
└────────────┼────────────────────┼────────────────────┼───────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ Indexer MCP │ │ Graph Query MCP │ │ Code Analyst MCP │
│ Server (8001) │ │ Server (8002) │ │ Server (8003) │
│ • index_repository│ │ • find_entity │ │ • get_code_snippet│
│ • parse_python_ast│ │ • search_entities │ │ • analyze_class │
│ • extract_entities│ │ • get_dependencies│ │ • analyze_function│
└─────────┬──────────┘ └─────────┬──────────┘ └─────────┬──────────┘
│ │ │
└──────────────────────┼──────────────────────┘
▼
┌───────────────────────────────────────────┐
│ Neo4j Knowledge Graph │
│ ┌─────────────────────────────────────┐ │
│ │ Node Types: │ │
│ │ • File (587) - with metadata │ │
│ │ • Module, Class (2110 with source) │ │
│ │ • Function (2712 with source) │ │
│ │ • Method, Parameter, Decorator │ │
│ │ • Import, Docstring (2124 parsed) │ │
│ │ │ │
│ │ Relationships: │ │
│ │ • CONTAINS, IMPORTS, INHERITS_FROM │ │
│ │ • CALLS, DECORATED_BY, HAS_PARAMETER│ │
│ │ • DOCUMENTED_BY, DEPENDS_ON │ │
│ └─────────────────────────────────────┘ │
└───────────────────────────────────────────┘
- Retrieve: Search indexed repositories for relevant code patterns
- Learn: Extract imports, class structures, and coding patterns from real code
- Generate: Create new code that follows the repository's patterns
- Source code storage: Actual source code stored in Neo4j nodes (not just metadata)
- Parsed docstrings: Structured documentation with summary, params, returns, examples
- File tracking: Every Python file indexed with size and line count
- Live visualization of agent thinking and tool calls
- Color-coded agent badges (Indexer, Graph Query, Code Analyst)
- Todo list display with status icons (✅, 🔄, ⏳)
- Syntax-highlighted code responses
- 3 Specialized MCP Servers: Independent, scalable services
- Deep Agents Orchestration: LangGraph ReAct with 50-step recursion limit
- Tool-rich API: 20+ tools for code analysis and retrieval
- Docker and Docker Compose
- OpenAI API key
- Clone the repository:
git clone <your-repo-url>cd opsly- Create
.envfile:
cp .env.example .env
# Edit .env and add your OPENAI_API_KEY- Start all services:
docker-compose up --build- Access the application:
- Web UI: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Neo4j Browser: http://localhost:7474 (neo4j/password)
- Index a repository (e.g., FastMCP):
curl -X POST http://localhost:8000/api/index \
-H "Content-Type: application/json" \
-d '{"repo_url": "https://github.com/jlowin/fastmcp", "branch": "main"}'- Ask the agent to generate code:
"How do I create a calculator MCP server using FastMCP?"
- Watch the agent:
- Search for FastMCP class and tool decorator patterns
- Retrieve actual source code from the indexed repository
- Generate new code based on the learned patterns
The agent will produce code like:
fromfastmcpimportFastMCP# Import learned from repomcp=FastMCP("calculator")
@mcp.tool() # Pattern learned from indexed examplesdefadd(a: int, b: int) ->int:
"""Add two numbers."""returna+bif__name__=="__main__":
mcp.run()Using the Web UI:
- Navigate to http://localhost:8000
- Enter repository URL in the sidebar (e.g.,
https://github.com/tiangolo/fastapi.git) - Click "Start Indexing"
Using the API:
curl -X POST http://localhost:8000/api/index \
-H "Content-Type: application/json" \
-d '{"repo_url": "https://github.com/tiangolo/fastapi.git", "branch": "main"}'Simple queries:
- "What is the FastAPI class?"
- "Show me the Depends function"
Medium complexity:
- "How does FastAPI handle request validation?"
- "What classes inherit from APIRouter?"
Complex queries:
- "Explain the complete lifecycle of a FastAPI request"
- "How does dependency injection work and show examples"
- "Compare Path and Query parameter implementations"
opsly/
├── shared/ # Shared utilities
│ ├── models/ # Pydantic models
│ ├── db/ # Neo4j driver
│ ├── config.py # Configuration
│ └── exceptions.py # Custom exceptions
├── agents/ # MCP Agent Servers
│ ├── indexer/ # Repository indexing MCP server
│ │ ├── server.py # FastMCP server
│ │ ├── tools.py # Tool implementations
│ │ └── parser.py # AST parsing
│ ├── graph_query/ # Graph query MCP server
│ │ ├── server.py # FastMCP server
│ │ ├── tools.py # Tool implementations
│ │ └── queries.py # Cypher queries
│ └── code_analyst/ # Code analysis MCP server
│ ├── server.py # FastMCP server
│ ├── tools.py # Tool implementations
│ └── analyzer.py # Code analysis logic
├── orchestrator/ # Orchestrator + API + UI
│ ├── agent/ # Deep Agent orchestration
│ │ ├── deep_agent.py # Main orchestrator with streaming
│ │ ├── mcp_tools.py # MCP tools loader
│ │ └── orchestrator_tools.py # Orchestrator-specific tools
│ ├── api/ # FastAPI routes
│ │ ├── main.py # FastAPI app
│ │ └── routes/ # API endpoints
│ └── web/ # Web UI
│ ├── static/ # JavaScript & CSS
│ └── templates/ # HTML templates
├── docker/ # Dockerfiles
├── docker-compose.yml # Service orchestration
└── pyproject.toml # Dependencies
┌──────────────────────────────────────────────────────────────────────────┐
│ USER TRIGGERS INDEXING │
│ POST /api/index {"repo_url": "...", "branch": "main"} │
└────────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ STEP 1: Clone Repository │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ • git clone <repo_url> /tmp/repos/<repo-name>-<hash> │ │
│ │ • git checkout <branch> │ │
│ │ • Find all .py files (skip __pycache__, .venv, tests) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ STEP 2: Parse Python AST (per file) │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ parser = PythonASTParser(file_path) │ │
│ │ entities = parser.parse() │ │
│ │ │ │
│ │ Extracts: │ │
│ │ • File metadata (name, path, size, line_count) │ │
│ │ • Module (name, path, docstring) │ │
│ │ • Classes (name, bases, decorators, source_code, docstring) │ │
│ │ • Functions (name, params, returns, source_code, docstring) │ │
│ │ • Methods (name, params, returns, source_code, docstring) │ │
│ │ • Imports (module, alias, names) │ │
│ │ • Decorators (name, args) │ │
│ │ • Parameters (name, type_hint, default, kind) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ STEP 3: Extract Source Code │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ def _get_source_segment(node: ast.AST) -> str: │ │
│ │ lines = source_code.splitlines() │ │
│ │ start = node.lineno - 1 │ │
│ │ end = node.end_lineno │ │
│ │ # Include decorators if present │ │
│ │ if node.decorator_list: │ │
│ │ start = node.decorator_list[0].lineno - 1 │ │
│ │ return '\n'.join(lines[start:end]) │ │
│ │ │ │
│ │ Result: Actual Python source code stored in each node │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ STEP 4: Parse Docstrings │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ def parse_docstring(raw: str) -> DocstringNode: │ │
│ │ # Parse Google/NumPy style docstrings │ │
│ │ summary = first_line │ │
│ │ params = extract_params() # Args: section │ │
│ │ returns = extract_returns() # Returns: section │ │
│ │ raises = extract_raises() # Raises: section │ │
│ │ examples = extract_examples() # Examples: section │ │
│ │ │ │
│ │ Result: Structured documentation for each entity │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ STEP 5: Create Neo4j Nodes │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ // Create File node │ │
│ │ MERGE (f:File {path: $path}) │ │
│ │ SET f.name = $name, f.size_bytes = $size, f.line_count = $lines │ │
│ │ │ │
│ │ // Create Module node │ │
│ │ MERGE (m:Module {name: $name, path: $path}) │ │
│ │ SET m.docstring = $docstring │ │
│ │ │ │
│ │ // Create Class node WITH source code │ │
│ │ MERGE (c:Class {name: $name, path: $path}) │ │
│ │ SET c.lineno = $lineno, │ │
│ │ c.end_lineno = $end_lineno, │ │
│ │ c.docstring = $docstring, │ │
│ │ c.bases = $bases, │ │
│ │ c.source_code = $source_code ← ACTUAL PYTHON CODE │ │
│ │ │ │
│ │ // Create Function node WITH source code │ │
│ │ MERGE (f:Function {name: $name, path: $path}) │ │
│ │ SET f.source_code = $source_code ← ACTUAL PYTHON CODE │ │
│ │ │ │
│ │ // Create Docstring node │ │
│ │ MERGE (d:Docstring {entity_name: $name, entity_type: $type}) │ │
│ │ SET d.raw = $raw_docstring, │ │
│ │ d.summary = $summary, │ │
│ │ d.description = $description, │ │
│ │ d.params = $params_json, │ │
│ │ d.returns = $returns_json, │ │
│ │ d.examples = $examples_json │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ STEP 6: Create Relationships │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ // File → Module │ │
│ │ MATCH (f:File {path: $path}), (m:Module {name: $name}) │ │
│ │ MERGE (f)-[:CONTAINS]->(m) │ │
│ │ │ │
│ │ // Module → Class/Function │ │
│ │ MATCH (m:Module {name: $module}), (c:Class {name: $class}) │ │
│ │ MERGE (m)-[:CONTAINS]->(c) │ │
│ │ │ │
│ │ // Class → Base Class (inheritance) │ │
│ │ MATCH (c:Class {name: $child}), (b:Class {name: $base}) │ │
│ │ MERGE (c)-[:INHERITS_FROM]->(b) │ │
│ │ │ │
│ │ // Function/Method → Called Functions │ │
│ │ MATCH (f:Function {name: $caller}), (c:Function {name: $callee}) │ │
│ │ MERGE (f)-[:CALLS]->(c) │ │
│ │ │ │
│ │ // Entity → Docstring │ │
│ │ MATCH (e:Class {name: $name}), (d:Docstring {entity_name: $name}) │ │
│ │ MERGE (e)-[:DOCUMENTED_BY]->(d) │ │
│ │ │ │
│ │ // Function/Method → Parameters │ │
│ │ MATCH (f:Function {name: $func}), (p:Parameter {name: $param}) │ │
│ │ MERGE (f)-[:HAS_PARAMETER]->(p) │ │
│ │ │ │
│ │ // Entity → Decorators │ │
│ │ MATCH (f:Function {name: $func}), (d:Decorator {name: $dec}) │ │
│ │ MERGE (f)-[:DECORATED_BY]->(d) │ │
│ │ │ │
│ │ // Module → Imports │ │
│ │ MATCH (m:Module {name: $module}) │ │
│ │ MERGE (i:Import {module: $import_module}) │ │
│ │ MERGE (m)-[:IMPORTS]->(i) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ RESULT: Knowledge Graph Ready for RAG │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ FastMCP Repository (587 files indexed): │ │
│ │ • 587 File nodes │ │
│ │ • 2,110 Class nodes (with source_code) │ │
│ │ • 2,712 Function nodes (with source_code) │ │
│ │ • 2,124 Docstring nodes (parsed) │ │
│ │ • ~15,000+ relationships │ │
│ │ │ │
│ │ Agent can now: │ │
│ │ 1. Search for entities (search_entities) │ │
│ │ 2. Retrieve source code (get_code_snippet) │ │
│ │ 3. Learn patterns from real code │ │
│ │ 4. Generate new code following those patterns │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
1. Source Code Extraction (The Critical Feature)
# From agents/indexer/parser.pydef_get_source_segment(self, node: ast.AST) ->str:
"""Extract actual source code for an AST node."""ifnothasattr(node, 'lineno') ornothasattr(node, 'end_lineno'):
return""lines=self.source_code.splitlines()
start=node.lineno-1# 0-indexedend=node.end_lineno# IMPORTANT: Include decorator lines if presentifhasattr(node, 'decorator_list') andnode.decorator_list:
first_decorator=node.decorator_list[0]
ifhasattr(first_decorator, 'lineno'):
start=first_decorator.lineno-1return'\n'.join(lines[start:end])Why this matters for RAG:
- Agents retrieve ACTUAL code, not just metadata
- Can learn import patterns:
from fastmcp import FastMCP - Can learn decorator patterns:
@mcp.tool() - Can learn class structures and method signatures
2. Docstring Parsing
# From agents/indexer/parser.pydefparse_docstring(docstring: Optional[str]) ->Optional[DocstringNode]:
"""Parse Google/NumPy style docstrings into structured format."""# Extract summary (first line)summary=lines[0].strip()
# Parse sections with regex patternsparam_pattern=re.compile(r'^\s*(?:Args?|Parameters?):\s*$')
returns_pattern=re.compile(r'^\s*(?:Returns?):\s*$')
# Google style: " param_name (type): description"google_param=re.compile(r'^\s{4,}(\w+)(?:\s*\(([^)]+)\))?:\s*(.*)$')
returnDocstringNode(
raw=docstring,
summary=summary,
params=params,
returns=returns_info,
examples=examples
)Why this matters for RAG:
- Agents can show parameter descriptions to users
- Examples from docstrings become usage templates
- Return value documentation helps understand APIs
3. Relationship Tracking
# From agents/indexer/parser.pyclassCallVisitor(ast.NodeVisitor):
"""Extract function calls from code."""defvisit_Call(self, node: ast.Call):
call_name=self._get_call_name(node.func)
self.calls.add(call_name)Why this matters for RAG:
- Track which functions call which (CALLS relationships)
- Understand dependencies between code entities
- Follow execution flows through the codebase
Responsibilities:
- Clone Git repositories
- Parse Python AST
- Extract code entities with source code
- Populate Neo4j graph with relationships
Tools:
index_repository(repo_url, branch)- Full repo indexingindex_file(file_path)- Single file indexingget_index_status(job_id)- Job progressparse_python_ast(file_path)- AST extractionextract_entities(source_code)- Entity identification
Responsibilities:
- Query Neo4j knowledge graph
- Find code entities
- Trace dependencies
- Explore relationships
Tools:
find_entity(name, entity_type)- Find by nameget_dependencies(entity_name)- What it depends onget_dependents(entity_name)- What depends on ittrace_imports(module_name)- Import chainsfind_related(entity, relationship, direction)- Related entitiesexecute_query(cypher)- Custom Cypher queriesget_class_hierarchy(class_name)- Class inheritance treesearch_entities(pattern)- Search by patternget_statistics()- Knowledge graph statistics
Responsibilities:
- Deep code analysis
- Pattern detection
- AI-powered explanations
- Complexity metrics
Tools:
analyze_function(function_name)- Metrics & patternsanalyze_class(class_name)- Class structure analysisfind_patterns(pattern_type)- Design pattern detectionget_code_snippet(entity_name)- Extract with contextexplain_implementation(entity_name)- AI explanationcompare_implementations(entity1, entity2)- Compare code
Responsibilities:
- Query analysis and classification
- Agent routing and execution planning
- Conversation context management
- Response synthesis
Tools:
analyze_query(query)- Classify query intent, extract entities, identify required agentsroute_to_agents(query, analysis)- Create execution plan with agent routing strategyget_conversation_context(session_id)- Retrieve conversation historysynthesize_response(results)- Combine agent outputs into coherent response
POST /api/chat- Send message, get responseWS /ws/chat- WebSocket real-time chat with streaming events:thinking- Agent processing statesquery_analysis- Query classification and entity extractionplan- Execution plan with steps and strategydelegation- Agent delegation eventstool_call- MCP tool invocationstool_result- Tool execution resultsresponse- Final answer
POST /api/index- Trigger indexingGET /api/index/status/{job_id}- Check status
GET /api/health- Basic health checkGET /api/agents/health- All agents statusGET /api/graph/statistics- Graph stats
Environment variables (see .env.example):
OPENAI_API_KEY- Required for Code AnalystNEO4J_URI,NEO4J_USER,NEO4J_PASSWORD- Neo4j connectionREDIS_HOST,REDIS_PORT- Redis for sessionsLOG_LEVEL- Logging level (DEBUG, INFO, WARNING, ERROR)
# Install dependencies
pip install -e ".[dev]"# Run individual services
python -m agents.indexer.server
python -m agents.graph_query.server
python -m agents.code_analyst.server
python -m orchestrator.api.main
# Run tests
pytest
# Format code
black .
ruff check .What the assignment suggested:
The assignment spec described creating an "Orchestrator Agent" as a separate MCP server with tools like analyze_query, route_to_agents, synthesize_response. This would mean the orchestrator itself is an MCP server that the API calls.
What we implemented instead: We used Deep Agent pattern with LangGraph ReAct directly in the API layer, NOT as an MCP server. The orchestrator has direct access to all MCP agent tools without wrapping them.
Why we chose Deep Agent:
Better Performance & Latency
- No extra network hop for orchestration logic
- Direct tool invocations instead of MCP protocol overhead
- Faster response times for multi-step queries
Native Streaming Support
- LangGraph ReAct provides built-in streaming for tool calls and responses
- Real-time WebSocket updates show agent thinking, planning, and tool execution
- Users see live progress instead of waiting for final response
Simpler Architecture
- Orchestrator runs in the same process as the API gateway
- No need to manage a 4th MCP server (orchestrator MCP server)
- Fewer network connections and failure points
More Flexible Tool Composition
- Orchestrator can have its own tools (analyze_query, synthesize_response) WITHOUT wrapping them as MCP
- Direct access to OpenAI function calling for orchestrator logic
- Can mix MCP tools (from agents) with local tools (orchestrator-specific)
Industry Best Practice
- Deep Agent pattern is standard in LangChain/LangGraph applications
- ReAct (Reason → Act → Observe) is proven for multi-step reasoning
- Recursion limit of 50 is industry standard for complex queries
Trade-offs accepted:
| Aspect | MCP Orchestrator | Deep Agent (Our Choice) |
|---|---|---|
| Network overhead | Higher (4 MCP servers) | Lower (3 MCP servers) |
| Orchestrator scalability | Can scale independently | Scales with API gateway |
| Tool protocol | Everything via MCP | MCP for agents, native for orchestrator |
| Streaming support | Would need custom implementation | Built-in with LangGraph |
| Development complexity | More complex (4th MCP server) | Simpler (integrated in API) |
| Assignment compliance | Literal interpretation | Pragmatic interpretation |
What we kept from assignment spec:
- ✅ Orchestrator has query analysis logic (
analyze_queryfunction) - ✅ Orchestrator routes to agents (via MCP tool calls)
- ✅ Orchestrator synthesizes responses (via
synthesize_responsefunction) - ✅ All 3 specialized agents (Indexer, Graph Query, Code Analyst) are MCP servers
Conclusion: While the assignment suggested making the orchestrator an MCP server, we chose the Deep Agent pattern for better performance, simpler architecture, and native streaming support. The functional requirements (analyze, route, synthesize) are all met, just without the MCP protocol overhead for orchestration logic.
LangGraph ReAct advantages:
- Explicit graph-based workflow with state management
- Built-in checkpointing for conversation persistence
- Better control over tool execution flow
- Native support for parallel tool calls
- Easier to debug with graph visualization
Plain LangChain Agent limitations:
- Less control over execution flow
- Harder to implement custom routing logic
- Limited streaming capabilities
- No built-in state persistence
Why use MCP protocol for Indexer, Graph Query, and Code Analyst?
Standardized Communication
- MCP provides a standard protocol for agent-to-agent communication
- Tool definitions are self-describing (JSON-RPC format)
- Easy to add new agents without changing orchestrator code
Independent Scaling
- Each agent runs as a separate Docker container
- Can scale agents independently based on load
- Indexer can handle heavy git cloning without blocking others
Language Agnostic
- MCP is language-agnostic (we use FastMCP for Python)
- Could add agents in other languages (TypeScript, Go, Rust)
- Agents only need to implement MCP protocol
Clean Separation of Concerns
- Each agent has a well-defined responsibility
- Agents don't know about each other
- Orchestrator is the single point of coordination
Easy to Extend
- Adding a new agent is just adding a new MCP server
- No need to modify existing agents
- New tools automatically discovered by orchestrator
Trade-offs:
- Network latency for each MCP call (~5-10ms per tool invocation)
- JSON-RPC serialization overhead
- More complex deployment (3+ containers)
Why it's worth it:
- Better separation of concerns outweighs network overhead
- Independent scaling is critical for production
- MCP is becoming an industry standard (Anthropic, Sourcegraph, etc.)
Why Neo4j vs Alternatives (PostgreSQL with pg_graph, MongoDB)?
Natural Fit for Code Relationships
- Cypher query language is designed for graph traversal
- Native support for relationship queries (INHERITS_FROM, CALLS, DEPENDS_ON)
- Fast path-finding algorithms (shortest path, all paths)
Performance for Graph Traversal
- O(1) relationship lookups (vs O(n) table joins in SQL)
- Index-free adjacency for fast navigation
- Optimized for "find all classes that inherit from X" queries
Schema Flexibility
- Can add new node types without migrations
- Dynamic properties on nodes
- Easy to extend with new relationship types
Cypher Query Language
- Expressive and readable
- Pattern matching with ASCII art syntax
- Aggregation and filtering in single query
Example Query Power:
// Find all classes that use FastAPI's Depends function (3 hops)MATCH (depends:Function{name:'Depends'})
MATCH (m:Method)-[:CALLS]->(depends)
MATCH (c:Class)-[:CONTAINS]->(m)
RETURNc.name, c.path, c.source_codeThis would be complex and slow in SQL with multiple joins.
Why WebSocket vs HTTP Polling?
Transparency in Multi-Agent Workflow
- Users see agent thinking in real-time
- Tool calls and results shown live
- Better understanding of how the system works
Better User Experience
- No "loading..." spinner for 30 seconds
- Progressive feedback builds trust
- Users can cancel if agent goes off track
Debugging & Monitoring
- Developers can see exact tool execution sequence
- Easy to identify where agent gets stuck
- Performance metrics visible per tool call
Event-Driven Architecture
- Server can push updates without client polling
- Reduced server load (no polling overhead)
- Lower latency for updates
Events we stream:
thinking- Agent reasoning statequery_analysis- Parsed query intentplan- Execution plan with stepsdelegation- Which agent is being invokedtool_call- MCP tool invocationstool_result- Tool execution resultsresponse- Final answer
Our approach:
- Pragmatic over dogmatic: Use MCP where it adds value (specialized agents), skip it where it adds overhead (orchestrator)
- Performance-first: Real-time streaming and low latency are critical
- Production-ready: Independent scaling, clean separation, easy debugging
- RAG-optimized: Store source code, parse docstrings, enable pattern learning
Result: A system that meets all functional requirements of the assignment while being faster, simpler, and more maintainable than a pure MCP-everywhere approach.
# Check logs
docker-compose logs -f
# Restart specific service
docker-compose restart indexer# Check Neo4j is ready
docker-compose logs neo4j
# Verify connection
docker exec -it opsly-neo4j cypher-shell -u neo4j -p password- Verify
OPENAI_API_KEYin.env - Check API quota and billing
Problem: When asked "How to write FastMCP code?", the agent would generate generic Python code (like factorial functions) instead of using knowledge from the indexed repository.
Root Cause: The original system prompt was too restrictive with "NEVER generate code from imagination" without clear guidance on HOW to use retrieved context.
Solution: Implemented RAG (Retrieval-Augmented Generation) workflow:
- Rewrote the system prompt to enforce a 3-step process: RETRIEVE → LEARN → GENERATE
- Agent must search and retrieve code patterns BEFORE generating any code
- All generated code must be based on patterns from the indexed repository
- Added example workflows showing correct tool usage sequence
# System prompt now enforces:# 1. search_entities(pattern="FastMCP") → Find relevant code# 2. get_code_snippet(entity_name="FastMCP") → Get actual source# 3. THEN generate code using learned patternsProblem: The knowledge graph only stored metadata (class names, line numbers) but not the actual source code, making it impossible for the agent to learn patterns.
Root Cause: Parser extracted AST information but didn't capture the source text.
Solution:
- Added
_get_source_segment()method to parser usingnode.linenoandnode.end_lineno - Added
source_codeandend_linenofields to ClassNode, FunctionNode, MethodNode - Updated Neo4j queries to store and retrieve source code
- Now 2,110 classes and 2,712 functions have full source code stored
def_get_source_segment(self, node: ast.AST) ->str:
"""Extract source code for an AST node."""lines=self.source_code.splitlines()
start=node.lineno-1end=node.end_lineno# Include decorators if presentifhasattr(node, 'decorator_list') andnode.decorator_list:
start=node.decorator_list[0].lineno-1return'\n'.join(lines[start:end])Problem: Tool arguments in the UI showed [object Object] instead of readable content, especially for complex structures like todo lists.
Root Cause: JavaScript template literals convert objects to strings using .toString(), which produces [object Object].
Solution:
- Created
formatArgValue()function to handle different data types - Special handling for
todosarray to display as a clean checklist - Used
JSON.stringify()with formatting for other objects - Added CSS styling for todo items with status icons
functionformatArgValue(key,value){if(key==='todos'&&Array.isArray(value)){returnvalue.map(todo=>{conststatusIcon=todo.status==='completed' ? '✅' : todo.status==='in_progress' ? '🔄' : '⏳';return`${statusIcon}${todo.content}`;}).join('\n');}returntypeofvalue==='object' ? JSON.stringify(value,null,2) : value;}Problem: Complex multi-step queries would hit the recursion limit (originally 20-30) before the agent could complete thorough research.
Root Cause: Default LangGraph recursion limits are conservative, but RAG workflows require more steps (search → retrieve multiple entities → analyze → generate).
Solution:
- Researched industry standards for LangGraph/ReAct agents
- Increased recursion limit to 50 (industry standard for multi-step agents)
- This allows 5-8 tool calls per query as recommended for thorough retrieval
Problem: The assignment required DOCUMENTED_BY relationships, but docstrings were only stored as raw text in the entity nodes.
Solution:
- Created
DocstringNodemodel with structured fields (summary, description, params, returns, examples) - Implemented
parse_docstring()function supporting Google/NumPy style docstrings - Created DOCUMENTED_BY relationships from entities to Docstring nodes
- Result: 2,124 parsed docstrings with structured data
Problem: The assignment spec required File nodes, but we only had Module nodes.
Solution:
- Added
FileNodemodel with name, path, size_bytes, line_count - Parser now creates File node for each indexed Python file
- Added CONTAINS relationship from File to Module
- Result: 587 File nodes tracking all indexed files
SCIP (Sourcegraph Code Intelligence Protocol)
- Industry standard for code indexing
- 70+ symbol kinds (vs our initial 5)
- Relationships: is_reference, is_implementation, is_type_definition
- Occurrence tracking for cross-file references
- Source: https://sourcegraph.com/docs/code_intelligence/scip
Microsoft GraphRAG
- Leiden community detection for code clustering
- Hierarchical summaries for large codebases
- Global/local search modes
- Source: https://microsoft.github.io/graphrag/
scip-python (Sourcegraph's Python Indexer)
- Uses Pyright for semantic analysis
- Type inference across modules
- Cross-file import resolution
- Source: https://github.com/sourcegraph/scip-python
- Recursion limit of 50 is standard for multi-step agents
- ReAct pattern: Reason → Act → Observe → Repeat
- Source: LangGraph documentation, LangChain examples
- Python AST nodes have
linenoandend_linenoattributes (Python 3.8+) ast.get_source_segment()for precise extraction- Decorator handling requires checking
decorator_list[0].lineno
- Google style:
Args:,Returns:,Raises:,Examples: - NumPy style:
Parameters,Returns,Raises - Regex patterns for section detection
After indexing FastMCP repository:
| Metric | Count |
|---|---|
| Files indexed | 587 |
| Classes with source code | 2,110 |
| Functions with source code | 2,712 |
| Parsed docstrings | 2,124 |
| Total relationships | ~15,000+ |
- Python only: Currently only indexes Python codebases
- File size limit: Large files (>500KB) are skipped
- No authentication: Web UI and API have no auth (add for production)
- Single repository: Currently supports one repository at a time
Based on research, potential enhancements:
- Usage tracking: Track where symbols are referenced, not just defined
- Type inference: Extract parameter and return types using type stubs
- Community detection: Group related code using graph algorithms
- Cross-repo search: Support multiple repositories simultaneously