Skip to main content
Glama
Mahnooramjad05

MCP Agent Toolkit

README.md
# MCP Agent Toolkit

An agent built on the Model Context Protocol (MCP) that discovers and calls external tools for multi-step research, data lookup, and code-execution tasks, returning reliable structured JSON.

## Overview

This project implements a real, runnable MCP server (using the official `mcp` Python SDK) exposing four distinct tools: a read-only SQL tool over a seeded SQLite database, a REST API tool, a sandboxed Python code interpreter, and a set of file-resource lifecycle tools. An MCP client/agent connects to the server over stdio, dynamically discovers the available tools (nothing is hardcoded), and drives a plan-act loop with a pluggable LLM (OpenAI, Anthropic, or a deterministic offline mock) to satisfy multi-step natural-language tasks such as "look up customer X's orders, sum their total, and tell me if it exceeds $500." The agent's final answer is validated against a Pydantic schema before being returned, and every tool call made along the way is recorded in a trace. A small FastAPI layer exposes this agent over HTTP as `POST /agent/run`. The SQL tool also demonstrates MCP "context negotiation": callers request a page size, the server clamps it to a safe maximum and reports whether more data is available.

## Key Features

- **MCP server with 4 distinct tools**, built on the official `mcp` Python SDK (`FastMCP`):
  - `sql_query` — read-only SQL access to a bundled SQLite sample database (`customers`, `products`, `orders`), with context-negotiated pagination (`page_size`/`offset`, server-clamped, `has_more`/`next_offset` reported back).
  - `rest_exchange_rate` — calls a REST API (a bundled local mock FastAPI server, see "REST tool: real API vs. mock" below) to fetch currency exchange rates.
  - `run_python` — a sandboxed Python code interpreter: snippets are statically checked against an import/name allowlist-denylist, then executed in an isolated subprocess with a wall-clock timeout (and, on POSIX, CPU/memory `rlimit`s).
  - `list_resources` / `open_resource` / `read_resource` / `release_resource` — explicit MCP resource lifecycle management (list available file resources, open a handle, read through that handle one or more times, then explicitly release it; reads after release fail).
- **MCP client/agent** (`agent/client.py`) that performs live tool discovery via `tools/list` (no hardcoded tool list) and runs a bounded plan-act loop, calling MCP tools and observing results until it produces a final answer.
- **Pluggable LLM** (`agent/llm.py`): `LLM_PROVIDER=openai|anthropic|mock`. Defaults to a deterministic, rule-based mock LLM that requires no API key and no network access, so the whole project (including all tests) runs fully offline.
- **Structured (JSON-mode) output**: the agent's final answer is always validated against the `AgentAnswer` Pydantic schema (`agent/schemas.py`), including a typed tool-call trace.
- **Context negotiation**: the SQL tool clamps requested page sizes to a server-side maximum and reports `has_more`/`next_offset`, demonstrating negotiated, paginated tool results instead of unbounded responses.
- **FastAPI wrapper** (`api/main.py`) exposing `POST /agent/run`, which runs the full client-server-agent interaction per request and returns the structured result plus the discovered tool names.
- **pytest suite** (18 tests) covering tool discovery, SQL correctness and pagination, sandbox accept/reject behaviour, the REST tool against the bundled mock server, resource lifecycle enforcement, and end-to-end structured agent output — all offline, no API key required.
- **Docker image** that seeds the database at build time and runs the mock REST server plus the FastAPI app.

## Tech Stack

- Python 3.11+
- `mcp` (official Model Context Protocol Python SDK, `FastMCP` high-level server API, stdio transport)
- FastAPI + Uvicorn
- Pydantic v2 (schemas / structured output)
- SQLite (stdlib `sqlite3`, no external DB server)
- httpx (REST calls)
- OpenAI SDK / Anthropic SDK (optional, pluggable)
- pytest
- Docker

## Architecture

