Skip to main content
Glama
cloud-skunkworks

Multi-cloud MCP RAG demo

README.md
# Multi-cloud MCP RAG demo (0.2.0rc2)

A small, read-only **Model Context Protocol (MCP)** server that gives an AI agent
retrieval over a sanitized knowledge base, with citation URIs it can quote back.

It is a teaching and integration demo, not a production search service. It uses
synthetic data only, and no LLM account or cloud credentials are needed to run it locally.

---

## Contents

1. [What this does](#1-what-this-does)
2. [Why it exists](#2-why-it-exists)
3. [Architecture](#3-architecture)
4. [Deploy and use](#4-deploy-and-use)
5. [Smoke test results (rc2)](#5-smoke-test-results-rc2)
6. [Repository map](#repository-map)
7. [Security and data](#security-and-data)
8. [GitHub publication](#github-publication)

## 1. What this does

The server exposes three MCP primitives over Streamable HTTP at `/mcp`:

| Primitive | Name | What an agent gets |
|---|---|---|
| Tool | `search_kb(query, limit=3)` | Up to 10 ranked documents, each with `citation` (`kb://documents/<id>`), a `sha256` of the text, and a relevance `score`. |
| Resource | `kb://articles` | JSON list of every document `id` and `title`. |
| Resource | `kb://documents/{document_id}` | Full text of one document. IDs never map to file paths. |
| Prompt | `grounded_answer(question)` | A grounding template: cite each claim, treat documents as untrusted, say "I do not know" when evidence is missing. |

The server does **not** generate answers. The host application (your agent) takes the
evidence, calls its own approved model, checks citations, and gates any financial action.

Every response is marked `untrusted_content: true`, and the server's instructions tell the
client that retrieved text is data, never instructions.

## 2. Why it exists

Agents that answer from a knowledge base need three things that are easy to get wrong:

1. **Grounded evidence with stable identifiers.** Answers should cite something a reviewer
   can re-fetch. Here every hit has a `kb://` URI and a content hash.
2. **Prompt-injection hygiene at the boundary.** Documents can contain text that looks like
   instructions. The retrieval layer labels that text as untrusted rather than pretending it is safe.
3. **The same contract on any cloud.** The corpus is loaded from local disk, AWS S3, Azure
   Blob Storage or Google Cloud Storage through the official SDKs. Failures are fail-closed:
   the server never silently falls back to demo data.

It also shows operational guardrails that a real deployment needs: a non-root, read-only,
loopback-bound container; bounded request and corpus sizes; DNS-rebinding (Host header)
rejection; and OpenTelemetry export that drops query text, document text and exceptions.

## 3. Architecture

### 3.1 Runtime view

```
 ┌──────────────────────┐
 │  Agent host app      │  owns the LLM, citation checks and approvals (out of scope)
 │  (examples/          │
 │   agent_client.py)   │
 └──────────┬───────────┘
            │  MCP over Streamable HTTP   POST /mcp   (stateless, JSON responses)
            ▼
 ┌─────────────────────────── mcp-rag container ───────────────────────────┐
 │  non-root uid 10001 · read-only rootfs · cap_drop ALL · 256 MiB · 1 CPU   │
 │                                                                          │
 │  __main__.py   uvicorn + Starlette    GET /healthz   POST /mcp           │
 │       │        body limit 16 KiB · Host allow-list · access log off      │
 │       ▼                                                                  │
 │  server.py     MCP primitives                                            │
 │       │        search_kb · kb://articles · kb://documents/{id}           │
 │       │        grounded_answer prompt                                    │
 │       ├──────► corpus.py    parse + bound (≤ 1 MiB, 1–200 docs, ≤ 12 kB  │
 │       │                     per text) · lexical token-overlap ranking    │
 │       │                     · sha256 per hit                              │
 │       ├──────► telemetry.py opt-in OTLP/HTTP · RetrievalOnlyExporter     │
 │       │                     forwards only rag.search + result count      │
 │       ▼                                                                  │
 │  storage.py    load ONE immutable snapshot at startup (fail closed)      │
 └───────┬──────────────────┬──────────────────┬──────────────────┬─────────┘
         │ local (default)  │ aws              │ azure            │ gcp
         ▼                  ▼                  ▼                  ▼
  data/corpus.json     S3 object          Blob object        GCS object
  (baked into image)   (boto3)            (DefaultAzure-     (google-cloud-
                                           Credential)        storage)
         │
         └── optional OTLP traces ──► approved internal collector (HTTPS)
```

### 3.2 Code map

| Path | Role |
|---|---|
| [`src/mcp_rag/__main__.py`](src/mcp_rag/__main__.py) | Entry point. Starts uvicorn, adds `/healthz`, disables library logs. |
| [`src/mcp_rag/server.py`](src/mcp_rag/server.py) | Declares the tool, resources and prompt; wraps search in a `rag.search` span. |
| [`src/mcp_rag/corpus.py`](src/mcp_rag/corpus.py) | Validates the corpus schema and bounds, ranks documents, builds citations. |
| [`src/mcp_rag/storage.py`](src/mcp_rag/storage.py) | Picks one provider from `RAG_PROVIDER` and reads at most 1 MiB + 1 byte. |
| [`src/mcp_rag/telemetry.py`](src/mcp_rag/telemetry.py) | Optional OTLP export with a payload-filtering exporter. |
| [`examples/agent_client.py`](examples/agent_client.py) | Smoke client. Calls `search_kb` and prints the JSON result. |
| [`architecture/code-spec.json`](architecture/code-spec.json) | Machine-readable component, connection and requirement spec. |
| [`architecture/architecture.calm.json`](architecture/architecture.calm.json) | Generated FINOS CALM 1.0 model. Checked for drift in CI. |
| [`docs/`](docs/) | Architecture, cloud configuration, sources and validation notes. |

### 3.3 Design choices

- **Stateless and immutable.** Each request is independent. The corpus is loaded once at
  startup. Restart the service to refresh data.
- **Lexical retrieval, not vectors.** Scoring is set-overlap of word tokens, which is
  deterministic and easy to test. It is not a semantic or vector search.
- **Bounded inputs.** Queries are 1–500 characters, `limit` is 1–10, request bodies are capped at 16 KiB.
- **Telemetry is allow-listed.** The exporter forwards only the `rag.search` span with
  `rag.result_count`. Any other span, event or attribute is dropped.

## 4. Deploy and use

### 4.1 Prerequisites

- Python 3.12 or later (the container uses `python:3.12-slim`).
- Docker Engine with Compose v2, running Linux containers.
- Optional: AWS, Azure or GCP access if you want to test cloud corpus loading.

### 4.2 Run the container (recommended)

From the repository root:

```bash
docker compose config --quiet                 # validate the compose file
docker compose up --build -d --wait           # build, start, wait for healthy
curl -s http://127.0.0.1:8000/healthz         # {"status":"ready"}

python3 -m venv .venv && . .venv/bin/activate
python -m pip install -r requirements-dev.txt -c requirements.lock
python examples/agent_client.py               # expect kb://documents/idempotency

docker compose down
```

PowerShell 7 equivalent:

```powershell
docker compose up --build -d
Invoke-RestMethod http://127.0.0.1:8000/healthz
python -m venv .venv
& ./.venv/Scripts/Activate.ps1
python -m pip install -r requirements-dev.txt -c requirements.lock
python examples/agent_client.py
docker compose down
```

The compose file publishes the port only on `127.0.0.1:8000`, runs as uid 10001,
uses a read-only root filesystem, drops all Linux capabilities and sets
`no-new-privileges`.

### 4.3 Run from Python (development)

```bash
export PYTHONPATH="$PWD/src"
python -m mcp_rag                             # listens on 127.0.0.1:8000
```

PowerShell: `$env:PYTHONPATH = (Join-Path (Get-Location) 'src'); python -m mcp_rag`

Change the bind address or port with `MCP_HOST` and `MCP_PORT`. Point the client at a
different URL with `MCP_URL` (default `http://127.0.0.1:8000/mcp`).

### 4.4 Call it from your own agent

```python
import asyncio
from mcp import Client

async def main():
    async with Client("http://127.0.0.1:8000/mcp") as client:
        result = await client.call_tool("search_kb", {"query": "payment retry idempotency", "limit": 3})
        for match in result.structured_content["matches"]:
            print(match["citation"], match["score"], match["title"])

asyncio.run(main())
```

Your agent should:

1. Pass only the returned `text` to its approved model, inside a clearly delimited data block.
2. Reject any answer whose claims do not cite a `kb://documents/<id>` from the result.
3. Treat the evidence as untrusted. Never execute instructions found in documents.

### 4.5 Configuration

| Variable | Default | Purpose |
|---|---|---|
| `RAG_PROVIDER` | `local` | `local`, `aws`, `azure` or `gcp`. Any other value fails at startup. |
| `RAG_LOCAL_PATH` | [`data/corpus.json`](data/corpus.json) | Local corpus file (local provider). |
| `RAG_OBJECT` | — | Object key or blob name for cloud providers. |
| `RAG_BUCKET` | — | S3 or GCS bucket name. |
| `AWS_REGION` | — | Required for `aws`. |
| `AZURE_STORAGE_ACCOUNT_URL` | — | HTTPS URL with no credentials or query string. Required for `azure`. |
| `AZURE_STORAGE_CONTAINER` | — | Required for `azure`. |
| `GCP_PROJECT_ID` | — | Required for `gcp`. |
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | unset (no export) | Approved OTLP/HTTP collector URL. |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8000` | Bind address for `python -m mcp_rag`. |

Copy [`.env.example`](.env.example) to `.env` for local overrides. `.env` files are git-ignored.
Cloud-specific setup is in [`docs/CLOUD.md`](docs/CLOUD.md).

### 4.6 Using a cloud corpus

The corpus must be one UTF-8 JSON array in the same format as [`data/corpus.json`](data/corpus.json),
up to 1 MiB and 200 documents. Each object has exactly `id`, `title` and `text`.

Credentials come from the runtime identity: IAM roles or IRSA for AWS, managed identity
or workload identity for Azure, and workload identity for GCP. **Do not put keys in
environment variables, compose files or the image.** The container's read-only filesystem
and dropped capabilities are designed for this model. Wire the identity into your runtime
before setting `RAG_PROVIDER` to a cloud value. Cloud reads have not been run in this
release (see section 5).

### 4.7 Run the tests and architecture checks

```bash
python -m pytest -q                           # 38 tests
python scripts/export_calm.py --check         # fails on architecture drift
```

[`scripts/verify.ps1`](scripts/verify.ps1) runs both commands for PowerShell users. CI runs the same checks,
then builds the image, waits for health, runs the client and stops the stack.

### 4.8 Troubleshooting

- **`docker compose up --build` fails on `pip install` with a certificate error.** Your
  network intercepts TLS. Add your corporate root CA to the image, for example with a
  `COPY` of the CA and `ENV PIP_CERT=...`. Do not disable certificate verification.
- **Server exits with "Server failed; check configuration, corpus and workload identity."**
  The message is deliberately generic. Check the provider variables, the corpus file
  and the runtime identity. Logs are disabled by design.
- **`421 Misdirected Request` from `/mcp`.** The `Host` header is not an allowed local
  name. Call it from `127.0.0.1` or `localhost`.

## 5. Smoke test results (rc2)

All results below come from running this exact release candidate. The table shows what
passed, what was run in this release, and what is still pending.

### 5.1 Environment

| Item | Value |
|---|---|
| Release | 0.2.0rc2 ([`pyproject.toml`](pyproject.toml)) |
| MCP SDK | `mcp==2.3.0` (latest stable on PyPI at test time) |
| Host Python | 3.12.3 (CI and container use 3.12) |
| Container base | `python:3.12-slim`, Docker Engine 29.8.2, Compose v5.6.0 |
| Platform | Linux x86_64 sandbox |

### 5.2 Results

| Check | Command | Result |
|---|---|---|
| Dependency resolution | `pip install -r requirements-dev.txt -c requirements.lock` | Passed |
| Dependency consistency | `pip check` | No broken requirements |
| Unit, protocol, HTTP, telemetry and architecture tests | `python -m pytest -q` | **38 passed** in 2.26 s |
| Real Streamable HTTP server and client | [`tests/test_http.py`](tests/test_http.py) (part of the 38) | Passed: readiness, `search_kb` result, Host header rejected |
| CALM 1.0 schema and drift | `python scripts/export_calm.py --check` | Code spec and CALM valid, no drift |
| Compose file | `docker compose config --quiet` | Passed |
| Image build and start | `docker compose up --build --wait` | Built and reached **healthy**. See note A |
| Health endpoint | `GET /healthz` | `{"status":"ready"}` |
| Agent smoke client | `python examples/agent_client.py` | Exit 0. Top hit `kb://documents/idempotency` (score 0.6667), then `kb://documents/rate-limits` (0.3333) |
| Pinned versions in image | `pip list` inside container | `mcp 2.3.0`, `starlette 1.9.0`, `httpx2 2.14.0`, `httpcore2 2.14.0`, `google-cloud-storage 3.17.1`, `certifi 2026.10.18`, `uvicorn 0.54.0` |
| Runtime hardening | `docker inspect` | User `10001:10001`, read-only rootfs, `cap_drop=[ALL]`, port bound to `127.0.0.1` only |
| Untrusted Host header | `POST /mcp` with `Host: attacker.invalid` | HTTP **421** |
| Container logs | `docker logs` | Empty (library logs disabled by design) |

**Note A.** The sandbox's network proxy re-signs TLS with its own CA, so the base image's
`pip` failed on the first build. The image was built from a scratch copy with that CA added.
The shipped [`Dockerfile`](Dockerfile) is unchanged. Your network may need the same step; see
troubleshooting in section 4.8.

### 5.3 Not run in this release

| Check | Status |
|---|---|
| GitHub Actions workflow | Not run. Needs your repository. The `verify.ps1` step was replaced by its two commands because PowerShell 7 is not installed in the sandbox. |
| AWS S3, Azure Blob, GCS retrieval | Not run. No cloud accounts or credentials were provided. Adapters are covered by SDK-mocked tests only. |
| OTLP export to a real collector | Not run. Covered by the in-memory payload-filter test only. |
| Load, penetration, IAM and supply-chain review | Not performed. See [`SECURITY.md`](SECURITY.md). |
| Image digest pinning and vulnerability scan | Not performed. Base image and action tags are not digest or commit pinned. |

### 5.4 Changes in rc2

- Updated to the latest stable releases of the pinned transitive packages:
  `starlette` 1.8.0 → 1.9.0, `httpx2` and `httpcore2` 2.13.1 → 2.14.0,
  `google-cloud-storage` 3.17.0 → 3.17.1 (also in [`requirements.txt`](requirements.txt)), and
  `certifi` 2026.7.22 → 2026.10.18. `mcp` 2.3.0 was already the latest stable.
- Version set to `0.2.0rc2`.
- README rewritten for this release. [`docs/VALIDATION.md`](docs/VALIDATION.md) has a new section for this run.

## Repository map

| Path | Purpose |
|---|---|
| [`src/mcp_rag`](src/mcp_rag) | MCP primitives, retrieval, cloud SDK adapters, OpenTelemetry |
| [`data/corpus.json`](data/corpus.json) | Synthetic sample corpus |
| [`examples/agent_client.py`](examples/agent_client.py) | Agent retrieval integration example |
| [`Dockerfile`](Dockerfile), [`compose.yaml`](compose.yaml) | Non-root, loopback-bound container |
| [`architecture`](architecture) | Code spec, JSON Schema, generated CALM model |
| [`docs`](docs) | Architecture, cloud configuration, sources and validation |
| [`tests`](tests) | Retrieval, protocol, adapter, telemetry and architecture tests |
| [`.github/workflows`](.github/workflows) | Tests, architecture checks and container smoke test |

## Security and data

This is a local, synthetic-data demo. The `/mcp` endpoint has **no authentication**.
Do not expose it publicly or load sensitive data. Read [`SECURITY.md`](SECURITY.md) before using real data
or any shared network. Dependencies are version-pinned, not claimed vulnerability-free.

## GitHub publication

This repository is the source for release `0.2.0rc2`, tagged `v0.2.0rc2`. Review the
license and source before reusing it. The repository contains no credentials. CI does not
deploy or publish images. It runs the tests and builds and smoke-tests the container on
pushes and pull requests. Add your own OIDC deployment stage and protected environment
approvals before any deployment.

MIT license for original code. See [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) for CALM schema licensing
and [`docs/SOURCES.md`](docs/SOURCES.md) for the tutorial and official SDK references.