MCP Ops Agent
MCP Ops Agent
A real Model Context Protocol server (built on FastMCP from the official MCP Python SDK) for an operations desk, plus a self-consuming agentic orchestrator that reaches those tools the same way any external client does — over genuine MCP JSON-RPC. The tools are defined exactly once and every consumer goes through the protocol; nothing calls the business logic behind its back.
The service owns no database. All data comes from a separate internal API
(ops-core-api) over HTTP; this repo is a pure
MCP + orchestration layer.
A companion project. The MCP-specific architecture notes are in
docs/architecture.md.
What it does
Four MCP tools, each a thin wrapper over a service that calls ops-core-api through an
interface:
Tool | Does |
| Is a slot free? Returns match/availability/capacity and other free times that day. |
| Finds customers by name fragment or exact UUID. |
| Prices a service with volume discounts (money stays exact). |
| Simulated — logs the send and returns a receipt; nothing is actually delivered. |
Two consumers, one server
flowchart LR
CD["Claude Desktop /<br/>MCP Inspector"] -- "MCP over<br/>Streamable HTTP" --> MCP
BR["Browser<br/>(portfolio UI)"] -- "SSE" --> API["POST /api/v1/invoke"]
API --> ORC["Orchestrator<br/>(LLM + MCP client)"]
ORC -- "MCP over<br/>Streamable HTTP (self-loopback)" --> MCP["FastMCP server<br/>/mcp"]
MCP --> TOOLS["mcp/v1/tools"]
TOOLS --> SVC["services"]
SVC --> GW["ops-core gateways"]
GW -- "X-API-Key" --> CORE["ops-core-api"]External MCP client (Claude Desktop, MCP Inspector, …) connects straight to
/mcpover Streamable HTTP and drives the tools by hand.Internal orchestrator is itself an MCP client to the same
/mcp: an LLM loop that lists the tools, calls them over the protocol, and streams each step.POST /api/v1/invokeis a thin HTTP route over the orchestrator. It streams the agent's steps to the browser as Server-Sent Events — you watch the agent decide, call a tool, read the result, and answer — without ever putting the LLM key or an MCP client in the browser.
Because the orchestrator connects to the server's own mounted /mcp over real HTTP,
there is no code path that invokes a tool outside the MCP protocol.
Architecture
Layered, with a strict inward dependency rule (outer depends on inner, never the
reverse) — the shared portfolio architecture, plus a versioned mcp/v1/ layer that
mirrors api/v1/:
mcp/v1/tools ─┐ api/v1 (HTTP: SSE route, health)
├─► services (business logic) ─► interfaces (ABC) ◄─ gateways / llm
orchestrator ──┘ ▲
contracts (DTOs) ──────────┘ core · exceptions · bootstrapgateways/ rather than repositories/: this service owns no store, so every adapter is
somebody else's service over the network.
Design patterns (named and commented in the code):
Factory —
llm/factory.py,gateways/ops_core/client.py(the only places the SDK / HTTP clients are built).Adapter — the httpx
gateways/ops_core/*adapters andgateways/agent/mcp_tool_gateway.py(an MCP client behind a plain interface).Strategy —
NotificationChannel(simulated now, real email/SMS later, no service change).Template Method — the orchestrator's fixed
run()loop skeleton.Dependency Inversion throughout — services depend on interfaces; the DI container in
bootstrap/container.pywires concretes.
Run locally
Prerequisites: Docker, and a running
ops-core-api with its demo data loaded; plus
an OpenAI API key (or any OpenAI-compatible, tool-calling endpoint).
cp .env.example .env
# edit .env: set LLM_API_KEY, and OPS_CORE_API_KEY / OPS_CORE_BASE_URL to match ops-core-apiDocker — this agent has no database of its own, so compose runs just the service, and
reaches ops-core-api through host.docker.internal. There is no shared docker network,
so both compose files stay independent:
docker compose up --build # agent on http://127.0.0.1:8003The port is published on loopback only (127.0.0.1:8003:8000) on purpose — see
Exposing this service. Inside the container it still listens on
8000, which is why AGENT_MCP_SELF_URL stays http://localhost:8000/mcp. With
ops-core-api running on the host, set
OPS_CORE_BASE_URL=http://host.docker.internal:8000.
Without Docker — pick 8003 so ops-core-api keeps 8000, and move the self-loopback to
the same port or the orchestrator will call nothing:
uv sync
AGENT_MCP_SELF_URL=http://localhost:8003/mcp \
uv run uvicorn src.main:app --reload --port 8003Try it
Composite request over SSE (-N disables curl buffering so you see events arrive):
curl -N -X POST http://localhost:8003/api/v1/invoke \
-H "Content-Type: application/json" \
-d '{"message": "Is the 18:00 table free tomorrow, and can you look up Anna Petrova?"}'event: tool_call
data: {"id":"call_1","name":"check_calendar_availability","arguments":{"date":"2026-07-26","time":"18:00","resource_type":"table"}}
event: tool_result
data: {"id":"call_1","name":"check_calendar_availability","content":"{\"available\": true, \"capacity\": 4, ...}","is_error":false}
event: tool_call
data: {"id":"call_2","name":"lookup_customer","arguments":{"name_or_id":"Anna Petrova"}}
event: tool_result
data: {"id":"call_2","name":"lookup_customer","content":"{\"count\": 1, ...}","is_error":false}
event: final
data: {"content":"The 18:00 table is free tomorrow (seats 4), and I found Anna Petrova (active)."}Health — liveness is the process alone; readiness reports both upstreams:
curl http://localhost:8003/health/live # {"status":"ok"}
curl http://localhost:8003/health/ready # {"status":"ok","llm":true,"ops_core":true}; 503 if either is downDocker's HEALTHCHECK polls /health/live, so the container is not marked unhealthy
because the LLM provider is having a bad morning.
Optional auth — leave it open for the demo, or set SECURITY_API_KEY (≥16 chars) to
require X-API-Key on POST /api/v1/invoke:
curl -N -X POST http://localhost:8003/api/v1/invoke \
-H "X-API-Key: $SECURITY_API_KEY" -H "Content-Type: application/json" \
-d '{"message":"..."}'Connect an external MCP client to /mcp
Run the service locally and point your MCP client at your own localhost — that is the intended shape, not a hosted URL.
MCP Inspector (quickest way to see tools and call them live):
npx @modelcontextprotocol/inspector
# In the UI: Transport = "Streamable HTTP", URL = http://localhost:8003/mcp → ConnectClaude Desktop — bridge stdio to your local endpoint via mcp-remote in
claude_desktop_config.json:
{
"mcpServers": {
"ops-agent": {
"command": "npx",
"args": ["mcp-remote", "http://localhost:8003/mcp"]
}
}
}Restart Claude Desktop; the four tools appear and execute against the very same server the internal orchestrator uses.
Exposing this service
/mcp is unauthenticated: MCP clients rely on the protocol's own auth story, and the
self-loopback needs no key. That is fine on localhost and not fine on the open
internet, so this is a deployment decision rather than a code one:
/mcpis closed at the perimeter. OnlyPOST /api/v1/invoke, with its ownSECURITY_API_KEY, faces outward.Set
SECURITY_API_KEYbefore exposing anything publicly. The per-IP rate limit (INVOKE_RATE_LIMIT_PER_MINUTE, 20 by default) protects against abuse, not against cost: 20 requests a minute from one address × up toAGENT_MAX_STEPSLLM calls each is real money.Compose publishes on
127.0.0.1:8003rather than0.0.0.0:8003so none of the above can happen by accident on a machine with a public IP.
Tests
uv run ruff check .
uv run mypy src
uv run pytest --cov=src/app/services --cov-report=term-missing --cov-fail-under=60The suite includes unit tests for every service (mocked interfaces), Pydantic
tool-argument validation, the orchestrator loop (scripted LLM + fake gateway), an
in-memory MCP round-trip, and — the headline — a hermetic real-loopback test
(tests/integration/test_self_loopback.py) that boots the whole app on a loopback port
and asserts a compound request streams two sequential tool_call events then a final
answer, with the orchestrator self-calling the mounted /mcp over genuine Streamable
HTTP. No live LLM or ops-core-api is required — both are mocked.
Configuration
All via environment (see .env.example); grouped by prefix:
Prefix | Concern |
| Log level and format (json/text). |
| Provider, model, key, base URL — OpenAI-compatible. |
|
|
|
|
| Optional |
|
|
Three things worth knowing before the first run:
The default provider is OpenAI, deliberately — unlike the sibling services, which default to a local Ollama model. An agentic tool-calling loop is unreliable on a small local model, and reliable tool calling is precisely what this service exists to demonstrate. Point
LLM_BASE_URLat Ollama if you would rather trade that for free.CORS_ALLOWED_ORIGINSis empty by default, which blocks every browser client. A frontend needs its origin listed there before it can call in.The
OPS_CORE_API_KEYplaceholderchange-me-min-16-charsis shared by all five services in the portfolio and is rotated in all five at once, so a plaincp .env.example .envstill gives a working demo.
MCP Ops Agent (RU)
Настоящий MCP-сервер (Model Context Protocol, на FastMCP из официального MCP Python SDK) для операционного пульта и внутренний агент-оркестратор, который обращается к тем же инструментам так же, как любой внешний клиент — через настоящий MCP JSON-RPC. Инструменты определены ровно один раз, и каждый потребитель идёт через протокол; ничто не вызывает бизнес-логику в обход.
У сервиса нет собственной базы данных. Все данные приходят из отдельного внутреннего API
(ops-core-api) по HTTP; этот репозиторий —
чистый слой MCP и оркестрации.
MCP-специфика архитектуры — в
docs/architecture.md.
Что делает
Четыре MCP-инструмента, каждый — тонкая обёртка над сервисом, который обращается к
ops-core-api через интерфейс:
Инструмент | Что делает |
| Свободен ли слот? Возвращает совпадение/доступность/вместимость и другие свободные времена в этот день. |
| Ищет клиентов по фрагменту имени или точному UUID. |
| Считает стоимость услуги с объёмными скидками (деньги — точно, без float). |
| Симуляция — логирует отправку и возвращает квитанцию; на самом деле ничего не отправляется. |
Два потребителя, один сервер
Внешний MCP-клиент (Claude Desktop, MCP Inspector) подключается прямо к
/mcpпо Streamable HTTP и вызывает инструменты вручную.Внутренний оркестратор сам является MCP-клиентом к тому же
/mcp: LLM-цикл, который получает список инструментов, вызывает их через протокол и стримит каждый шаг.POST /api/v1/invoke— тонкий HTTP-роутер поверх оркестратора. Стримит шаги агента в браузер как Server-Sent Events: видно, как агент решает, вызывает инструмент, читает результат и отвечает — без переноса LLM-ключа или MCP-клиента в браузер.
Поскольку оркестратор подключается к собственному смонтированному /mcp по настоящему
HTTP, не существует пути, которым инструмент вызывался бы в обход MCP-протокола.
Архитектура
Слоистая, со строгим правилом однонаправленных зависимостей (внешние слои зависят от
внутренних, никогда наоборот) — общая архитектура портфолио плюс версионированный слой
mcp/v1/, зеркалящий api/v1/. Адаптеры лежат в gateways/, а не в repositories/:
своего хранилища у сервиса нет, всё внешнее и по сети.
Паттерны (названы и прокомментированы в коде): Factory (llm/factory.py,
gateways/ops_core/client.py), Adapter (httpx-адаптеры и mcp_tool_gateway.py),
Strategy (NotificationChannel), Template Method (цикл run() оркестратора),
Dependency Inversion — везде.
Запуск локально
Нужны: Docker и запущенный ops-core-api
с загруженными демо-данными; ключ OpenAI (или любой OpenAI-совместимый endpoint
с поддержкой tool-calling).
cp .env.example .env
# в .env задайте LLM_API_KEY, а также OPS_CORE_API_KEY / OPS_CORE_BASE_URL под ops-core-api
docker compose up --build # агент на http://127.0.0.1:8003Порт публикуется только на loopback (127.0.0.1:8003:8000) — см. «Публикация наружу».
Внутри контейнера сервис слушает 8000, поэтому AGENT_MCP_SELF_URL остаётся
http://localhost:8000/mcp. Если ops-core-api поднят на хосте, задайте
OPS_CORE_BASE_URL=http://host.docker.internal:8000.
Без Docker — берите 8003, чтобы 8000 остался за ops-core-api, и переведите
self-loopback на тот же порт:
uv sync
AGENT_MCP_SELF_URL=http://localhost:8003/mcp \
uv run uvicorn src.main:app --reload --port 8003Примеры
Составной запрос через SSE:
curl -N -X POST http://localhost:8003/api/v1/invoke \
-H "Content-Type: application/json" \
-d '{"message": "Свободен ли завтра столик на 18:00 и найди Анну Петрову?"}'Ответ — поток событий tool_call → tool_result (по одному на каждый инструмент) и
финальное final со связным ответом (см. английский пример выше).
Health: /health/live — только процесс (это и опрашивает Docker), /health/ready —
оба upstream'а, 503 если хоть один недоступен.
Подключение внешнего MCP-клиента к /mcp
Поднимите сервис локально и подключайте клиент к своему localhost. Быстрее всего —
MCP Inspector (npx @modelcontextprotocol/inspector, транспорт «Streamable HTTP», URL
http://localhost:8003/mcp). Для Claude Desktop используйте мост mcp-remote
в claude_desktop_config.json (см. английскую версию).
Публикация наружу
/mcp не защищён ключом: MCP-клиенты полагаются на собственную авторизацию протокола,
а self-loopback ключа не требует. На localhost это нормально, в интернете — нет, и это
вопрос деплоя, а не кода:
/mcpзакрывается на периметре. Наружу смотрит толькоPOST /api/v1/invokeсо своимSECURITY_API_KEY.Перед публикацией наружу задайте
SECURITY_API_KEY. Рейт-лимит (INVOKE_RATE_LIMIT_PER_MINUTE, по умолчанию 20) защищает от абьюза, но не от расхода: 20 запросов в минуту с одного IP × доAGENT_MAX_STEPSвызовов LLM — это реальные деньги.Compose биндит порт на
127.0.0.1, чтобы ничего из этого не случилось случайно на машине с публичным IP.
Тесты
uv run ruff check .
uv run mypy src
uv run pytest --cov=src/app/services --cov-report=term-missing --cov-fail-under=60Ключевой тест — герметичный self-loopback (tests/integration/test_self_loopback.py):
поднимает всё приложение на loopback-порту и проверяет, что составной запрос стримит два
последовательных события tool_call и финальный ответ, причём оркестратор реально
дергает смонтированный /mcp по Streamable HTTP. Живые LLM и ops-core-api не нужны —
оба замоканы.
Конфигурация
Дефолтный провайдер — OpenAI, и это осознанно: соседние сервисы по умолчанию ходят в
локальную Ollama, но агентский цикл с tool-calling на маленькой локальной модели работает
ненадёжно, а именно надёжность вызова инструментов этот сервис и демонстрирует.
CORS_ALLOWED_ORIGINS по умолчанию пуст — браузерному клиенту нужно прописать свой
origin. Плейсхолдер change-me-min-16-chars в OPS_CORE_API_KEY общий для всех пяти
сервисов портфолио и меняется во всех пяти сразу.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/upkero/mcp-ops-agent'
If you have feedback or need assistance with the MCP directory API, please join our Discord server