A single-agent orchestration demo that fans out across four real MCP (Model Context Protocol) servers — Outlook, Teams, Slack, and a Task Tracker — to answer one simple question: "What should I focus on right now?" Built with FastAPI + the official MCP Python SDK + OpenAI on the backend, a React/Copilot-styled UI on the frontend, fully containerized with Docker.
Scope note: This is a demo/prototype proving the MCP orchestration pattern and user experience before building the real thing inside M365 Copilot (Microsoft Graph + Teams + Copilot Studio connectors). Login and the four tools' underlying data are simulated with local fixtures; the MCP protocol, the parallel tool-calling, and the LLM reasoning are real.
A knowledge worker's day is fragmented across four or five different apps — a calendar and inbox, a chat tool, a team channel, a ticket tracker — and "what should I actually do first" requires manually checking all of them and mentally cross-referencing what matters. In a regulated financial services setting this is worse: a compliance deadline buried in an email, a client escalation in a Slack channel, and a board-level ask in a Teams mention can all be sitting unread at the same time, with no single view connecting them by urgency.
Building a bespoke integration for each of these tools (custom auth, custom API client, custom data shape per tool) doesn't scale as the number of connected tools grows. MCP solves the integration half of this problem: it gives an agent one standard way to discover what a tool can do and call it, regardless of which tool it is.
NeoLend (fictional digital lending fintech, same company as Demo 1). Priya Shah, Regional Credit Manager, starts her day fragmented across Outlook, Teams, Slack, and an internal Task Tracker. She asks Copilot: "What should I focus on this morning?" — and again in the afternoon and evening, as the picture evolves.
A second persona, Ankit Verma (Junior Credit Analyst), has only Outlook and the Task Tracker connected — Teams and Slack are not authorized for him. This demonstrates that connector access is per-user, per-tool, exactly as M365 Copilot connectors work: the agent doesn't just have "more or less data," it structurally cannot query a tool it isn't authorized for.
Full seed data (calendar, emails, mentions, messages, tickets) lives in backend/app/data/.
| For | Value |
|---|---|
| Priya (end user) | One question, one place, instead of checking 4 apps every morning/afternoon/evening |
| Compliance/Risk | Audit-critical deadlines (KYC re-verification) and client escalations surface automatically, ranked by real urgency, not by which app happened to ping loudest |
| Leadership | A board-level Teams mention doesn't get lost in the flow of everyday chat |
| IT/Security | Demonstrates that connector access is structural (per-tool authorization), not a UI toggle |
| Engineering (the MCP thesis) | Proves that adding a 5th tool tomorrow means writing one more MCP server, not rewriting the orchestrator - the "brain" doesn't change |
Discover → Fan out → Reason. One orchestrator ("the brain") that:
- Looks up which of the 4 tools this persona has connected.
- For each connected tool, spawns that tool's real MCP server (via the official
mcpPython SDK, stdio transport) and callslist_tools()/call_tool()— the actual MCP protocol handshake, not a mocked abstraction. - Runs all connected tool calls in parallel (
asyncio.gather). - Feeds everything gathered, plus the requested time of day, into one reasoning LLM call that produces a ranked, time-aware plan.
Each of the 4 tools is a standalone Python process speaking MCP over stdio
(app/mcp_servers/*.py), using the official mcp SDK's Server class. The orchestrator
(mcp_client_manager.py) is a real mcp.ClientSession - it doesn't know in advance what
tools each server exposes; it calls list_tools() at runtime and gets back real tool
schemas. Only the data behind each tool is fake (a JSON fixture instead of a live
Microsoft Graph / Slack Web API call) - the protocol, discovery, and invocation are
genuine, so the swap-in path to production is a data-source change, not a redesign.
Each fixture item (email, ticket, message) carries a resolved_at / acknowledged_at
timestamp (or null if still open). The three time-of-day options (morning, afternoon,
evening) map to three points in a single simulated day (data/simulated_clock.json).
Each MCP tool computes a simple status ("open"/"resolved") by comparing that timestamp to
the requested "as of" time - the same underlying dataset naturally produces a different,
evolving picture across the day, and the reasoning LLM call is the one that decides what's
actually urgent given that status, not a hardcoded rule.
mock_auth.py holds a persona -> connected_tools map. The MCP client manager only
spawns a server if the persona has that tool connected - an unauthorized tool's server
process is never even started. This is visible on the System Flow page's Authorization
panel and in the connector strip (greyed "Not connected" state) rather than being a
same-data-minus-filtering illusion.
Shows, live via Server-Sent Events:
- Per-connector authorization - which of the 4 tools this persona has allowed.
- MCP discovery - the actual tool schemas returned by each connected server's real
list_tools()call. - Connector fan-out graph - 4 parallel lanes (not a sequential chain, unlike Demo 1) showing each MCP call's status and latency as it resolves.
- Reasoning call panel - the single synthesis LLM call's model, tokens, latency, cost, and LangSmith trace link if configured.
- Run summary - total wall-clock time (should be close to the slowest single MCP call plus the reasoning call, since the fan-out is parallel, not additive), total tokens, total cost, LangSmith status.
LangSmith is optional, same pattern as Demo 1: if LANGCHAIN_API_KEY /
LANGCHAIN_TRACING_V2=true are set, the reasoning call is wrapped with LangSmith's
standalone traceable decorator and a trace link appears. If unset, the same
latency/token/cost telemetry is still computed directly from the OpenAI response, just
without a trace link.
neolend-morning-planner/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI routes
│ │ ├── config.py # .env-driven settings
│ │ ├── auth/mock_auth.py # persona login + per-connector authorization
│ │ ├── mcp_servers/
│ │ │ ├── _common.py # shared fixture loading + status computation
│ │ │ ├── outlook_server.py # real MCP server (stdio)
│ │ │ ├── teams_server.py
│ │ │ ├── slack_server.py
│ │ │ └── tracker_server.py
│ │ ├── orchestration/
│ │ │ ├── mcp_client_manager.py # spawns servers, discovery, parallel tool calls
│ │ │ ├── brain.py # fan-out + reasoning "brain"
│ │ │ ├── telemetry.py # cost calc + optional LangSmith wrapper
│ │ │ └── state.py # last-run cache (System Flow replay)
│ │ ├── prompts/*.txt # synthesize_plan.txt, chat_followup.txt
│ │ ├── models/schemas.py
│ │ ├── services/openai_client.py
│ │ └── data/ # outlook/teams/slack/tracker/users/pricing/clock JSON
│ ├── requirements.txt
│ ├── Dockerfile
│ └── .env.example
├── frontend/
│ ├── src/
│ │ ├── pages/ (Login, Planner, SystemFlow)
│ │ ├── components/ (ConnectorStrip, PlanCards, ChatRail, AppShell,
│ │ │ DiscoveryPanel, FanOutGraph, ReasoningPanel,
│ │ │ AuthorizationPanel, RunSummaryFooter, toolMeta.js)
│ │ ├── api/client.js
│ │ ├── context/SessionContext.jsx
│ │ └── styles/theme.css
│ ├── package.json
│ ├── Dockerfile
│ ├── nginx.conf
│ └── .env.example
├── docker-compose.yml
├── .env.example
└── README.md (this file)
- Docker + Docker Compose (recommended), or Python 3.12 + Node.js 20 for local dev
- An OpenAI API key
- (Optional) A LangSmith API key
git clone <this-repo>
cd neolend-morning-planner
cp .env.example .env
# edit .env and set OPENAI_API_KEY=sk-...
docker-compose up --build- Frontend: http://localhost:3000
- Backend docs (Swagger): http://localhost:8000/docs
Sign in as Priya Shah for the full 4-tool experience. Sign in as Ankit Verma to see the per-connector authorization boundary (Teams/Slack show "Not connected" and are never queried).
Backend:
cd backend
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # edit OPENAI_API_KEY
uvicorn app.main:app --reload --port 8000Frontend:
cd frontend
cp .env.example .env # VITE_API_BASE_URL=http://localhost:8000
npm install
npm run dev # http://localhost:3000- Get an API key from https://smith.langchain.com
- In
.env:LANGCHAIN_TRACING_V2=true LANGCHAIN_API_KEY=ls__your-key LANGCHAIN_PROJECT=neolend-morning-planner-demo - Restart the backend. The System Flow page's Reasoning panel will now show a "View trace ↗" link, and the Run Summary will link to the LangSmith project dashboard.
- No key set → identical behavior, minus the trace link.
This is the demo's core extensibility story - notice how little of the orchestrator needs to change:
- Write a new
app/mcp_servers/<tool>_server.pyfollowing the existing pattern (list_tools()+call_tool()handlers, backed by a JSON fixture). - Add its fixture file to
app/data/. - Register it in
mcp_client_manager.py'sSERVER_SCRIPTS,SERVER_DISPLAY_NAMES, andTOOL_CALL_PLANdicts. - Add it to
ALL_TOOLSinbrain.pyandmain.py. - Add it to the persona's
connected_toolslist inusers.jsonif authorized. - Add it to
TOOLSinfrontend/src/components/toolMeta.jsfor the UI.
brain.py's fan-out and reasoning logic itself does not change - this is the payoff of
building on MCP's uniform tool-calling interface instead of bespoke per-tool code.
- New persona: add an entry to
backend/app/data/users.jsonwith aconnected_toolslist. - Adjust urgency over the day: edit
resolved_at/acknowledged_attimestamps in the fixture files, or the threeas_oftimestamps insimulated_clock.json. - No code changes needed - the MCP servers, authorization, and reasoning all read from these files.
| Symptom | Likely cause | Fix |
|---|---|---|
401 Missing session token |
Frontend didn't send x-session-token |
Confirm login succeeded; check SessionContext |
| CORS error in browser console | CORS_ORIGINS doesn't include the frontend's origin |
Add the exact origin to CORS_ORIGINS in .env |
| Plan run hangs on a connector | MCP subprocess failed to start (e.g. Python path issue in a custom Docker base image) | Check backend container logs; the connector's SSE event will carry an error field |
| OpenAI 401/403 errors | OPENAI_API_KEY missing/invalid |
Check .env, restart backend |
| A connector is always skipped for a user who should have it | connected_tools in users.json doesn't list that tool for that persona |
Add the tool key to their connected_tools array |
| System Flow page empty on first visit | No plan has been run yet | Click "Run live", or run a plan from the Planner page first |
Reasoning output has _parse_warning / empty plan |
Model didn't return valid JSON | Check synthesize_plan.txt wording; the pipeline degrades gracefully rather than crashing |
| Component | Real | Simulated |
|---|---|---|
| MCP protocol (discovery + tool calls) | ✅ Official MCP SDK, real stdio handshake | |
| Parallel fan-out across connectors | ✅ Real asyncio.gather over real MCP sessions |
|
| Per-connector authorization | ✅ Real check; unauthorized servers are never spawned | Authorization map itself is a static fixture, not real OAuth |
| LLM reasoning (synthesis call) | ✅ Real OpenAI call | |
| Latency, token counts, cost | ✅ Computed from real OpenAI response + real MCP call timing | |
| LangSmith tracing | ✅ Real, if configured | |
| Login | Persona picker, no real Entra ID/SSO | |
| Outlook/Teams/Slack/Tracker data | Static JSON fixtures behind each MCP server, not live APIs | |
| Chat "draft a reply" | Produces a draft only; nothing is actually sent |
- Each
*_server.pyswaps itsload_fixture()calls for real Microsoft Graph / Slack Web API / Jira-or-equivalent calls - the MCP tool names/schemas can stay the same. mock_auth.py'sconnected_toolsmap becomes a real query against each connector's OAuth consent state (Microsoft Graph connectors, Slack app installs, etc.).mcp_client_manager.pyandbrain.pyneed no changes - this is the point of building on MCP: the orchestrator's fan-out/reasoning logic is decoupled from what's actually behind each tool.- Persona login → Entra ID / SSO.