```
                     ┌───────────────────────────────┐
  HTTP POST          │        FastAPI (api/main.py)   │
  /agent/run  ─────▶  │   POST /agent/run              │
                     └───────────────┬────────────────┘
                                     │ run_agent_task(task)
                                     ▼
                     ┌───────────────────────────────┐
                     │   Agent / MCP client            │
                     │   (agent/client.py)             │
                     │                                 │
                     │  1. spawn MCP server (stdio)     │
                     │  2. tools/list -> dynamic         │
                     │     tool discovery                │
                     │  3. loop:                        │
                     │       LLMClient.plan_step(...)    │
                     │       -> call_tool | final_answer │
                     │  4. validate AgentAnswer (Pydantic)│
                     └───────┬───────────────┬─────────┘
                             │               │
                    MCP stdio│               │pluggable LLM
                    protocol │               │(agent/llm.py)
                             ▼               ▼
              ┌─────────────────────┐   OpenAI / Anthropic /
              │  MCP server          │   deterministic MockLLM
              │  (mcp_server/server.py, FastMCP)
              │                       │
              │  sql_query  ─────────┼──▶ data/sample.db (SQLite)
              │  rest_exchange_rate ─┼──▶ mock_rest_server.py (FastAPI, localhost)
              │  run_python ─────────┼──▶ sandbox.py (subprocess, allowlist, timeout)
              │  list/open/read/     │
              │  release_resource ───┼──▶ data/resources/*.txt|*.md
              └─────────────────────┘
```

- **MCP server** (`mcp_server/server.py`): a single `FastMCP` instance registering the four tools below plus one MCP resource template (`resource://mcp-agent-toolkit/{filename}`), served over stdio. The agent spawns it as a subprocess (`python -m mcp_server.server`) per run, so each HTTP request gets an isolated server process.
- **The four tools**:
  1. `sql_query` opens a fresh read-only `sqlite3` connection (`PRAGMA query_only = ON`) per call against `data/sample.db`, rejects anything that isn't a single `SELECT`, and implements **context negotiation**: it accepts a requested `page_size`/`offset`, clamps `page_size` to `[1, 25]` server-side regardless of what was asked for, and always reports back `effective_page_size`, `total_rows_matched`, `has_more`, and `next_offset` so a caller can page through a large result set safely.
  2. `rest_exchange_rate` makes a real HTTP call (via `httpx`) to a REST API for currency exchange rates.
  3. `run_python` statically validates a Python snippet with `ast` (import allowlist + denylisted names such as `open`/`eval`/`exec`/dunder-attribute access), then runs it in a brand-new subprocess (never `exec()`-ed in-process) with a wall-clock timeout and, on POSIX, CPU-time/address-space `rlimit`s.
  4. `list_resources` / `open_resource` / `read_resource` / `release_resource` model explicit MCP **resource lifecycle**: list available files, open a named handle (`handle_id`), read through it any number of times, then release it — after release, further reads on that handle fail. The same files are also exposed as standard MCP resources via `resource://mcp-agent-toolkit/{filename}` for `resources/list`/`resources/read`.
- **MCP client/agent** (`agent/client.py`): connects over stdio with `mcp.ClientSession`, calls `session.list_tools()` to discover tools dynamically (the tool list is never hardcoded in the agent), then loops: ask the `LLMClient` for a `PlanStep` (call a tool, or produce a final answer) given the task, the discovered tools, and the history of calls/results so far; execute the chosen tool via `session.call_tool(...)`; repeat (bounded by `MAX_AGENT_STEPS`). The final answer dict from the LLM is validated into an `AgentAnswer` Pydantic model, with a `ToolCallRecord` per tool call made.
- **Pluggable LLM** (`agent/llm.py`): `build_llm_client()` reads `LLM_PROVIDER` and returns an `OpenAILLMClient`, `AnthropicLLMClient`, or the default `MockLLMClient`. All three implement the same `plan_step(task, available_tools, history) -> PlanStep` interface. The real providers are prompted to return one JSON object matching the `call_tool` / `final_answer` shape described in `SYSTEM_INSTRUCTIONS`. The `MockLLMClient` is a small deterministic rule-based planner: it recognizes the reference task shape ("customer X's orders... exceeds $N"), issues one `sql_query` call, then computes the sum/threshold comparison itself and returns a `final_answer` — enough to drive the full agent loop, including the FastAPI layer and the entire pytest suite, with zero network access and no API key.
- **FastAPI layer** (`api/main.py`): `POST /agent/run` takes `{"task": "..."}`, calls `run_agent_task(task)` (which owns the full spawn-discover-loop-validate lifecycle above), and returns `{"result": AgentAnswer, "available_tools": [...]}`.

