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.

Related MCP server: carly-cli

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 общий для всех пяти сервисов портфолио и меняется во всех пяти сразу.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

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