Repository navigation
MCP Tool Server
mcp_server/ exposes AlphaAgent's market and news data over the Model Context Protocol
(MCP), an open JSON-RPC protocol in which a server advertises typed tools and any MCP-capable
client discovers and calls them. The server is a thin, strictly read-only adapter over the same
service layer the CrewAI crew uses.
Why it exists, in the words of mcp_server/server.py:
The news scraper and the market-data client used to be private Python functions reachable only from inside the CrewAI crew. Wrapping them in an MCP server turns them into a protocol-level capability: any MCP-capable client (Claude Code, Cursor, a local Ollama agent, another CrewAI crew, a LangGraph workflow) can call them without a line of backend code changing.
Two consequences matter for the architecture:
-
One implementation, two consumers. The crew can source its tools from this server
instead of calling the service layer in-process (
AI_TOOLS_VIA_MCP=true), which means the agent path and the external path are literally the same code. See Agent-Debate. - A hard capability boundary. The MCP surface is market data only. Execution stays behind the REST API and the guard — see Execution-Guard and API-Reference.
| Property | Value | Source |
|---|---|---|
| Implementation |
mcp_server/server.py (FastMCP), entrypoint mcp_server/__main__.py
|
— |
| SDK |
mcp 1.28.1 in this venv (transitive dependency of crewai==1.15.23) |
pip show crewai → mcp
|
| Server name / identity | alphaagent-market-data |
FastMCP(name=...), asserted in test_mcp
|
| Tools | 6, all read-only |
@mcp.tool() declarations |
| Default transport | stdio |
run(transport="stdio") |
| HTTP bind |
0.0.0.0:8100, path /mcp
|
MCP_HOST, MCP_PORT, mcp.settings.streamable_http_path
|
| Compose service |
mcp (container alpha_mcp), port 8100:8100
|
docker-compose.yml |
Every tool returns a JSON string. _json() serialises Decimal as a string (never a float)
and dataclasses via their as_dict(). Tool names are declared with @mcp.tool() and the
docstrings are the tool descriptions an LLM client sees — test_mcp asserts every tool has a
description longer than 30 characters, because "an undocumented tool is unusable by an LLM
client".
| Tool | Arguments | Returns |
|---|---|---|
get_market_price |
ticker: str |
Latest quote: price, previous_close, change_pct, currency, source, as_of. source is yfinance, cache or fallback. |
get_news_sentiment |
ticker: str, limit: int = 10
|
Aggregated sentiment (label/score/confidence), articles[] each with polarity and sentiment, plus positive_count, negative_count, neutral_count, headline_count, sources_used, degraded. |
get_price_history |
ticker: str, period: str = "1y"
|
latest, period_high, period_low, change_pct, sma_50, sma_200, drawdown_from_high_pct, below_sma_50, below_sma_200, degradation_flags, and points — a [date, close] array for charting. |
get_financial_health |
ticker: str |
total_debt, total_cash, debt_to_equity, current_ratio, profit_margin, free_cash_flow, trailing_pe, analyst_target, recommendation_key, sector, red_flags[], degraded. |
get_market_snapshot |
ticker: str |
price, history, fundamentals, news (sentiment, counts and top_headlines) in one call, with a top-level degraded flag. |
list_watchlist |
none |
tickers[] and count, read from the ALPHA_WATCHLIST setting. |
Notes that are verifiable in the source:
-
limitis clamped, not trusted.get_news_sentimentcomputesmax(1, min(int(limit or 10), 30))before callingbuild_news_report, so a client cannot ask for 10 000 headlines. (Alimitof0orNonebecomes the default 10.) -
periodfalls back to1yviaperiod or "1y"; the default window is what makes the 200-day average computable. -
get_market_snapshotis a convenience fan-in, not a new data source: it callsget_latest_quote,_price_history,_financial_healthandbuild_news_report, and reuses the quote price for the fundamentals call. Itsdegradedflag is true only when the quote, the history and the fundamentals are all missing. -
The internal imports are aliased (
_financial_health,_price_history) because the@mcp.tool()functions intentionally share those names — an unaliased import would make each tool call itself recursively. -
Degradation is part of the contract. Every tool returns an explicit
{"ticker": ..., "error": ..., "degraded": true}payload instead of raising when an upstream provider is unavailable.get_financial_healthnever returnsNone: an unavailable ticker — including any crypto pair, which has no balance sheet — yields a degraded object, so a consumer can state that it found no evidence rather than crashing.
run() normalises the requested transport (http → streamable-http; sse is also accepted;
anything unrecognised falls back to stdio) and calls mcp.run(transport=...).
| Transport | How to start | Use when | Behaviour |
|---|---|---|---|
stdio (default) |
python -m mcp_server |
A local MCP client that spawns the process: Claude Code, Cursor, a local agent. | The client owns the process; JSON-RPC frames travel over stdin/stdout. |
streamable-http |
python -m mcp_server --transport http → serves http://0.0.0.0:8100/mcp
|
The containerised microservice that other services (and the worker) call over the network. | Long-lived HTTP endpoint, one service for many clients. |
Under stdio, stdout belongs to the protocol. _force_logging_to_stderr() removes any
root StreamHandler writing to stdout and installs a stderr handler — and it is called twice,
because Django's LOGGING config installs its own stdout handler during django.setup(). The
module docstring is explicit that this is "a correctness requirement, not a preference", and
test_mcp.McpLoggingTests asserts no root stream handler targets sys.stdout. Default level is
WARNING, overridable with MCP_LOG_LEVEL (the compose service sets INFO).
Host and port come from MCP_HOST / MCP_PORT (defaults 0.0.0.0 / 8100) and can be
overridden per-invocation with --host / --port, which mutate mcp.settings before run().
In docker-compose.yml the service is:
mcp:
<<: *app
command: [python, -m, mcp_server, --transport, http, --host, 0.0.0.0, --port, "8100"]
environment:
<<: *app_env
MCP_LOG_LEVEL: INFO
ports: ["8100:8100"]
healthcheck:
test: [CMD-SHELL, "python -c \"import socket; socket.create_connection(('127.0.0.1', 8100), 3)\""]The health check is a TCP connect, not an HTTP request, because the MCP endpoint expects a
JSON-RPC handshake. Binding 0.0.0.0 and publishing the port is deliberate — the tools serve
public market data and are read-only; the compose comment notes the mapping can be dropped if
only the worker needs them. mcp_server/* is exempted from ruff's S104
(hardcoded-bind-all-interfaces) for this reason.
No tool reads or writes portfolios, positions, recommendations or the ledger. Nothing here can place a trade. Execution stays behind the REST API and its guard, so a compromised or over-eager agent cannot move money through MCP.
This is enforced by construction and then pinned by tests
(test_mcp.McpIsolationTests), because a claim about a negative is only as good as its check:
| Test | Assertion |
|---|---|
test_tools_do_not_query_the_database |
Calls all five data tools inside CaptureQueriesContext with a real portfolio in the database and asserts zero ORM queries. |
test_no_tool_name_suggests_account_access |
No tool name may contain portfolio, trade, buy, sell, execute, approve, balance, position, transaction, recommendation, credential or token. |
test_tool_descriptions_do_not_advertise_mutations |
No description may say "place a trade", and "execute" may not appear outside a "cannot" clause. |
The only Django coupling in the tool surface is django.conf.settings in list_watchlist,
which reads the ALPHA_WATCHLIST list (default AAPL,TSLA,BTC) — no model imports. Django is
still booted at import time (django.setup()) because the tools delegate to the shared service
layer (services.market_data, services.news, services.fundamentals) and because settings
must resolve consistently; but no tool opens a database connection. The cache reads inside those
services go to the Django cache backend, not the ORM.
The two-call pattern is the boundary in practice:
MCP (read-only evidence) → LLM proposal → evaluate_proposal guard → ledger
^ the only DB-mutating path
An MCP client can therefore gather everything needed to form an opinion and still be unable to act on it. See Execution-Guard for the guardrail rules themselves.
Routing is opt-in:
| Setting | Env var | Default | Effect |
|---|---|---|---|
AI_CONFIG["TOOLS_VIA_MCP"] |
AI_TOOLS_VIA_MCP |
false |
Route the crew's tools through MCP instead of calling the service layer in-process. |
AI_CONFIG["MCP_SERVER_URL"] |
AI_MCP_SERVER_URL |
"" (compose: http://mcp:8100/mcp) |
The streamable-HTTP endpoint. |
AlphaAgentOrchestrator.mcp_servers(allowed) returns [] — so the caller falls back to
in-process tools — when routing is disabled, when the URL is empty (logging
TOOLS_VIA_MCP is on but AI_MCP_SERVER_URL is empty), or when crewai.mcp.config.MCPServerHTTP
cannot be imported (older CrewAI). Otherwise it returns exactly one reference:
def predicate(tool: dict) -> bool:
# CrewAI namespaces MCP tool names, so match on the suffix.
name = str(tool.get("name", ""))
return any(name.endswith(suffix) for suffix in allowed)
return [MCPServerHTTP(url=url, streamable=True, tool_filter=predicate)]The tool_filter is the mechanism that gives each agent only the tools its role needs. CrewAI
prefixes MCP tool names (e.g. mcp_get_market_price), so the predicate matches on the
suffix rather than equality — test_enabled_with_a_url_returns_one_filtered_reference
asserts predicate({"name": "mcp_get_market_price"}) is true while the news tool is false.
| Agent | Tools allowed over MCP | In-process fallback when MCP is off |
|---|---|---|
| Bullish Research Analyst |
get_news_sentiment, get_price_history
|
tools["news"], tools["history"] (2) |
| Risk Assessor (Short Seller) |
get_financial_health, get_price_history, get_news_sentiment
|
tools["financial"], tools["history"], tools["news"] (3) |
| Chief Investment Officer (CIO) | get_market_price |
tools["market"] (1) |
When MCP routing is live the agents are constructed with tools=[] and mcps=<refs>, so there
is exactly one tool surface per agent — no duplicated or shadowed capabilities.
test_crew_uses_mcp_refs_and_no_local_tools_when_enabled asserts len(agent.tools) == 0 and
len(agent.mcps or []) == 1 for all three; the disabled-path test asserts tool counts
[2, 3, 1] and empty mcps. Default is off because in-process calls are one less network hop
(.env.example). See Agent-Debate for the bull/bear/CIO design.
Only the server side is verified here (tool list, transports, bind address, path). The client snippets below use each client's documented MCP configuration format; they were not executed against a running client in this repository.
Start the server the way the client will, and confirm it boots:
cd /root/projects/AlphaAgent
venv/bin/python -m mcp_server # stdio: prints nothing, waits on stdin. Ctrl-C to exit.
venv/bin/python -m mcp_server --help # --help works without booting DjangoTwo environment facts make the stdio launch work from a client:
-
server.pycallsos.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings"), andconfig/settings.pyloads.envfrom an absoluteBASE_DIR, so secrets do not need to be duplicated into the client config — a local.envis enough. -
python -m mcp_serverresolves the package fromsys.path, so the client must either launch with the repository as the working directory or setPYTHONPATH.
stdio — Claude Code (registered once, from the repository root):
claude mcp add alphaagent -- /root/projects/AlphaAgent/venv/bin/python -m mcp_serverstdio — Cursor (.cursor/mcp.json, absolute paths, no secrets):
{
"mcpServers": {
"alphaagent": {
"command": "/root/projects/AlphaAgent/venv/bin/python",
"args": ["-m", "mcp_server"],
"env": { "PYTHONPATH": "/root/projects/AlphaAgent" }
}
}
}Streamable HTTP — start the service, then point clients at the URL instead of a command:
docker compose up -d mcp # or: venv/bin/python -m mcp_server --transport http
claude mcp add --transport http alphaagent http://127.0.0.1:8100/mcp{ "mcpServers": { "alphaagent": { "url": "http://127.0.0.1:8100/mcp" } } }Reachability check that does not require an MCP client (the same probe the compose health check uses — a JSON-RPC handshake would be needed for anything deeper):
python -c "import socket; socket.create_connection(('127.0.0.1', 8100), 3); print('reachable')"For a protocol-level verification without a client, core/tests/test_mcp.py drives the real
server through the official SDK over an in-memory transport — including the handshake, the
advertised tool list and every payload shape. See Quality-and-Testing.
Related pages: Architecture, Agent-Debate, Execution-Guard, API-Reference, Configuration, Operations, Quality-and-Testing, Engineering-Decisions.
Start here
The system
Interfaces
Running it
Background