### REST tool: real API vs. mock (read this)

The `rest_exchange_rate` tool calls a **bundled local mock REST server** (`mcp_server/mock_rest_server.py`, a small FastAPI app), not an external public API. This was a deliberate choice per the project brief: the goal is a demo (and a pytest suite) that is fully self-contained and deterministic and does not depend on outbound internet access being available in whatever environment this repo is cloned, tested, or CI-run in. The mock server is exercised over real HTTP (via `httpx`, over `127.0.0.1`) exactly as a call to a real public API would be — the tool code has no special-casing for "mock vs. real"; pointing `MOCK_REST_BASE_URL` at a real exchange-rate API with a compatible response shape would work with no code changes to the tool itself.

### Sandboxing approach and its real limits (read this)

The `run_python` tool is a **portfolio-grade sandbox**, explicitly not a hardened, production-grade isolation boundary. What it actually does:

1. **Static AST check** (`mcp_server/sandbox.py::check_code_safety`): parses the snippet with Python's `ast` module and rejects it *before execution* if it imports anything outside a small allowlist (`math`, `statistics`, `json`, `itertools`, `collections`, `datetime`, `re`, `random`, `textwrap`), or references denylisted names (`open`, `exec`, `eval`, `compile`, `__import__`, `getattr`/`setattr`/`delattr`, `globals`/`locals`/`vars`, etc.), or accesses any dunder attribute (a common sandbox-escape vector, e.g. `().__class__`).
2. **Process isolation**: the snippet is written to a temp file and run via `subprocess.run([sys.executable, "-I", "-S", script])` — a brand-new OS process, never `exec()`-ed in the server's own process.
3. **Resource limits**: a wall-clock timeout (`subprocess.run(timeout=...)`, default 5s, max 10s) always applies. On POSIX, a `preexec_fn` additionally applies `resource.setrlimit` for CPU time and address space. **On Windows there is no OS-level CPU/memory rlimit** — only the wall-clock timeout and the static allowlist apply; this is a real, documented gap, not an oversight.

**What this does not protect against:** a sufficiently creative snippet may find static-analysis bypasses that this simple allowlist/denylist does not catch (this is a known-hard problem for a dynamic language like Python); there is no OS-level container/seccomp/VM boundary (no gVisor/Firecracker/Docker-level isolation for the interpreter itself — the Docker image in this repo containerizes the whole app, not each snippet individually); and on Windows, memory/CPU exhaustion inside the timeout window is not prevented. For genuinely untrusted/adversarial code, use a real container- or VM-based sandbox instead of this module.

## Setup / Installation

```powershell
# 1. Create and activate a virtual environment (recommended)
python -m venv venv
venv\Scripts\activate

# 2. Install dependencies
pip install -r requirements.txt

# 3. Seed the sample SQLite database (creates data/sample.db)
python scripts/seed_db.py

# 4. Copy the env template (optional -- defaults work with no keys set)
copy .env.example .env
```

## Usage

### Run the FastAPI server

```powershell
uvicorn api.main:app --reload
```

### (Optional) Run the bundled mock REST server, for the REST tool

```powershell
uvicorn mcp_server.mock_rest_server:app --port 8811
```

If this is not running, `rest_exchange_rate` calls will fail with a connection error — everything else (SQL tool, sandbox tool, resource tools, the reference agent task) works without it.

### Example request/response

