使用说明
CrewAI + MCP Stocks Analysis — Sector Agent Exercise (solved)
A teaching exercise for Agents & MCP, carrying the completed solution.
Four CrewAI agents analyse a stock by calling a real MCP server over stdio. Every call they make is recorded, and a deterministic check afterwards compares the finished report against what the tools actually returned — so you can prove whether the report is grounded rather than hoping it is.
The exercise was: add a fourth agent, a Sector Analyst, that compares the target stock against its sector peers. It is done, and the interesting part is how little Python it took. See The exercise below.
What changed in this version. Two things, and the second is the reason for the first.
- The architecture caught up with the demo. The previous version of this repo decorated functions with
@mcp.tool()and then imported those same functions directly intoagents.py, re-wrapping them with CrewAI's@tool. No server was ever started and no JSON-RPC ever crossed a process boundary — the "MCP" was decoration in the literal sense. It also depended onfastmcp, a different package from the one the code imported. Now the crew spawnspython -m stocks_crew.mcp_serverand talks to it as a client, exactly the way Claude Desktop or another team's agent would. Packaging moved fromrequirements.txtto uv, and the flatagents.py/mcp_server.py/datasource.pyat the repo root became thesrc/stocks_crew/package.- The sector agent moved out of Python and into YAML. It used to be an
Agent(...)literal increate_agents(), aTask(...)literal increate_tasks(), and atasks[2].agent = agents["sector"]line you had to remember to add. It is now a block inconfig/agents.yamland a block inconfig/tasks.yaml.crew.pydid not change.Its task brief changed too, and not cosmetically — the old one asked for numbers no tool in this project can return. That is the sharpest lesson in the exercise; it has its own section.
Quick start
Requires uv:
curl -LsSf https://astral.sh/uv/install.sh | sh
Then:
uv sync # creates .venv and installs everything from uv.lock
cp .env.sample .env # add OPENAI_API_KEY or GEMINI_API_KEY (either one)
uv run streamlit run streamlit_crewai_app.py
The first
uv syncpulls CrewAI, chromadb and the OpenAI and Google SDKs — expect a few hundred MB and a minute or two. Subsequent syncs are instant.
That is the whole thing. There is no API server to start first — the old
instructions in this repo told you to run uvicorn api:app before the Streamlit
app, and that is no longer true (it was never needed for the CrewAI path). The
crew runs inside the Streamlit process and launches the MCP subprocess itself.
The FastAPI bridge is a separate, optional entry point for HTTP clients:
uv run uvicorn stocks_crew.api:app --host 0.0.0.0 --port 8001 --reload
Prove the MCP server is real before you spend a token:
uv run python -m stocks_crew.crew --list-tools # real handshake, prints the tool list
uv run stocks-crew-mcp # the server itself; silent stdout is correct
npx @modelcontextprotocol/inspector uv run stocks-crew-mcp
Architecture
┌────────────────────────┐
browser ────────▶│ streamlit_crewai_app │ :8501
└───────────┬────────────┘
│ in-process call
┌───────────▼────────────┐ ┌──────────────────┐
│ stocks_crew.crew │◀──────▶│ stocks_crew.trace│
│ 4 agents, sequential │ events │ the tool trace │
└───────────┬────────────┘ └──────────────────┘
│ MCPServerAdapter (stdio, JSON-RPC)
┌───────────▼────────────┐
│ stocks_crew.mcp_server │ subprocess, FastMCP
└───────────┬────────────┘
│
┌───────────▼────────────┐
│ tools → analytics │ pandas + yfinance
│ → datasource │
└────────────────────────┘
optional: stocks_crew.api (:8001) ── data routes call tools/ directly,
/analyze runs the crew over MCP
How the crew talks to the MCP server
crew.py spawns the server as a child process and speaks MCP over its
stdin/stdout:
params = StdioServerParameters(
command=sys.executable,
args=["-m", "stocks_crew.mcp_server"],
env={**os.environ},
)
with MCPServerAdapter(params) as mcp_tools:
... # mcp_tools are now CrewAI tools the agents can call
Each run spawns a fresh MCP subprocess (~1s of startup). That is deliberate for a demo: you can watch the process appear and see the tool calls in the logs.
Consequence: nothing in the server may write to stdout, since stdout is
the protocol channel. Use logging (which goes to stderr). This is why
datasource.py logs its yfinance column debug instead of print()ing it.
The crew
Four agents, run sequentially, all on the same model — openai/gpt-4o-mini or
gemini/gemini-2.5-flash, depending on which provider the run picked (see
Choosing a model provider):
| Agent | MCP tools | Job |
|---|---|---|
| Research Specialist | search_symbols, latest_quote, price_series |
Confirm the symbol is real, get the current quote and recent history. |
| Technical Analyst | indicators, detect_events |
Read SMA/EMA/RSI and the gap / volatility / 52-week flags. |
| Sector Analyst | search_symbols, latest_quote, indicators |
Confirm 3–5 peers, fetch each one's quote and indicators, place the target among them. |
| Report Writer | none | Synthesize the three into a report. A writer with tools re-fetches data and quietly contradicts the analysts. |
Roles, backstories and task briefs live in src/stocks_crew/config/*.yaml, so a
prompt can be tweaked without touching Python. Task order in tasks.yaml is
the run order.
The exercise
Goal: add a fourth agent, a Sector Analyst, that compares the target stock against its sector peers.
What it took
Two YAML blocks. crew.py builds whatever agents.yaml declares and runs
whatever tasks.yaml declares, in file order, so a new agent is a new block:
# src/stocks_crew/config/agents.yaml
sector:
role: Sector Comparison Specialist
goal: >
Place {symbol} against three to five peers traded in the same sector, using
only quote and indicator values fetched for each peer.
backstory: >
...
tools:
- search_symbols
- latest_quote
- indicators
# src/stocks_crew/config/tasks.yaml
sector:
description: |
...
expected_output: >
...
agent: sector
context:
- research
- technical
Placement matters in both files, for different reasons:
- In
agents.yaml, block order sets the column order in the Streamlit UI. - In
tasks.yaml, order is execution order.sectormust come afterresearchandtechnical, becausecontextmay only name tasks that have already run — and beforereport, which consumes its output.
Then one edit that is easy to forget: the report task had to be told the
sector output exists. That means adding - sector to its context and a
sector section to its brief. Without it the sector agent runs, spends tokens, and
its work never reaches the reader — the quietest possible way for this exercise
to look like it worked. tests/test_crew.py::test_the_report_task_is_given_the_sector_comparison
pins it.
What did not change
crew.py — no new Agent(...), no new Task(...), no tasks[2].agent = ....
Compare with the integration checklist the old version of this exercise needed:
| Old (Python) | Now (YAML) |
|---|---|
sector_agent = Agent(...) in create_agents() |
a block in agents.yaml |
add "sector" to the returned agents dict |
— (the dict is the YAML) |
sector_task = Task(...) in create_tasks() |
a block in tasks.yaml |
add sector_task to the returned list |
— (file order is the list) |
tasks[2].agent = agents["sector"] |
agent: sector in the task block |
renumber tasks[3].agent = agents["report"] |
— (nothing is indexed) |
col1, col2, col3, col4 = st.columns(4) |
— (st.columns(len(defaults))) |
Four of those seven steps existed only to keep two Python lists in sync by index, and "wrong task index" was on the old hint file's list of common mistakes. That class of bug is now unrepresentable.
The Python that did change is cosmetic, and only where a hardcoded number had crept in:
streamlit_crewai_app.py— a display label forsector, andof 3→of {len(defaults)}on the agent cards. An agent with no label still gets a column, labelled with its key.src/stocks_crew/api.py—sector_toolsadded to the deprecated per-agent fields, so requests written against the old API still land on the right agent.crew.py— the "running research → technical → report…" progress line now derives that list from the config instead of spelling it out.
The P/E trap
The obvious sector brief — the one the previous version of this exercise shipped — asks for:
- Price performance (1M, 3M, 6M, 1Y)
- Key financial metrics (P/E ratio, market cap, revenue growth)
- Technical indicators (RSI, moving averages, volatility)
- Sector positioning and competitive advantages
Read that against the tool table. This desk serves quotes, OHLCV history, SMA/EMA/RSI and event flags. It returns no P/E ratio, no market cap, no revenue growth. Item 2 asks the agent for numbers it has no way to obtain — so it supplies them from the model's memory of some earlier year, stated with exactly the same confidence as the figures it actually fetched.
On the old architecture nothing noticed. On this one, guardrails.py compares
every figure in the report against the full output of every tool call, and those
invented metrics come back as ungrounded_figures in red.
The fix is not a softer guardrail. It is a brief that asks for what the tools can supply, and says so out loud:
Do NOT report P/E ratios, market capitalisation, revenue growth, analyst ratings or index membership. No tool on this desk returns them, so any figure you write for them would be invented. State once that valuation and fundamental metrics are outside the available tools, and move on.
The general rule: an agent's job description is bounded by its tools. Writing a task that outruns them is the most common way to manufacture a hallucination, and it is a design mistake, not a model failure. Run the old brief once with the guardrail panel open — it is worth the tokens.
There is a second, subtler gap here, and it is a good thing to argue about in
class. The peer tickers are the model's own suggestion — no tool returns a peer
list. The brief requires each one to be confirmed with search_symbols, which
proves the ticker exists but not that it is genuinely a peer. So the peers'
numbers are grounded and their relevance is not, and the grounding
guardrail cannot tell the difference. Ask the room how they would check the
second thing.
The tool trace — the point of the demo
The task prompts say things like "Call latest_quote. Do not estimate prices."
That is a prompt, not a guarantee. The trace is how you find out whether the
model complied.
trace.py subscribes to crewai_event_bus and records one entry per tool call:
name, the arguments the model chose, both timestamps, and a preview of the
output. It listens to the framework rather than wrapping the tools, because
after this refactor there is no local function left to wrap — the tools live in
another process behind MCP.
Things worth doing in class:
- Take
latest_quoteaway from the Research agent in the sidebar and re-run. Watch the report still quote a price, and watch the trace show where it did not come from. - Take
search_symbolsaway from the sector agent. It can no longer confirm a peer exists, and the brief tells it to drop unconfirmed candidates — see whether it does, or whether it quietly compares against tickers it never checked. - Ask for a symbol that Yahoo has no data for. The tools answer
{"error": "no_data"}in band rather than raising, the agent is supposed to say so, and the trace shows the call really happened. - Run with an empty tool list for every agent. The crew produces a confident, entirely fabricated report and the trace is empty. That contrast is the lesson.
Note what the sector agent does to the trace: it calls up to three tools per peer, so a five-peer run is a dozen-plus calls against the two or three of a run without it. Cost scales with the crew, and it scales in the trace, where you can see it.
There is no explain tool
The old server exposed an explain tool that called OpenAI from inside the MCP
process, and the old Technical Analyst listed it among its tools. It is gone. The
LLM layer sits above MCP, in the crew that consumes these tools — if the crew's
own output were also a tool, the crew could call itself.
tests/test_mcp_server.py pins this.
Guardrails
Three of our own, at three different points in the run — plus the framework's, which is worth a section of its own for what it does not do.
On the input. symbol is untrusted text on its way into four agent prompts
and into MCP tool arguments, so it is validated (crew.validate_symbol) before
it can become instructions: letters, digits, . and -, up to 10 characters. A
bad ticker is a 422 from the API and an invalid_symbol result from
run_analysis — before any prompt or subprocess runs.
On the agents' reach. The tools: list in agents.yaml is an allow-list per
agent, applied when the crew is built. The report writer has none. The sector
agent deliberately lacks price_series: a year of history for five peers is
thousands of rows through the model's context, for a comparison latest_quote
and indicators already support. An allow-list is a budget as much as a
boundary.
On the output. guardrails.py runs after crew.kickoff() and checks the
finished report against the tool output the agents actually received:
- Grounding — every figure in the report must match a number some tool returned. This is the trace turned from evidence into a verdict.
- Advice — the task briefs forbid investment advice, price targets and predictions. This checks whether that held.
It is deterministic Python, not a second model, because a guardrail that can
hallucinate is not a guardrail. The result rides back on the run_analysis
result as guardrail and is rendered between the report and the trace: claim,
verdict, evidence.
Things worth arguing about in class, all of them visible in guardrails.py:
- It reports, it does not block. Should a failed check withhold the report instead, or hand it back to the writer for one retry? Where does that loop end?
- Its tolerances are chosen to under-report. It accepts rounding (
187.7712→187.77), rescaling (44,123,456→ "44.1 million") and a sign carried by the prose ("down 1.24" for-1.24), and it ignores bare integers under 1000 because indicator windows are not market figures. Every one of those is a hole a determined hallucination could fit through. The alternative is a guardrail that fires on every honest run and gets switched off in week two. - The advice check matches phrases, never the bare word "buy" — otherwise the
mandatory disclaimer ("not a recommendation to buy or sell") fails the report
that complied.
tests/test_guardrails.pypins that case. - It checks against
ToolTracer.evidence, the full tool output, not the 240-characterresult_previewthe UI shows. A display limit is not a reason to blind the check. - The sector agent widens one of these holes, and it is worth watching. Five peers' worth of quotes and indicators is a much larger pool of grounded numbers, so a coincidental match is likelier — a figure can pass as "grounded" while being grounded in the wrong peer's data. The check answers "where did this number come from?", never "is this sentence true".
And the framework's own. CrewAI ships HallucinationGuardrail, attached to a
task rather than run afterwards. The report task opts into it in tasks.yaml:
report:
...
hallucination_guardrail: true # or a mapping: {threshold: 8.0}
from crewai.tasks.hallucination_guardrail import HallucinationGuardrail
Task(..., guardrail=HallucinationGuardrail(llm=llm))
In the open-source crewai package this class does nothing. It is a
placeholder whose __call__ returns (True, output) for every input and logs
"Hallucination detection is a no-op in open source, use it for free at
https://app.crewai.com". threshold and context are stored and ignored. Hand
it a report saying a stock trades at 999999.00 and that you should buy it, and it
passes. The real implementation is installed by the hosted platform, which sets
the module-level _validate_output_hook the placeholder defers to.
It is wired up here anyway, and crew.guardrail_status() reports
enforcing: False so the UI can say so in as many words. Four things worth
taking from that:
- A guardrail nobody has verified is a liability, not a safeguard. The import succeeds, the constructor succeeds, the task runs, the logs look healthy. The only signal that nothing is being checked is a warning line the size of every other warning line. Check what your guardrails actually do before you count them.
- It could not do the job here even if it were live. It is constructed before
kickoff(), so the tool output the report should be grounded against does not exist yet — the best reference context available at that moment is the task's ownexpected_output.guardrails.pyruns after and gets the real evidence. When a check runs decides what it is able to know. - Deterministic beats delegated for this question. "Does 187.77 appear in any tool result?" is settled by arithmetic in 190 lines that run offline, on any machine, with no vendor account. Asking a model costs a call, adds latency, and puts a thing that can hallucinate in charge of catching hallucinations.
- It does not close this exercise's open gap either. The sector agent's peer choice is ungrounded by construction — see "The gap that remains" in EXERCISE_INSTRUCTIONS.md. A faithfulness score over the task output would not catch an airline compared against a bank, because every number in that comparison is real.
Good exam question: what would you have to build to make an LLM judge worth trusting here, and how would you test it?
Project structure
pyproject.toml deps, entry points, pytest config
uv.lock pinned, committed — this is the reproducible bit
streamlit_crewai_app.py UI (:8501)
src/stocks_crew/
datasource.py yfinance + Yahoo search; returns DataFrames/dicts
analytics.py pure pandas: SMA, EMA, RSI, gap/vol/52w flags
tools.py the five tool functions; return JSON strings
mcp_server.py FastMCP registration + main() -> stdio transport
crew.py the MCP client + the crew builder
llm.py which provider and model the crew runs on
trace.py records tool calls off the CrewAI event bus
guardrails.py output check: report figures vs. tool results
console.py UTF-8 stdout, so CrewAI's emoji do not crash cp1252
config/agents.yaml roles, goals, backstories, per-agent tool lists
config/tasks.yaml task briefs, run order, context wiring
api.py optional FastAPI bridge (:8001)
tests/ pytest; no network or LLM calls by default
hints/ the exercise hints, rewritten for the YAML crew
EXERCISE_INSTRUCTIONS.md what the exercise was, and how it was solved
review-pr/ an unrelated Claude Code skill; not part of the demo
MCP tools
Served by stocks-crew-mcp, all returning JSON strings:
| Tool | Arguments |
|---|---|
search_symbols |
query |
latest_quote |
symbol |
price_series |
symbol, interval="daily", lookback=180 |
indicators |
symbol, window_sma=20, window_ema=50, window_rsi=14 |
detect_events |
symbol |
That is the whole menu. Nothing here returns fundamentals — see The P/E trap.
HTTP API (optional)
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /health |
— | {"status": "ok"} |
| GET | /mcp/tools |
— | live handshake: {tools: [{name, description}], count} |
| GET | /agents |
— | each agent and the tools it may call |
| POST | /search |
{q} |
array of {symbol, name, region, currency} |
| POST | /quote |
{symbol} |
{symbol, price, change, change_percent, volume, timestamp} |
| POST | /series |
{symbol, interval, lookback} |
array of OHLCV rows |
| POST | /indicators |
{symbol, window_sma, window_ema, window_rsi} |
{symbol, last_close, sma, ema, rsi} |
| POST | /events |
{symbol} |
{symbol, date, gap_up, gap_down, vol_spike, is_52w_high, is_52w_low} |
| GET | /providers |
— | {providers: [{name, label, api_key_env, model, configured}], default} |
| POST | /analyze |
{symbol, provider, api_key, language, tone, horizon_days, tools} |
{success, result, provider, model, tool_trace, agent_tools, mcp_tools, guardrail, …} |
The data routes call tools.py in-process — a button click should not pay for
spawning a subprocess. /analyze and /mcp/tools go over MCP, because that is
the part the demo is about.
tools on /analyze is a per-agent allow-list keyed by agent name from
agents.yaml; omitted agents keep their YAML defaults:
{
"symbol": "AAPL",
"tools": {
"research": ["latest_quote"],
"technical": ["indicators", "detect_events"],
"sector": ["search_symbols", "latest_quote"]
}
}
The older research_tools / technical_tools / sector_tools / report_tools
fields still work and are folded into tools.
Tool failures come back as {"error": "<code>", "message": "..."} with a 200, so
the UI renders them inline. A malformed request is a real 422.
Choosing a model provider
The crew runs on OpenAI or Google Gemini. You need a key for one of them, not both.
OPENAI_API_KEY=sk-... # .env — or
GEMINI_API_KEY=AIza...
Which one a given run uses is decided in src/stocks_crew/llm.py, in this
order:
- the
providernamed by the caller — the Streamlit sidebar dropdown, or{"provider": "gemini"}in an/analyzebody; LLM_PROVIDERin.env;- whichever provider has a key set;
- OpenAI, which is also whose "key required" error you get when nothing is set.
Everything below that decision is vendor-neutral. CrewAI routes on the model
string, written in LiteLLM's <provider>/<model> syntax — openai/gpt-4o-mini
builds an OpenAI client that reads OPENAI_API_KEY, gemini/gemini-2.5-flash
builds a Gemini client that reads GEMINI_API_KEY — so switching providers is
one model string and one environment variable:
llm = LLM(model="gemini/gemini-2.5-flash", temperature=0.2)
build_crew() takes that string and never learns which vendor is behind it; the
agents, the MCP server and the guardrail do not change at all. That is the point
worth showing on screen: the provider is a detail of the LLM layer, not of the
agent design — and the sector agent added by this exercise needed no change to
run on a different vendor.
CrewAI 1.x calls each vendor through its own SDK rather than through LiteLLM, so
crewai[google-genai] is in the dependencies — without it, a gemini/… model
has nothing to route to. It also validates the model part against a list of
models it knows; a name it does not recognise falls through to LiteLLM, which
this project does not install. If you point GEMINI_MODEL or OPENAI_MODEL at
something newer than the pinned crewai, upgrade crewai.
GET /providers reports both vendors and which of them currently has a key.
Environment variables
| Variable | Purpose |
|---|---|
OPENAI_API_KEY |
Key for the OpenAI provider. Without a key for the chosen provider, /analyze returns openai_api_key_required / gemini_api_key_required — there is no offline fallback. |
GEMINI_API_KEY |
Key for the Gemini provider (Google AI Studio). |
LLM_PROVIDER |
Optional; openai or gemini. Which provider a run uses when the caller does not name one. |
OPENAI_MODEL |
Optional; defaults to gpt-4o-mini. |
GEMINI_MODEL |
Optional; defaults to gemini-2.5-flash. |
LOG_LEVEL |
Optional; DEBUG shows the MCP handshake and yfinance internals. |
Either model variable takes a bare name or the fully-qualified LiteLLM
<provider>/<model> form — the routing prefix is added for you.
The Streamlit sidebar also accepts a provider and a key at runtime, which take precedence. Sidebar keys are stored per provider, so switching vendors never sends an OpenAI key to Google.
Note the key is now needed for any run. The old README said it was "only
needed for the AI-powered explanation tool" and that the rest worked without it —
true when explain was the only LLM call. The crew itself is the LLM layer now.
Tests
uv run pytest # unit + a real MCP stdio round trip; no network, no tokens
uv run pytest -m network # additionally hits Yahoo Finance
uv run pytest -m llm # additionally runs the crew for real (needs a key, spends tokens)
tests/test_mcp_server.py launches the server as a subprocess and does a real
list_tools() handshake — that is the regression test that keeps MCP from
silently becoming decorative again.
The sector agent's own tests are in tests/test_crew.py, under "The exercise",
and every one of them is an assertion about YAML.
Troubleshooting
openai_api_key_required/gemini_api_key_required— no key for the provider the run chose, neither in.envnor typed into the Streamlit sidebar. The code tells you which provider it picked and which variable it looked for; if that is the wrong provider, setLLM_PROVIDERor passproviderexplicitly.- The crew hangs, or the adapter reports a protocol error — almost always
something printing to stdout inside the server process. Check for stray
print()calls indatasource.py,analytics.pyortools.py. - Empty trace after a successful run — the agents answered without calling anything. That is a finding, not a bug; see "The tool trace" above.
- The sector section quotes P/E ratios — the brief drifted. No tool returns them; see The P/E trap.
- A run takes a long time — there is no timeout, and four agents take longer
than three: the sector agent calls up to three tools per peer. The old version
had a
threading.Timerthat raised in a timer thread, which could not actually interruptkickoff(); it was removed rather than left in as reassurance. Interrupt the process, or capmax_iteron the agents increw.build_crew. - Empty series / "No data available" — Yahoo rate-limits aggressively. Wait a minute or try another symbol. With several peers to fetch, the sector agent hits this sooner than the rest of the crew does.
[CrewAIEventsBus] Sync handler error … 'charmap' codec can't encode(Windows) — CrewAI's console listener prints emoji and the legacy cp1252 console cannot encode them. Harmless, but noisy in a demo, so the entry points callconsole.use_utf8_console(). If you importstocks_crew.crewfrom your own script, call it there too.uv syncpicks the wrong Python —.python-versionpins 3.11; runuv python install 3.11if uv cannot find one.
License
For demo/educational purposes only. No investment advice.