How I Built a 59-Tool MCP Server That Coordinates Parallel AI Agents

Most people build one MCP tool. I built 59 across 19 categories — and they coordinate parallel AI agents running sprints against a PostgreSQL-backed career engine.

This is the story of Cortex, my personal AI operating system, and the MCP server that gives Claude Code native access to every part of it.

What is MCP and Why Should You Care

Model Context Protocol is Anthropic's open standard for connecting AI models to external tools and data sources. Think of it as USB-C for AI — a universal interface that lets any model use any tool without custom integration code.

Before MCP, giving an AI agent access to your database meant writing bespoke prompt engineering: "here's the curl command, parse the JSON, extract the field..." It was fragile, verbose, and broke constantly.

With MCP, you define tools as typed functions with descriptions. The model sees them as first-class capabilities. It knows the parameters, the return types, and when to use each one. The difference in reliability is night and day.

For production AI systems — where agents need to read databases, manage state, coordinate with each other — MCP isn't optional. It's infrastructure.

Architecture: FastMCP + FastAPI

Cortex has two layers. The dashboard is a FastAPI application with 27+ PostgreSQL tables, Jinja2 templates, and a REST API secured by API key authentication. The MCP server is a separate process built on FastMCP that wraps every API endpoint into a tool.

The MCP server is a single file — cortex_mcp.py — that imports FastMCP and defines tools using the @mcp.tool() decorator. Every tool is an async function that calls the dashboard's REST API:

from fastmcp import FastMCP

mcp = FastMCP("cortex", instructions="Cortex — Aryan's AI career engine. Sprint planning, task management with dependencies, agent coordination, worktree orchestration.")

BASE_URL = os.getenv("CORTEX_URL", "https://cortex.stuckaryan.in")
API_KEY = os.getenv("CORTEX_API_KEY", "aryan-agent-2026-sk")

async def _api(method: str, path: str, body: dict | None = None) -> dict:
    """Call the Cortex dashboard REST API."""
    async with httpx.AsyncClient(base_url=BASE_URL, timeout=30) as client:
        headers = {"X-API-Key": API_KEY, "Content-Type": "application/json"}
        resp = await client.request(method, f"/api{path}", headers=headers, json=body)
        resp.raise_for_status()
        return resp.json()

Every tool follows the same pattern: validate inputs, call _api(), return JSON. The MCP server is stateless — all state lives in PostgreSQL. This means you can restart it, scale it, or run multiple instances without coordination.

The 19 Categories

Here's how 59 tools organize into coherent categories:

Sprint Planning (5 tools)cortex_create_sprint, cortex_plan_sprint, cortex_list_sprints, cortex_get_sprint, cortex_update_sprint. The star here is cortex_plan_sprint, which bulk-creates a sprint with tasks and dependency graphs in a single atomic call:

@mcp.tool()
async def cortex_plan_sprint(project_id: int, sprint_name: str, sprint_goal: str,
                              tasks: list[dict]) -> str:
    """Bulk-create a sprint with tasks and dependencies in one call.

    Each task in the list should have: title, description, priority,
    estimated_hours, depends_on_indices (list of 0-based indices into
    this same task list).
    """
    result = await _api("POST", "/sprints/plan", {
        "project_id": project_id,
        "sprint_name": sprint_name,
        "sprint_goal": sprint_goal,
        "tasks": tasks,
    })
    return json.dumps(result)

The depends_on_indices pattern is critical. When you're planning a sprint, tasks don't have IDs yet. So dependencies reference array indices: task 1 depends on task 0, task 2 depends on task 1. The server resolves these to real task IDs after creation.

Task Management (6 tools) — CRUD plus cortex_complete_task and cortex_claim_task. Completing a task triggers cascade_unblock() on the server side — a BFS that walks the dependency graph and unblocks any tasks whose blockers are all done.

Dependency Management (4 tools) — Add single or bulk dependencies, get blocked tasks, get ready tasks. The server runs cycle detection before adding any dependency using a BFS traversal:

async def would_create_cycle(conn, task_id: int, depends_on_id: int) -> bool:
    """BFS to detect cycles before adding a dependency."""
    visited = set()
    queue = [depends_on_id]
    while queue:
        current = queue.pop(0)
        if current == task_id:
            return True
        if current in visited:
            continue
        visited.add(current)
        rows = await conn.fetch(
            "SELECT depends_on_task_id FROM task_dependencies WHERE task_id = $1", current
        )
        queue.extend(r['depends_on_task_id'] for r in rows)
    return False

Agent Coordination (4 tools)cortex_checkin, cortex_checkout, cortex_heartbeat, cortex_claim_task. The claim is atomic: it checks if the task is blocked, already done, or claimed by another agent, and fails fast with a 409 if any condition is true.

Worktree Orchestration (3 tools)cortex_create_worktree, cortex_list_worktrees, cortex_merge_worktree. These wrap git worktree commands to give each agent an isolated copy of the repo. The worktree path follows a convention: .trees/task-{id}.

Knowledge & Research (3 tools) — Search, get, and add research documents. The knowledge base stores everything from competitor analysis to architectural decisions.

Graph Operations (2 tools)cortex_enrich_graph triggers a full scan of all entities to backfill missing relations. cortex_get_graph returns the full knowledge graph or a local subgraph around any entity.

Social Media (7 tools) — Tweet posting (with dry_run safety), content scheduling, template generation from content pillars, tweet optimization with hashtags, social stats, and link analysis that auto-categorizes URLs into research docs.

Twitter Reader (3 tools) — Read tweets from timelines, get user profiles, fetch trending topics.