```
POST /agent/run
Content-Type: application/json

{
  "task": "Look up customer Alice Nguyen's orders, sum their total, and tell me if it exceeds $500."
}
```

```json
{
  "result": {
    "task": "Look up customer Alice Nguyen's orders, sum their total, and tell me if it exceeds $500.",
    "status": "completed",
    "answer": "Alice Nguyen's completed orders total $334.00, which does not exceed $500.00.",
    "key_facts": {
      "customer_name": "Alice Nguyen",
      "order_total": 334.0,
      "threshold": 500.0,
      "exceeds_threshold": false,
      "num_completed_orders": 3
    },
    "tool_calls": [
      {
        "step": 1,
        "tool_name": "sql_query",
        "arguments": {
          "sql": "SELECT c.name AS customer_name, o.order_id, o.quantity, p.unit_price, (o.quantity * p.unit_price) AS line_total, o.status FROM orders o JOIN customers c ON c.customer_id = o.customer_id JOIN products p ON p.product_id = o.product_id WHERE c.name = 'Alice Nguyen'",
          "page_size": 25
        },
        "result_summary": "{\"columns\": [...], \"rows\": [...], ...}",
        "raw_result": { "columns": ["customer_name", "order_id", "quantity", "unit_price", "line_total", "status"], "rows": [ { "customer_name": "Alice Nguyen", "order_id": 1, "quantity": 1, "unit_price": 59.5, "line_total": 59.5, "status": "completed" }, { "..." : "..." } ], "row_count_returned": 3, "total_rows_matched": 3, "offset": 0, "requested_page_size": 25, "effective_page_size": 25, "has_more": false, "next_offset": null }
      }
    ]
  },
  "available_tools": ["sql_query", "rest_exchange_rate", "run_python", "list_resources", "open_resource", "read_resource", "release_resource"]
}
```

This example runs with the default `LLM_PROVIDER=mock` and requires no API key.

### Running the seed script and tests

```powershell
python scripts/seed_db.py
pytest -v
```

The test suite (18 tests) seeds the database automatically via a session fixture if it does not already exist, spawns the real MCP server as a subprocess for discovery/end-to-end tests, and starts the bundled mock REST server in-process for the REST tool test. No test requires a real API key or outbound network access.

## Environment Variables

| Variable | Required | Default | Description |
|---|---|---|---|
| `LLM_PROVIDER` | No | `mock` | One of `mock`, `openai`, `anthropic`. Selects the agent's planning LLM. |
| `OPENAI_API_KEY` | Only if `LLM_PROVIDER=openai` | — | OpenAI API key. |
| `OPENAI_MODEL` | No | `gpt-4o-mini` | OpenAI model name. |
| `ANTHROPIC_API_KEY` | Only if `LLM_PROVIDER=anthropic` | — | Anthropic API key. |
| `ANTHROPIC_MODEL` | No | `claude-3-5-haiku-latest` | Anthropic model name. |
| `MOCK_REST_BASE_URL` | No | `http://127.0.0.1:8811` | Base URL the `rest_exchange_rate` tool calls. |

See `.env.example` for a copyable template (placeholder values only — no real secrets are stored in this repo).

## Project Layout

```
mcp_server/
  server.py             MCP server (FastMCP): tool + resource definitions
  sandbox.py            Sandboxed code interpreter (AST check + subprocess)
  mock_rest_server.py   Bundled mock REST API (FastAPI)
agent/
  client.py             MCP client / agent loop, tool discovery
  llm.py                Pluggable LLM client (openai/anthropic/mock)
  schemas.py            Pydantic structured-output schemas
api/
  main.py               FastAPI wrapper: POST /agent/run
scripts/
  seed_db.py             SQLite seed script
data/
  resources/             Sample files exposed via the resource lifecycle tools
  sample.db              Generated by seed_db.py (not committed, see .gitignore)
tests/                   pytest suite
```

## License

MIT License, Copyright (c) 2026 Mahnoor Amjad. See `LICENSE`.