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 live demo: every tool call and result arrives as its own SSE event.

The live demo: every tool call and result arrives as its own SSE event.

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

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

list_services()

Lists every service on the price list with its unit price.

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. The recipient must be one existing customer (id or full name, checked against ops-core-api), the message is at most 1000 characters, and a run may send at most AGENT_MAX_NOTIFICATIONS_PER_RUN (3).

Related MCP server: dat-ai-mcp

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

  • 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 and gateways/agent/mcp_tool_gateway.py (an MCP client behind a plain interface).

  • Strategy — NotificationChannel (simulated now; a real email/SMS channel is a new implementation, but read the notification limits under Exposing this service first).

  • 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"}; 503 {"detail":"dependencies unavailable: llm",...} if a dependency is down

Docker's HEALTHCHECK polls /health/live, so the container is not marked unhealthy because the LLM provider is having a bad morning.

Auth — POST /api/v1/invoke requires SECURITY_API_KEY (≥16 chars) as X-API-Key. The service does not start without it; .env.example ships the portfolio's shared placeholder change-me-min-16-chars:

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

  • Replace the placeholder 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.

  • Whoever holds SECURITY_API_KEY reaches everything the tools return through POST /api/v1/invoke: customer records including their notes. The public demo runs on synthetic data; with real data, treat the key accordingly.

  • /mcp is protected only by the SDK's DNS-rebinding check: a Host that is not localhost/127.0.0.1 gets 421, a foreign Origin gets 403. Behind a reverse proxy that forwards a public Host, /mcp therefore answers 421; keep it internal.

  • What bounds one run: AGENT_MAX_STEPS, AGENT_RUN_TIMEOUT_SECONDS (60), at most AGENT_MAX_TOOL_CALLS_PER_STEP (4) tool calls per LLM turn, AGENT_MAX_NOTIFICATIONS_PER_RUN (3) and LLM_MAX_TOKENS (1024) per turn. The tokens a run used are logged as agent.usage.

  • One rate-limit bucket per source address. Behind a proxy or a site bridge every visitor arrives from the same address and shares the 20 a minute; limit per visitor there.

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, RUN_TIMEOUT_SECONDS, MAX_TOOL_CALLS_PER_STEP, MAX_NOTIFICATIONS_PER_RUN, and MCP_SELF_URL (the orchestrator's loopback to /mcp/).

SECURITY_

API_KEY, required, gating POST /api/v1/invoke.

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 it matches every sibling's .env.example. You still set LLM_API_KEY yourself.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Provides AI agents with operational customer context, including typed revenue objects, persistent state, scoped tools, and human-in-the-loop handoffs through MCP, REST, and CLI.
    2 npm
    12
    Apache 2.0
  • F
    license
    A
    quality
    C
    maintenance
    Provides reusable MCP tools for Amazon Connect AI Agents and Bedrock AgentCore to look up customers, manage support tickets, schedule appointments, update CRM records, retrieve knowledge, and create tasks.
    7
    -