Skip to main content
Glama
eddii1

Payment Delay MCP

by eddii1
README.md
# Payment Delay MCP - Serving a Production ML Model to Any LLM Over MCP

**A scikit-learn classifier deployed behind a FastAPI microservice and published to language models as [Model Context Protocol](https://modelcontextprotocol.io) tools - so an off-the-shelf chat client discovers and invokes the model correctly with zero integration code written against it.**

<p>
  <img alt="Python" src="https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white">
  <img alt="FastAPI" src="https://img.shields.io/badge/FastAPI-0.141-009688?logo=fastapi&logoColor=white">
  <img alt="MCP" src="https://img.shields.io/badge/MCP-FastMCP%201.29-6E56CF">
  <img alt="scikit-learn" src="https://img.shields.io/badge/scikit--learn-1.6.1-F7931E?logo=scikitlearn&logoColor=white">
  <img alt="Docker" src="https://img.shields.io/badge/Docker-4--service%20stack-2496ED?logo=docker&logoColor=white">
  <img alt="Bedrock" src="https://img.shields.io/badge/AWS-Bedrock%20via%20LiteLLM-FF9900?logo=amazonaws&logoColor=white">
</p>

![gpt-oss-120b selects the right MCP tool and calls the model API](docs/demo.gif)

> ### The thesis
> The model is the payload, not the point.
>
> Most "AI-powered" demos hardcode a model call into a bespoke application. This project inverts that: the classifier is published as a **protocol**, so the LLM client is interchangeable. The same server drives OpenWebUI in Docker, OpenCode on the CLI, and Claude Desktop - **with no code change and no client-specific adapter**.

---

## Overview

A telecom operator wants to know which customers will pay late. A trained classifier answers that, but a `.pkl` file is not a product - somebody still has to write glue code to call it, and that glue is rewritten for every new consumer.

This repository is the glue, written once as a protocol. Four layers, each independently deployable:

```mermaid
flowchart TB
    subgraph reasoning["Reasoning path"]
        UI["OpenWebUI<br/>:3000"] -->|OpenAI protocol| LL["LiteLLM<br/>:4000"]
        LL -->|bedrock_mantle| BR["AWS Bedrock<br/>gpt-oss-120b"]
    end

    subgraph tools["Tool path"]
        UI -->|OpenAPI| MCPO["mcpo<br/>:8001"]
        MCPO -->|MCP over stdio| FM["FastMCP server<br/>5 tools · 2 resources · 1 prompt"]
        FM -->|HTTP| API["FastAPI service<br/>:8000"]
        API --> PRED["inference.predictor<br/>the only code that<br/>opens the pickle"]
        PRED --> PKL[("models/*.pkl<br/>RandomForest +<br/>RandomOverSampler")]
    end

    style reasoning fill:#1f2a3710,stroke:#8884
    style tools fill:#1f372a10,stroke:#8884
```

The two paths are deliberately separate. **The LLM never executes anything.** It emits a `tool_calls` message naming a tool and its arguments; the *client* executes it and replays the result. That distinction is what makes the model swappable - and it is why this stack works identically whether the reasoning layer is Bedrock, a local Ollama, or Claude.

---

## The core idea: tool selection is a documentation problem

An LLM picks a tool from its **name, signature, and docstring** - nothing else. No fine-tuning, no examples, no routing logic. So the docstrings are the interface, and writing them is engineering work, not commentary.

Two tools here overlap heavily. Both predict payment delay. Getting the model to choose correctly, unprompted, required encoding the operational constraints directly into the description:

| Tool | When the model should pick it | The disambiguating signal |
|---|---|---|
| `predict_payment_delay` | User has a CSV, as a path or pasted text | Docstring warns that `csv_path` fails when the server runs in a container that cannot see the user's filesystem, and to prefer `csv_text` |
| `predict_single_customer` | User describes one customer in prose | Docstring says "for natural-language cases where the LLM extracts a single customer into structured features" |

**Verified result:** given a customer described in plain English, `gpt-oss-120b` selected `predict_single_customer` over `predict_payment_delay` unaided, filled the feature dictionary from the prose, and returned a grounded answer. Confirmed in the logs of both hops - `POST /predict_single_customer 200` at mcpo, then `POST /predict 200` at the model service.

That is the whole claim of the project, and it is falsifiable: disable the tool and the same model answers the same question confidently and wrongly, with both log panes empty.

---

## A request, end to end

The part most tool-use diagrams omit is that a single user question costs **two** round trips to the model, and the intermediate assistant message must be replayed verbatim or the `tool_call_id` dangles:

```mermaid
sequenceDiagram
    participant U as User
    participant W as OpenWebUI
    participant L as LiteLLM
    participant M as Bedrock model
    participant O as mcpo
    participant S as FastMCP
    participant A as FastAPI + model

    U->>W: "Will customer X pay late?"
    W->>L: messages[] + tools[]
    L->>M: translated to Bedrock
    M-->>W: finish_reason: tool_calls
    Note over W: the client executes,<br/>not the model
    W->>O: POST /predict_single_customer
    O->>S: MCP call over stdio
    S->>A: POST /predict
    A-->>S: {prediction, probability_yes}
    S-->>O: result
    O-->>W: 200 OK
    W->>L: messages[] + assistant(tool_calls) + tool(result)
    L->>M: second round trip
    M-->>U: grounded natural-language answer
```

The `tools[]` array is re-sent on **every** request - the model is stateless and rediscovers the toolset each turn.

---

## What is verified

Four checkpoints, each confirmed against logs rather than assumption:

| # | Layer | Evidence |
|---|---|---|
| 1 | Model service | `GET /health` 200; single-row `POST /predict` returns `no`, `confidence 0.985` |
| 2 | mcpo bridge | 5 tools rendered at `:8001/docs`; `predict_single_customer` executed by curl |
| 3 | LiteLLM to Bedrock | `/v1/models` lists the model; a tool-calling request returns `finish_reason: tool_calls` |
| 4 | Full autonomous loop | `POST /predict_single_customer 200` at mcpo **and** `POST /predict 200` at the API, from a plain-English question |

Checkpoint 3 matters more than it looks: `finish_reason: tool_calls` is the only way to distinguish "the model declined to use the tool" from "the tool was never offered to it." Those failures look identical in the chat window.

---

## The model

> **Dataset disclosure.** The training data is a public telecom **churn** benchmark with the target column relabelled `payment_delay` for the purposes of this exercise. The features are call-record and account fields, not billing history. The modelling is real and the pipeline is real; the business framing is synthetic. Treat the numbers as a working example, not a validated credit-risk model.

| Property | Value |
|---|---|
| Rows / columns | 3,000 / 20 |
| Class balance | `no` 2,587 (86.23%) · `yes` 413 (13.77%) |
| Pipeline | `ColumnTransformer` -> `RandomOverSampler` -> `RandomForestClassifier` (imblearn) |
| Split | 80/20 stratified |
| Features at inference | 36 - 19 raw plus 17 derived `<column>_is_outlier` flags |
| Decision threshold | 0.35, persisted as an artifact |

The threshold is **not** 0.5 and is **not** hardcoded. It ships as `models/threshold.pkl` and is overridable per request, because on a target that is 13.77% positive the default cutoff optimises for the wrong thing. A lower threshold catches more late payers at the cost of more false positives, and which trade-off is correct is a business decision, not a modelling one - so the API exposes it as a parameter.

Nothing in the codebase hardcodes a column name. Feature order comes from `feature_columns.pkl`, outlier bounds from `outlier_bounds.pkl`, so retraining does not require a code change.

---

## Engineering decisions worth defending

**The MCP server never imports the model.** It calls the API over HTTP. That keeps the MCP process small - no sklearn, no 9 MB pickle resident - and lets the model service scale, deploy, and get monitored like any other microservice. A protocol adapter should hold no business logic.

**Prediction runs off the event loop.** The inference call is dispatched with `run_in_threadpool`, so CPU-bound scoring never blocks FastAPI's async loop under concurrent requests.

**stdio discipline.** MCP over stdio requires stdout to carry JSON-RPC frames and nothing else, so a stray `print()` corrupts the stream and kills the session. Consequently all logging is routed to stderr, `httpx` and `httpcore` are silenced, and `launcher.py` redirects uvicorn's output to a log file, waits for `/health`, and only then hands the client clean stdio.

**Two entrypoints for two topologies.** `server.py` is the container entrypoint, where the API is a separate service. `launcher.py` is the local entrypoint, which starts the API itself and waits for it - the right shape for a desktop MCP client that expects one process to own its dependencies.

**A pin that documents a real incident.** `mcp>=1.2.0,<2.0`: mcp 2.x renamed `streamablehttp_client`, and mcpo 0.0.20 still imports the old name, so mcpo crash-loops against 2.x. The cap is commented in `requirements.txt` with the reason, because a version pin without a reason is deleted by the next person who reads it.

---

## Repository structure

```
mcp-payment-delay/
├── src/payment_delay/
│   ├── config.py                 # single source of truth for paths + endpoints, all env-overridable
│   ├── inference/predictor.py    # the only code that opens the pickle; imports no web framework
│   ├── api/main.py               # thin FastAPI adapter over the predictor
│   └── mcp_server/
│       ├── server.py             # FastMCP tools, resources, prompt (container entrypoint)
│       ├── api_client.py         # HTTP calls into the model service
│       └── launcher.py           # starts the API, then serves MCP on clean stdio (local entrypoint)
├── models/                       # model, threshold, outlier bounds, feature order
├── data/telecomunicatii.csv      # sample dataset
├── deploy/litellm_config.yaml    # Bedrock routing
├── scripts/bedrock_smoke_test.py # asserts a tool call comes back, not merely a 200
├── docs/                         # architecture + Docker runbook
├── Dockerfile                    # one image, serves both the API and the mcpo bridge
└── docker-compose.yml            # API + mcpo + LiteLLM + OpenWebUI
```

---

## Getting started

### Run the model service alone - no cloud credentials needed

```bash
python3 -m venv .venv && source .venv/bin/activate
make install                       # pip install -e ".[dev]"
make api                           # http://localhost:8000/docs
```

| Endpoint | Purpose |
|---|---|
| `GET /health` | service up, model loaded |
| `GET /model/info` | model type, classes, features, threshold |
| `GET /schema` | required CSV columns |
| `POST /predict` | one row (JSON object or one-row CSV) -> one yes/no |
| `POST /predict/batch` | multi-row CSV -> one yes/no per row |
| `POST /predict/summary` | multi-row CSV -> one yes/no for the whole file |

```bash
curl -F "file=@data/telecomunicatii.csv" \
     "http://localhost:8000/predict/summary?threshold=0.35"
```

### Attach your own MCP client

```bash
python3 -m payment_delay.mcp_server.launcher
```

Serves the tools over stdio and starts the API if it is not already healthy. `opencode.json` wires this into OpenCode; Claude Desktop and any other stdio MCP client attach the same way.

### Run the full stack

```bash
cp .env.example .env               # add your Bedrock key
python3 scripts/bedrock_smoke_test.py
make stack                         # http://localhost:3000
```

Full runbook, including credential setup and troubleshooting: **[docs/docker-stack.md](docs/docker-stack.md)**.

---

## The MCP surface

Five tools, two resources, one prompt template:

```
get_api_health           service + model status
get_model_info           model metadata, classes, features, endpoints
get_input_schema         expected CSV columns
predict_payment_delay    CSV in (path or text), per-row or aggregate, threshold configurable
predict_single_customer  one customer as a JSON object

payment-delay://context       business + modelling context, injected as a resource
payment-delay://api-contract  the HTTP contract these tools call

interpret_payment_delay_result   prompt template for business-language explanation
```

Resources and prompts are the underused half of MCP. The context resource means the client does not need to be told what the model is for - it can read it.

---

## Tech stack

`FastAPI` · `FastMCP` · `mcpo` · `scikit-learn` · `imbalanced-learn` · `pandas` · `LiteLLM` · `AWS Bedrock` · `OpenWebUI` · `Docker Compose` · `uvicorn` · `httpx`

## Docs

- **[Architecture](docs/architecture.md)** - the layers, the prediction pipeline, and why the split is where it is
- **[Docker stack runbook](docs/docker-stack.md)** - credentials, startup, health checks, troubleshooting
- **[docs/assignment/](docs/assignment/)** - the original brief

---

*Eduard-Gabriel Tudoran, 2026.*