OpsPilot MCP Server
by fferjani94
README.md
# OpsPilot — Enterprise AI Workflow Agent
Fictional SaaS customer support investigation agent, using FastAPI, SQLite/SQLAlchemy,
Alembic, MCP, LangGraph, optional local Ollama inference, and a dependency-free web
dashboard.
**Status:** Milestone 5 complete (version 0.5.0), plus the Milestone 6A offline evaluation
benchmark ([Offline evaluation](#offline-evaluation-milestone-6a)). The dashboard,
investigation API, CLI and MCP tools work end to end **on a local machine** with a local
Ollama model and the seeded SQLite database. The project is **not production-ready**: see
[Production deployment limitations](#production-deployment-limitations).
## Run locally on Windows (PowerShell)
From the repository root:
```powershell
.venv\Scripts\python.exe -m pip install --no-cache-dir -e ".[dev,agent]"
.venv\Scripts\python.exe -m alembic upgrade head
.venv\Scripts\python.exe -m enterprise_ai_workflow_agent.db.seed
.venv\Scripts\python.exe -m pytest -q
.venv\Scripts\python.exe -m ruff check .
.venv\Scripts\python.exe -m alembic check
.venv\Scripts\python.exe scripts\smoke_mcp.py
```
Install Ollama separately from https://ollama.com/download and pull a model:
```powershell
ollama pull qwen2.5:3b
ollama list
```
Start the Ollama service if it is not already running (`ollama serve` in a separate terminal).
### Dashboard
Start the server **from the repository root** (the default `DATABASE_URL`,
`sqlite:///./opspilot.db`, is relative to the working directory, and the MCP tool server
inherits it):
```powershell
.venv\Scripts\python.exe -m uvicorn enterprise_ai_workflow_agent.main:app --host 127.0.0.1 --port 8000
```
Open http://127.0.0.1:8000/. Pick an example or type a question, choose the number of tool
rounds and select **Investigate** (or press Ctrl+Enter). The result shows:
- **Answer**: a short narrative and where it came from (model, tool-less synthesis, or a
deterministic evidence summary), with a warning banner for limited investigations.
- **What the evidence supports**: rule-generated sections (relevant tickets, relevant
documents, other records, unknowns). Record lists and counts come only from here.
- **Retrieved records**: every record returned by the tools, with its relevance label.
- **Tool execution timeline**: each call with its arguments, adjustments, errors, and
calls added by the orchestrator.
**Stop waiting** cancels the request. The server notices the closed connection within about
a second and cancels the investigation, which shuts down its MCP subprocess and frees the
concurrency slot. API docs are at http://127.0.0.1:8000/docs.
### Investigation API
`POST /api/v1/investigations` runs one investigation synchronously and returns the answer,
evidence, analysis sections and tool trace:
```powershell
$body = '{"question": "Investigate SSO issues for Northstar Labs. Check tickets and documentation.", "max_rounds": 4}'
Invoke-RestMethod http://127.0.0.1:8000/api/v1/investigations -Method Post -ContentType application/json -Body $body
```
- Request: `question` (1-4000 characters, required), optional `model` (an installed Ollama
tag) and `max_rounds` (1-8). Unknown fields are rejected.
- `200`: `status` is `completed` or `limited` (with `limited_reason`). Other fields:
`answer`, `narrative`, `answer_source`, `answer_notice`, `round_limit_reached`,
`tool_rounds`, `tool_calls`, `duration_seconds`, `tool_trace`, `evidence`, `analysis`.
- Errors use one shape, `{"id", "question", "status": "failed", "error": {"code",
"message", "details"}, "duration_seconds"}`: `422 invalid_request`, `404
model_unavailable`, `503 ollama_unavailable` / `ollama_integration_missing` /
`mcp_unavailable` / `model_inference_failed`, `504 timeout`, `500 internal_error`.
Messages never include configuration values, paths or exception details.
- `GET /api/v1/investigations/config` returns the default model, round limits, question
length limit and timeout (no URLs or credentials).
Runtime settings (environment variables or `.env`, see `.env.example`): `OLLAMA_BASE_URL`,
`OLLAMA_MODEL`, `INVESTIGATION_TIMEOUT_SECONDS` (default 300, covers queueing too),
`INVESTIGATION_MAX_ROUNDS` (default 4), `INVESTIGATION_MAX_CONCURRENCY` (default 1;
further requests queue).
### CLI
```powershell
.venv\Scripts\python.exe -m enterprise_ai_workflow_agent.agent.cli "Investigate SSO issues for Northstar Labs. Check support tickets and documentation. Cite the evidence." --model qwen2.5:3b
```
`qwen2.5:3b` is a small starting model; tool-calling accuracy and response quality vary.
If resources allow, try a larger tool-capable model. Ollama model downloads can take
multiple GB of disk space. Confirm free space before pulling weights.
## Offline evaluation (Milestone 6A)
```powershell
.venv\Scripts\python.exe -m enterprise_ai_workflow_agent.evaluation # all cases
.venv\Scripts\python.exe -m enterprise_ai_workflow_agent.evaluation --case U3 # one case
```
Runs the versioned benchmark [`evals/benchmark_v1.json`](evals/benchmark_v1.json) (25 cases:
6 successful investigations, 4 missing accounts, 5 incomplete evidence, 7 unsafe
conclusions, 3 tool-discipline cases) and writes `evals/results/benchmark_v1-offline.{json,md}`
(git-ignored). Runs filtered with `--case` write `...-offline-subset-<IDs>.{json,md}` and
never overwrite the full-suite report. Reports record the benchmark's content SHA-256 and
which cases ran. The exit code is 1 if any case fails; failures print the case ID,
expected vs observed values and the tool trace. A case that raises is reported as a
failure (`run_completed`) and the remaining cases still run. The same cases run in
`pytest` (`tests/test_evaluation.py`), and every case must pass.
Each case runs the real LangGraph agent against the **real MCP server in memory** on its
own SQLite fixture with explicit record IDs (`evaluation/harness.py`; production seeding is
not used). The model is **scripted**: tool-loop and synthesis replies come from the case.
No Ollama, network or subprocess is needed, and reports contain no timings, so repeated
runs are identical. A script that runs out of replies, or has replies left over, fails the
case, so a case cannot pass on a graph path that never requested its scripted text.
The benchmark covers pre-resolved and model-first (`pre_resolve: false`) runs, multi-page
ticket retrieval, rejected guessed IDs and skipped duplicate calls, failed lookups, the
round limit, tool-loop and synthesis failures, and seven unsupported-conclusion guards.
`tests/test_evaluation.py` disables each of those guards in turn (mutation tests) and
checks that the corresponding case fails, and checks that replacing a synthesis with
generic text fails every case whose answer must come from synthesis.
Metrics, per case and aggregated per category (`evaluation/metrics.py`):
| Dimension | Checks and metrics |
|---|---|
| Answer correctness | required facts present (fact recall), forbidden claims absent, expected answer source; share of unsafe cases blocked. Facts are scoped to the **narrative** (model-written text, or the deterministic notice when the answer was withheld) or to the whole **answer** including the rule-generated record sections |
| Evidence grounding | **citation validity**, scored separately for model-written text and for the whole answer: every cited ID was retrieved with the same record type (`Ticket 101`, `Tickets 101 and 103`, `Ticket IDs: 101, 103`, or `#101` when exactly one retrieved record has that ID); **evidence retrieval** (required records retrieved, forbidden ones not); unsupported-claim count in model-written text |
| Task completion | `completed`/`limited`/`failed` status, limitation reason, per-account ticket coverage |
| Tool efficiency | required/forbidden tools, executed-call and round budgets, failed and redundant (identical) calls, rejected proposals by tool and reason, minimum orchestrator follow-ups |
Expectations accept equivalent wording (each fact lists alternative regexes) and use upper
bounds rather than exact call sequences. Limitations:
- Scripted replies test orchestration, retrieval and answer guardrails, **not model
quality**. Live-model evaluation (repeated runs against Ollama) is future work.
- All checks are lexical. They detect the specific claims each case names; they are **not
general hallucination detection**. A wrong statement that no pattern covers goes
unnoticed, and a narrative fact only shows that the expected wording is present, not
that the rest of the narrative is true.
- Citation parsing is heuristic: a typeless `#N` that matches records of several types is
reported as ambiguous and not scored; IDs written without `#` or a record type ("see
101") are not recognised; a number listed after an ID ("Ticket 101, 2 users") is read as
a second ID.
- The unsupported-claim metric reuses the agent's own grounding detectors, so it guards
against regressions but is not an independent judge.
- Runs patch module-level MCP server state (restored on exit, including after exceptions)
and must not run concurrently.
Add a case by appending it to `evals/benchmark_v1.json` (the schema is in
`evaluation/dataset.py`). Use `--verbose` to see agent logs. Changing the meaning of
existing cases calls for a new benchmark version rather than an edit.
## Architecture
Browser dashboard (or CLI) -> FastAPI `POST /api/v1/investigations` ->
`InvestigationService` (concurrency limit, timeout, cancellation; one worker thread and
event loop per run) -> LangGraph StateGraph with an Ollama chat model -> MCP stdio client ->
MCP server subprocess (one per investigation) with five read-only tools -> SQLAlchemy ->
SQLite. The result passes deterministic grounding checks before it is returned. The data
endpoints (`/api/v1/accounts`, `/api/v1/tickets`, ...) read the database directly.
Tool calls are limited to 4 rounds by default, with at most 5 tool calls per round;
tool output is truncated to 12,000 characters per call. Unknown tool names are
rejected. Errors become tool messages for the agent to interpret. The final response
includes a tool-call trace. There is no persistent memory across runs.
Each investigation's MCP subprocess is closed when the run ends, fails, times out or is
cancelled (on Windows through a job object, so the venv launcher's child process exits
too). Uvicorn does not cancel a request handler when the client disconnects, so the
endpoint polls for a closed connection and cancels the run itself.
### Reliability safeguards for small models
- **Bounded arguments.** Tool schemas publish `minimum`/`maximum` for `limit` (1-20).
Out-of-range numbers are clamped to the schema bounds before the MCP call; the trace
keeps the original and sent arguments plus an `adjustments` note. Record IDs are never
changed. The MCP server still validates every call.
- **Clean tool errors.** Validation errors reach the model as short, actionable text
(for example `Invalid arguments. limit: Input should be less than or equal to 20`).
Unexpected server exceptions are logged and returned as a generic message.
- **Intent-aware follow-ups.** When the model stops, deterministic rules (no extra LLM
call) check whether evidence the question explicitly asks for is missing:
support-issue terms (ticket, incident, error, failure, ...) require tickets for the
verified account the question names; guidance terms (troubleshoot, documentation,
how to, next steps, ...) require a knowledge search. Missing lookups run through the
same validated tool path, within `max_rounds`, and appear in the trace with
`origin: "orchestrator"` and a `reason`. Dependent lookups chain (an account search,
then its tickets) while rounds remain. Account-only questions get no ticket lookups, and
several matching accounts are never guessed between.
- **Account discovery.** If a ticket or account question ends with no account lookup at
all (in testing, `qwen2.5:3b` sometimes returns an empty first reply), and exactly one
account name can be read from the question (a run of capitalised words that are not
question words or acronyms such as SSO), `search_accounts` is called with that text.
Account IDs still come only from the MCP result. Several candidate names, or none, make
the investigation `limited` with an explanation; nothing is guessed. If the model's own
account search matched nothing but did not search a name from the question on its own
(for example both names in one query), each such name is searched separately; an empty
result is a negative finding only for names that were actually searched.
- **Round budget.** The last tool round goes to required lookups first; the model's own
calls in that round run alongside them within the per-round call limit. `max_rounds` is
never raised.
- **Repeated lookups.** An identical call that already succeeded is not re-executed. If a
model turn only repeats earlier lookups while required evidence is missing, the
orchestrator uses that round for the missing lookup instead.
- **Transient model failures.** Ollama "token repeat" errors, empty streams and empty
replies (no text and no tool call) are retried within a fixed budget of 4 attempts.
Persistent empty replies are treated as the model stopping, not as a failure.
- **Answer = model narrative + deterministic sections.** Record lists and counts never
come from the model. Every answer ends with sections rendered from the labelled records:
*Relevant support tickets*, *Relevant knowledge documents* (or an explicit statement that
documentation-backed guidance is unavailable), *Other retrieved records* and *Unknown or
unconfirmed*. The dashboard shows the same sections. The model writes only a short
explanatory narrative.
- **Final synthesis.** Evidence collection and narrative writing are separate. Once the
required evidence is in hand and the model stops (or the orchestrator completed the
evidence, or the tool-round limit is reached), a single synthesis call, made with the
model *without tools*, writes the narrative. It sees only the question and the verified
records, grouped by relevance with their IDs. It also replaces a tool-loop answer that
fails the grounding checks. Synthesis runs at most once (transient errors are retried
within the attempt budget), stays inside the investigation timeout, and is skipped when
required evidence is missing.
- **Grounding for every narrative.** The tool-loop answer, a synthesis, and a synthesis
after the round limit all pass the same checks: cited IDs must exist *with the same
record type*; records without a topic match must not be presented as related; record
counts must match the records (a count of "related" tickets must equal the relevant
ones); troubleshooting instructions must cite a relevant retrieved document, otherwise
they are removed with a visible note; causal claims get an "unconfirmed" note. A
narrative that fails is replaced by a deterministic one (`answer_source:
"evidence_summary"` with an `answer_notice`), never an invented conclusion.
- **Fix claims.** No record confirms that a step resolves a customer's problem, so
present- or future-tense claims that one does ("checking the settings resolves the
issue", "this will fix the login failures") are softened to "may help resolve/fix", with
a visible note. Ticket status wording ("Ticket 2 was resolved", "is resolved"), hedged,
negated and quoted text are left unchanged. The synthesis prompt also asks the model not
to make such claims.
- **Status.** `completed`: the required evidence was retrieved and a grounded answer
was returned. `limited`: required evidence is still missing (lookup failed, ambiguous or
unidentifiable account, or the round budget ran out first), explained in
`limited_reason`. Reaching the tool-round limit alone is reported as
`round_limit_reached`, not as `limited`. Retrieving records that do not match the
question, or finding no matching account, is a complete, negative result (the answer
states which search matched nothing). Infrastructure failures in the tool loop are
`failed` errors.
- **Relevance.** Records are labelled by lexical overlap with the question's topic, plus
a few explainable groups of related terms (for example *sso*, *saml* and *login* for
sign-in problems). Generic symptom words (failure, error, ...) alone do not make a record
relevant when the question names a topic. Each relevant record carries a
`relevance_basis`. A related topic does not establish a cause.
## Production deployment limitations
The application is built and tested for local, single-user use. Before any shared or
production deployment it would still need:
- **Security:** no authentication, authorization, tenant isolation or rate limiting. Bind
the server to `127.0.0.1` (as above) and do not expose it publicly.
- **Data:** synthetic seed data only; tools are read-only and there is no approval system.
- **Scaling:** the concurrency limit and request queue live in one server process; several
Uvicorn workers would each apply their own limit.
- **Operations:** no observability backend (the trace is per run), and the application's
INFO logs are not shown under Uvicorn's default logging configuration.
- **Testing:** CI uses fake LLM/MCP clients (no Ollama or model download). Real-model and
browser checks are run manually.
### Docker (current limitations)
The `Dockerfile` builds and starts the API and dashboard, and CI builds the image, but the
image is not yet a working investigation environment (planned for Milestone 8):
- It installs the package without the `agent` extra, so investigations return `503
ollama_integration_missing`.
- `OLLAMA_BASE_URL` defaults to `127.0.0.1`, which inside a container is the container
itself. Ollama on the host needs `host.docker.internal` or a shared network.
- The image contains neither `migrations/`/`alembic.ini` nor a database, and nothing creates
tables at startup, so the data endpoints and tools fail unless a prepared SQLite file is
mounted and `DATABASE_URL` points to it.
- There is no compose file, health check or Ollama service definition.
## Answer accuracy limitations
- Evidence grounding is rule-based and prompted, **not formally verified**. Check
citations against the tool output. There is no guarantee of factual correctness or of
immunity to prompt injection.
- Knowledge search uses lexical token overlap, not embeddings/vector retrieval.
- The guards check record IDs and types, relevance claims, record counts, uncited
troubleshooting instructions and causal wording. They do not check the model's
interpretation: a narrative may still overstate (for example, calling a resolved
ticket "the solution"). The deterministic sections below the narrative are the
authoritative record list.
- Troubleshooting advice is detected by phrasing (imperatives, "you should", "next
steps"...). Softer wording such as "it is important to address this" is not removed.
- Fix claims are detected by phrasing too: "X resolves/fixes/will fix ..." is softened, but
paraphrases such as "is the solution" or "addresses the root problem", and past-tense
claims ("this resolved the issue"), are not changed.
- Account discovery reads capitalised names. Lowercase names, or a single-word name at
the start of a sentence, are not recovered. A capitalised product name next to an
account name makes the question ambiguous, and the run ends `limited` instead.
- Relevance is lexical, with a few hand-written groups of related terms; it is not
semantic search.
- `qwen2.5:3b` often spends tool rounds on invented IDs (blocked) or repeated calls. Real
investigations took about 12-160 s on a laptop (most 30-90 s); a question with no
identifiable account can use the whole round budget before it ends `limited`.
- Reliability figures come from small manual batches (8 real runs each), not a benchmark.
## Existing MCP server
```powershell
.venv\Scripts\python.exe scripts\smoke_mcp.py
```
The server supports `search_accounts`, `get_account_tickets`, `get_ticket_details`,
`get_product_information`, and `search_knowledge`.