Skip to main content
Glama

MCP Ops Agent

CI License: MIT Python

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

check_calendar_availability(date, time, resource_type)

Is a slot free? Returns match/availability/capacity and other free times that day.

lookup_customer(name_or_id)

Finds customers by name fragment or exact UUID.

calculate_quote(service, quantity)

Prices a service with volume discounts (money stays exact).

send_notification(recipient, message)

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"]
  1. External MCP client (Claude Desktop, MCP Inspector, …) connects straight to /mcp over Streamable HTTP and drives the tools by hand.

  2. 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.

  3. POST /api/v1/invoke is 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 · bootstrap

gateways/ 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):

  • Factoryllm/factory.py, gateways/ops_core/client.py (the only places the SDK / HTTP clients are built).

  • Adapter — the httpx gateways/ops_core/* adapters and gateways/agent/mcp_tool_gateway.py (an MCP client behind a plain interface).

  • StrategyNotificationChannel (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.py wires 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-api

Docker — 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:8003

The 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 8003

Try 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 down

Docker'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 → Connect

Claude 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:

  • /mcp is closed at the perimeter. Only POST /api/v1/invoke, with its own SECURITY_API_KEY, faces outward.

  • Set SECURITY_API_KEY before 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 to AGENT_MAX_STEPS LLM calls each is real money.

  • Compose publishes on 127.0.0.1:8003 rather than 0.0.0.0:8003 so 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=60

The 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_

Log level and format (json/text).

LLM_

Provider, model, key, base URL — OpenAI-compatible.

OPS_CORE_

ops-core-api base URL, X-API-Key, timeout, total attempts.

AGENT_

MAX_STEPS, and MCP_SELF_URL (the orchestrator's loopback to /mcp).

SECURITY_

Optional API_KEY gating POST /api/v1/invoke (unset = open).

CORS_

ALLOWED_ORIGINS, comma-separated.

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_URL at Ollama if you would rather trade that for free.

  • CORS_ALLOWED_ORIGINS is empty by default, which blocks every browser client. A frontend needs its origin listed there before it can call in.

  • The OPS_CORE_API_KEY placeholder change-me-min-16-chars is shared by all five services in the portfolio and is rotated in all five at once, so a plain cp .env.example .env still 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 через интерфейс:

Инструмент

Что делает

check_calendar_availability(date, time, resource_type)

Свободен ли слот? Возвращает совпадение/доступность/вместимость и другие свободные времена в этот день.

lookup_customer(name_or_id)

Ищет клиентов по фрагменту имени или точному UUID.

calculate_quote(service, quantity)

Считает стоимость услуги с объёмными скидками (деньги — точно, без float).

send_notification(recipient, message)

Симуляция — логирует отправку и возвращает квитанцию; на самом деле ничего не отправляется.

Два потребителя, один сервер

  1. Внешний MCP-клиент (Claude Desktop, MCP Inspector) подключается прямо к /mcp по Streamable HTTP и вызывает инструменты вручную.

  2. Внутренний оркестратор сам является MCP-клиентом к тому же /mcp: LLM-цикл, который получает список инструментов, вызывает их через протокол и стримит каждый шаг.

  3. 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_calltool_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

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