Most agent demos start with one model, one prompt, and a handful of functions in the same process. That is a useful place to start, but it becomes difficult to evolve once the application needs multiple domains, multiple agent behaviors, and tools that should be owned independently.
I wanted to build something closer to a small platform than a single chatbot: a local-first application where an agent can use travel, finance, and entertainment capabilities without knowing how those capabilities are implemented or where they run.
The result is a Python application built with:
- Strands Agents for agent orchestration
- Ollama for a local language model
- Model Context Protocol (MCP) for the tool contract
- FastMCP for the gateway and domain servers
- FastAPI for the backend API
- Streamlit for a lightweight chat interface
- mise and uv for reproducible local setup
The important design choice is the MCP gateway. The agent talks to one MCP endpoint, while the gateway composes several focused domain servers behind it.
The Problem
A first version of an agent application often looks like this:
User -> Chat UI -> Agent -> Every tool in the application
That shape has a few problems:
- The agent client becomes coupled to every tool server. Adding a new domain means changing the client configuration or orchestration code.
- Tool ownership becomes unclear. Travel, finance, and entertainment concerns end up mixed together.
- Independent deployment is harder. A failure or restart in one domain can affect the whole tool surface.
- The system is difficult to inspect. There is no single place to ask which downstream services are healthy.
- Agent behavior and domain capabilities get tangled. Changing an agent's tone or workflow should not require changing the tool implementation.
The underlying problem was not simply "how do I call an LLM?" It was how to create clean boundaries around an agent that needs to use capabilities from several domains.
The Design Goal
I set three constraints for the design:
- The model should run locally during development.
- The agent should have one stable tool endpoint.
- Each domain should expose a small, independently owned MCP server.
That led to this architecture:
flowchart LR
UI[Streamlit UI] --> API[FastAPI backend]
API --> A[Strands agent]
A --> G[MCP gateway]
G --> T[Travel MCP server]
G --> F[Finance MCP server]
G --> E[Entertainment MCP server]
T --> TT[Travel tools]
F --> FT[Finance tools]
E --> ET[Entertainment tools]
The UI and backend have a straightforward responsibility: accept a conversation and return an answer. The agent decides whether tools are needed. The gateway hides the topology of the domain servers from the agent runtime.
What I Built
1. A registry of agent behaviors
Agent definitions live as flat modules under src/agents/. Each definition describes an agent identity, system prompt, and skills. The registry is the single source of truth:
_AGENT_REGISTRY: dict[str, AgentDefinition] = {
DEFAULT_AGENT.agent_id: DEFAULT_AGENT,
RESEARCH_AGENT.agent_id: RESEARCH_AGENT,
SUPPORT_AGENT.agent_id: SUPPORT_AGENT,
TRAVEL_ITINERARY_AGENT.agent_id: TRAVEL_ITINERARY_AGENT,
FINANCE_TRACKER_AGENT.agent_id: FINANCE_TRACKER_AGENT,
ENTERTAINMENT_SCOUT_AGENT.agent_id: ENTERTAINMENT_SCOUT_AGENT,
}
This separates how the agent should behave from which tools exist.
The backend exposes the available profiles through GET /api/agents, and the chat endpoint accepts an agent_id. That makes it possible to add a research-focused agent or a travel-planning agent without duplicating the MCP connection logic.
2. One MCP client surface for the agent
The orchestrator connects to the configured MCP endpoint, discovers its tools, and gives the discovered tool list to Strands:
with create_mcp_client() as mcp_client:
tools = mcp_client.list_tools_sync()
agent = build_agent(tools, agent_id=agent_id)
result = agent(prompt)
The orchestrator does not need to know whether a tool belongs to travel or finance. It only needs the MCP contract.
The MCP URL is normalized before connecting so streamable HTTP endpoints do not introduce avoidable redirect failures. This is a small detail, but transport details like this matter when local services are composed together.
3. A config-driven MCP gateway
The gateway loads its downstream routes from src/mcp/gateway/routes.json:
{
"routes": [
{
"name": "travel",
"url": "http://127.0.0.1:8001/mcp",
"envVar": "TRAVEL_MCP_SERVER_URL"
}
]
}
At startup, each route is mounted as a proxy namespace. A new downstream server can therefore be introduced by adding route configuration and implementing its own MCP server, without rewriting the agent client.
The route loader also supports environment-variable overrides and a complete configuration-path override through MCP_GATEWAY_ROUTES_CONFIG. That keeps local defaults convenient while leaving room for another environment to provide different service locations.
4. Focused domain servers
Each domain server owns only its own tools:
- Travel: geocoding, weather, place search, route distance, and Wikipedia summaries
- Finance: foreign exchange rates, historical rates, stock quotes, and crypto markets
- Entertainment: local events and movie search
The gateway composes these servers, but it does not absorb their domain logic. This keeps the boundaries useful: domain changes stay in the domain server, while gateway changes stay focused on composition and operations.
5. Health visibility through the gateway
The gateway exposes a gateway_health tool and the backend exposes GET /api/mcp-health. The health response includes the overall gateway status and the status of each downstream server.
That gives the application an operational view like this:
{
"gateway": "MCP Gateway",
"overall_status": "ok",
"downstream": {
"travel": {"status": "ok"},
"finance": {"status": "ok"},
"entertainment": {"status": "ok"}
}
}
A tool gateway should not be treated as a black box. If the agent cannot use a tool, the first question should be whether the downstream server is available and advertising the expected tools.
A Request Through the System
A chat request travels through the application like this:
sequenceDiagram
participant U as User
participant UI as Streamlit UI
participant API as FastAPI
participant A as Agent orchestrator
participant G as MCP gateway
participant D as Domain MCP server
U->>UI: Ask a question
UI->>API: POST /api/chat
API->>A: Build conversation prompt
A->>G: Discover and call tools
G->>D: Proxy the domain tool call
D-->>G: Return structured result
G-->>A: Return tool result
A-->>API: Generate final answer
API-->>UI: Return response JSON
For example, a question such as:
What is the weather in Sydney, and how far is it from the airport?
can be handled by the travel tool group without the UI knowing which server provides geocoding, weather, or route calculations.
Why Keep the Model Local?
The project uses Ollama during development, with the model and host configured through environment variables:
OLLAMA_MODEL=qwen3.5:4b
OLLAMA_HOST=http://127.0.0.1:11434
MCP_SERVER_URL=http://127.0.0.1:8000/mcp
A local model makes experimentation cheaper and keeps the basic development loop independent of a hosted inference provider. It also makes the architecture easier to test: the agent, gateway, and domain servers can be run on one machine with explicit local boundaries.
This does not mean a local model is always the right production choice. The orchestration layer is intentionally separate from the model implementation, so the model can change without changing the MCP architecture.
Running It Locally
The project uses mise to manage the toolchain and uv for dependencies.
mise install
mise run sync
cp .env.example .env
Start the domain servers first, then the integrated API and UI:
mise run mcp-travel
mise run mcp-finance
mise run mcp-entertainment
mise run api
mise run ui
The main endpoints are:
- Streamlit UI: the local Streamlit URL printed by
mise run ui - Chat API:
POST /api/chat - Agent catalog:
GET /api/agents - API health:
GET /api/health - MCP health:
GET /api/mcp-health - MCP gateway:
/mcp
The repository also includes tests for conversation prompt construction, agent skill composition, and invalid agent selection.
Design Tradeoffs
Why a gateway instead of connecting to every server directly?
A direct multi-client design can be simpler at very small scale. The gateway becomes valuable when the number of domain servers grows or when the agent runtime should have one stable connection point.
The tradeoff is an additional network hop and another component to operate. In this project, the gateway earns its place by providing composition, namespaces, route configuration, and downstream health checks in one location.
Why flat agent modules instead of a large class hierarchy?
The agent profiles are mostly configuration and prompt behavior. Flat modules make each profile easy to locate and review. The registry provides explicit discovery without adding inheritance or lifecycle complexity.
Why Streamlit for the UI?
The goal was to validate the architecture and interaction loop, not to build a full product frontend. Streamlit provides a usable chat surface quickly while keeping the backend contract visible and testable.
What I Learned
The most useful lesson was that agent architecture is largely about boundaries.
The model is only one part of the system. The more durable decisions were:
- giving agents a stable capability surface
- separating agent behavior from domain tools
- keeping each MCP server focused
- making service locations configurable
- exposing health information where composition happens
MCP is useful here not because it makes every tool intelligent, but because it gives tools a common interface that the agent runtime can discover and use.
What I Would Build Next
The current implementation is a foundation. The next improvements would be:
- streaming agent responses through the API and UI
- richer tool metadata and request tracing
- authentication and authorization at the gateway
- contract tests for every downstream MCP server
- retries and timeouts per route
- persistent conversation storage
- production model adapters alongside the local Ollama adapter
The core shape would stay the same: specialized agents, focused domain servers, and one observable MCP gateway between them.
Closing Thoughts
The project started as a question about building a useful local agent. It became a question about how to let an agent grow without turning the entire application into one tightly coupled tool registry.
The answer was to put the boundary in the right place:
User experience -> API -> Agent -> MCP gateway -> Domain servers
That structure keeps the system understandable today and gives it a path to evolve tomorrow.
The complete project is available on GitHub, including the setup commands, route configuration, agent registry, MCP servers, and tests.
Top comments (0)