Plus: Project Management (3), Session & Messages (5), Revenue (2), Content Calendar (2), Notes (2), Project Initialization (1), App Management (3), Tool Registry (1), Notifications & Graph (2), System (1).

How Sprint Planning Chains with Task Management

The real power isn't individual tools — it's how they compose. Here's a typical agent workflow:

  1. cortex_plan_sprint — creates sprint with 7 tasks and a dependency DAG
  2. cortex_get_ready_tasks — finds Layer 0 tasks (no dependencies, parallelizable)
  3. cortex_claim_task — atomically claims a ready task
  4. cortex_create_worktree — isolates work in .trees/task-{id}
  5. Agent does the work in the worktree
  6. cortex_complete_task — marks done, triggers cascade_unblock
  7. cortex_get_ready_tasks — newly unblocked tasks appear
  8. Repeat

The cascade unblock is the key mechanism. When task A completes and task B depends only on A, B automatically transitions from blocked to ready. No manual intervention. The agent just keeps polling for ready tasks.

Agent Coordination: How Multiple Agents Share State

The database is the coordination layer. Multiple Claude agents can connect to the same MCP server simultaneously because:

  1. Atomic claims: cortex_claim_task uses a check-and-update pattern. If agent A claims task 5, agent B gets a 409 error and moves to the next ready task.

  2. Heartbeats: Agents send periodic heartbeats via cortex_heartbeat. The server tracks which agents are active and what they're working on.

  3. Worktree isolation: Each agent works in its own git worktree. No file conflicts. No merge races during development.

  4. Shared state, no shared memory: All state is in PostgreSQL with connection pooling via asyncpg (min 2, max 10 connections). Agents don't need to know about each other — they just need to see the same task board.

_pool = await asyncpg.create_pool(
    DATABASE_URL, min_size=2, max_size=10,
    timeout=15, ssl=False, statement_cache_size=0,
)

The statement_cache_size=0 is deliberate. With dynamic queries from the authorization system (OpenFGA-style RBAC), cached prepared statements caused type mismatches. Disabling the cache traded a few microseconds of parse time for zero cache invalidation bugs.

Tool Registration: Tools as Data

Every MCP tool is also registered as a database entity. On boot, sync_tools_registry() writes all 59 tool definitions into the tools table:

_CORTEX_MCP_TOOLS = [
    ("cortex_create_sprint", "sprints", "Create a new sprint"),
    ("cortex_plan_sprint", "sprints", "Bulk-create sprint with tasks and deps"),
    ("cortex_claim_task", "tasks", "Claim a task for an agent"),
    ("cortex_enrich_graph", "graph", "Trigger full graph enrichment"),
    # ... 55 more
]

async def sync_tools_registry():
    async with db_connection() as conn:
        for name, category, description in _CORTEX_MCP_TOOLS:
            await conn.execute(
                "INSERT INTO tools (name, category, description, mcp_server) "
                "VALUES ($1, $2, $3, 'cortex') "
                "ON CONFLICT (name) DO UPDATE SET "
                "description = EXCLUDED.description, category = EXCLUDED.category, is_active = TRUE",
                name, category, description,
            )

This means tools are discoverable through the API (GET /api/tools), visible in the dashboard, and part of the knowledge graph. An agent can query "what tools exist in the graph category?" and get back cortex_enrich_graph and cortex_get_graph without hardcoding anything.

Lessons Learned

What broke: Twikit (Python Twitter client) authentication hit error 366/226 — Twitter's bot detection flagged the TLS fingerprint. Solution was switching to rnet, a Rust-based HTTP client that emulates Chrome's TLS stack. The lesson: when your automation breaks against a major platform, the problem is almost always fingerprinting, not your code logic.

What scaled: asyncpg's connection pool. With 3 agents hitting the same database simultaneously, I expected contention. Never saw it. The pool handles 10 concurrent connections, and most tool calls complete in under 50ms. PostgreSQL's MVCC means readers don't block writers. The bottleneck was always the AI model's thinking time, never the database.

What surprised me: 3 parallel agents is the sweet spot. I tested with 2, 3, 4, and 5. With 2, you leave parallelism on the table. With 4+, the coordination overhead — merge conflicts, dependency resolution, context switching — eats the gains. 3 agents with proper file ownership maps (assign files to tasks during planning, not execution) gives roughly 2.5x throughput over a single agent.

The boot enrichment insight: When I added the knowledge graph, I assumed I'd need to manually backfill all existing entities. Instead, I wrote bulk_enrich_graph_internal() — a function that runs on every server start, scans all entities with foreign keys, and creates missing graph edges. First run: 0 to 189 nodes and 361 edges from data that already existed. Zero manual work.

@asynccontextmanager
async def lifespan(app: FastAPI):
    await init_db()
    if is_db_available():
        try:
            from .api.relations import bulk_enrich_graph_internal
            created = await bulk_enrich_graph_internal()
            if created:
                print(f"[boot] graph enrichment: {created} relations created")
        except Exception as e:
            print(f"[boot] graph enrichment skipped: {e}")
    yield
    await close_pool()

The Real Takeaway

Building one MCP tool is easy. Building 59 that compose into workflows — sprint planning, parallel execution, knowledge management, social media automation — requires thinking about tools as an API surface, not individual functions.

The patterns that matter: atomic operations for coordination, dependency DAGs for task ordering, connection pooling for concurrent access, and boot-time enrichment for data consistency.

If you're building production AI systems, MCP is the interface layer you need. Start with 5 tools. Make them compose. The rest follows.