mcp-toolserver
by roshano3o3
README.md
# mcp-toolserver
An MCP server exposing four real tools (document search, SQL, arithmetic, corpus introspection), plus an agent client that connects to it, discovers those tools at runtime, and chains them with Claude to answer questions no single tool could answer alone.
## What this demonstrates
- **The Model Context Protocol** — an open protocol (Anthropic, Nov 2024) that standardizes how an AI application connects to external tools and data. Without it, every AI app needs a custom integration for every tool, and every tool needs a custom integration for every AI app — an N×M problem. MCP turns that into N+M: a tool provider builds one MCP server, and any MCP-compatible client can use it with no bespoke integration code. This repo is a small, concrete instance of that: the server and client here don't know about each other's internals, only the protocol between them.
- **Dynamic tool discovery** — the agent client never hardcodes a tool list. It calls `list_tools()` at connect time and converts whatever the server currently advertises into Anthropic's tool-use format. Add or remove a tool on the server and the client picks it up automatically, with no client-side code change.
- **Multi-step tool chaining** — a single question can require two different tools in sequence (look a number up, then compute with it), and the agent loop handles that itself: Claude decides to call a second tool using the *result* of the first, without being told to.
## The four tools
| Tool | Signature | What it does |
|---|---|---|
| `search_documents` | `(query: str, top_k: int = 5) -> list[dict]` | Semantic search over docmind's ingested PDF corpus (dense embeddings + Chroma). Returns `{text, source, page, score}` per chunk. |
| `query_database` | `(sql: str) -> list[dict]` | Read-only SQL against a small seeded demo company database (`employees`, `departments`). SELECT only — see Safety below. |
| `calculate` | `(expression: str) -> float` | Arithmetic evaluation (`+ - * / ** %`, parentheses) with no code execution path — see Safety below. |
| `list_documents` | `() -> list[dict]` | Inventory of the ingested corpus: `{source, pages, chunks}` per document. |
Each tool's docstring *is* its MCP description — that's what the LLM actually reads to decide when to call it, so they're written for that audience, not for a human skimming the source.
## Live demo runs
All three below are real runs against the real Claude API and a real spawned MCP server subprocess — not fabricated traces. Leading with the one that actually chains two tools, since that's the interesting case.
### 1. Multi-step: query_database → calculate
> "What's the average salary in Engineering, and what would a 12% raise cost in total?"
```
Answer:
Here's the breakdown for the Engineering department:
| Metric | Value |
|---|---|
| Average Salary | $141,600 |
| Total Current Payroll | $708,000 |
| Cost of 12% Raise | $84,960 |
| New Total Payroll | $792,960 |
A 12% raise across all Engineering employees would cost an additional $84,960,
bringing the department's total payroll from $708,000 to $792,960.
Iterations: 3
Tool calls:
1. query_database({'sql': "SELECT AVG(salary) as avg_salary, SUM(salary) as total_salary FROM employees WHERE department_id = (SELECT id FROM departments WHERE name = 'Engineering')"})
-> [{'avg_salary': 141600.0, 'total_salary': 708000}]
2. calculate({'expression': '708000 * 0.12'})
-> 84960.0
```
Claude wrote the SQL itself, read the result, then wrote and ran the arithmetic expression itself — the tool inputs above are Claude's, not hardcoded. (Verified by hand: 5 Engineering employees summing to $708,000 in the seed data, ÷5 = $141,600 avg, ×0.12 = $84,960.)
### 2. Single tool: query_database
> "How many employees are in the Engineering department?"
```
Answer:
There are 5 employees in the Engineering department.
Iterations: 2
Tool calls:
1. query_database({'sql': "SELECT COUNT(*) as employee_count FROM employees e JOIN departments d ON e.department_id = d.id WHERE d.name = 'Engineering'"})
-> [{'employee_count': 5}]
```
### 3. Single tool: search_documents
> "What is corrective RAG?"
```
Answer:
## Corrective RAG (CRAG)
Corrective RAG (CRAG) is an enhanced version of standard Retrieval-Augmented
Generation (RAG) that adds a self-correction step after the initial retrieval
phase. [...] Standard (vanilla) RAG simply takes the top-k retrieved documents
and passes them directly to the language model generator -- regardless of
whether those documents actually answer the question. CRAG improves on this by
checking retrieval quality before generation.
[... full answer continues with the retrieve -> grade -> (generate | rewrite &
retry) flow and the latency/LLM-call tradeoff, condensed here for length ...]
Iterations: 2
Tool calls:
1. search_documents({'query': 'corrective RAG'})
-> [5 chunks from langgraph_agents.pdf and llm_evaluation.pdf, scores 0.44-0.58]
```
The answer is grounded in the actual retrieved text (docmind's `langgraph_agents.pdf`) — not general knowledge about CRAG, which Claude also has, but wasn't asked to use here.
## Safety
**`query_database` — layered, not single-point:**
1. **App-level keyword/shape check** — rejects anything that isn't a single `SELECT` (or `WITH ... SELECT`) statement before it reaches SQLite at all. Blocks `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `CREATE`, `ATTACH`, `DETACH`, `PRAGMA`, `VACUUM`, `REINDEX`, and rejects multiple statements (`;`-separated) outright.
2. **SQLite's native read-only mode** — the connection itself is opened with `?mode=ro` in the URI. This is enforced by the SQLite engine, not application code, so it's the real backstop if step 1 has a gap: even a query that somehow got past the keyword check physically cannot write.
3. **Row cap** — every query is wrapped as `SELECT * FROM (<query>) LIMIT 500`, so no query can return more than 500 rows regardless of what it asks for.
4. **Wall-clock timeout** — a `sqlite3` progress handler checks elapsed time and aborts the statement if it runs too long.
**`calculate` — AST allowlist, not `eval()`:** the expression is parsed with `ast.parse(..., mode="eval")` and walked by hand; only `Constant` (numeric), `BinOp` (`+ - * / ** %`), and `UnaryOp` (`+`/`-`) nodes are permitted. Anything else — a `Name` lookup, a `Call`, an `Attribute` — has no matching branch in the walker and raises `ValueError` by construction. This is why `calculate("__import__('os').system('...')")` fails: it's not pattern-matched against a blocklist of dangerous calls, there's simply no code path that would ever execute a `Call` node at all.
## Design decisions
- **Explicit agent loop, not the Anthropic SDK's beta Tool Runner.** The SDK does ship an MCP bridge (`anthropic.lib.tools.mcp`) that plugs MCP tools straight into the Tool Runner. It wasn't used here because the goal was a specific, inspectable return contract — `{answer, tool_calls: [{tool, input, output}], iterations}` — which needs hand-rolled bookkeeping around each turn. The Tool Runner would hide exactly the mechanics (loop control, per-call tracing) this project is meant to show.
- **stdio transport, not streamable-http.** The client spawns the server as its own subprocess on demand; both live in the same trust boundary and there's no network hop, so stdio's simplicity (no ports, no auth story needed) fits. Streamable-http is supported (`--transport streamable-http` / `MCP_TRANSPORT` env var) for the case where server and client are genuinely separate processes/machines, but nothing here has been hardened for that (see Limitations).
- **8-iteration cap.** Bounds the worst-case cost and latency of a runaway loop — the same reasoning as docmind's rewrite cap. All three demo runs above finished in 2-3 iterations; 8 is a generous ceiling meant to catch a genuinely malformed request or unstable model behavior, not something expected to bind in normal use.
## Connection to docmind
`search_documents` and `list_documents` read **docmind's own persisted Chroma collection** directly (`DOCMIND_CHROMA_PATH`, default pointed at the sibling `docmind` project's `data/chroma`), embedding queries with the same `all-MiniLM-L6-v2` model docmind used at ingestion time. Nothing about the connection is docmind-specific at the code level — it's just a Chroma collection at a configured path — so this project is a genuine second consumer of that corpus, not a copy of it. It's a small proof that docmind's retrieval layer isn't wired into docmind's own FastAPI backend specifically; it's addressable by any MCP-aware client that knows where the collection lives.
## Known limitations
- The demo SQL database is tiny and synthetic (12 employees, 4 departments) — nothing here has been tested against a real-scale or adversarial database.
- The SQL keyword blocklist is a regex over the query text, not a real SQL parser — it can both over-block (e.g. a legitimate `pragma_table_info()` table-valued function reference) and, in principle, miss a construct nobody thought to test. The read-only connection mode is the defense that doesn't depend on the blocklist being complete.
- `calculate` supports only numeric literals and the six listed operators — no functions (`sqrt`, `sin`, ...), no variables. Deliberately minimal, not a general expression engine.
- The MCP server has no authentication. Fine for stdio (process-local, single trust boundary); if run over `streamable-http` as currently implemented, anyone who can reach the port can call any tool, including `query_database`.
- The 8-iteration cap is a hard stop, not a graceful degradation — a question that legitimately needs more than ~4 tool round-trips gets a "stopped after 8 iterations" message instead of a real answer.
- No conversation memory across CLI invocations — each `python -m toolserver.client.agent "..."` call starts a fresh conversation with no history.
- No streaming — each loop iteration is a blocking `messages.create` call; a slow tool or long generation blocks the whole turn.
- Tests fully mock the Anthropic client and the MCP `Client` (by design — no real API calls in the test suite). That means schema drift in either SDK wouldn't be caught by `pytest` alone; the live demo runs above are the only real-API verification, and they're manual, not part of CI.
## Setup & run
Requires Python 3.12, an `ANTHROPIC_API_KEY`, and (for `search_documents`/`list_documents`) a docmind checkout with its corpus already ingested.
```bash
git clone https://github.com/roshano3o3/mcp-toolserver.git
cd mcp-toolserver
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
cp .env.example .env # edit .env and set ANTHROPIC_API_KEY
```
By default `DOCMIND_CHROMA_PATH` in `.env.example` points at a sibling `docmind` checkout's `data/chroma`. Point it at wherever your docmind corpus actually lives, or ignore it — `query_database`, `calculate`, and `list_documents`' error path all work with no docmind checkout present at all.
Run the agent directly (it spawns the MCP server itself as a subprocess — no separate server process to start):
```bash
python -m toolserver.client.agent "How many employees are in the Engineering department?"
```
Or run the MCP server standalone, e.g. to point another MCP client at it:
```bash
python -m toolserver.server # stdio (default)
python -m toolserver.server --transport streamable-http # http://127.0.0.1:8765/mcp by default
```
Tests:
```bash
pytest
ruff check .
```
## Verified against the installed SDK (not written from memory)
`mcp==2.0.0` is a significant departure from the older `mcp.server.fastmcp.FastMCP` API — that module doesn't exist in this version. Everything below was confirmed by reading the installed package's source and running live smoke tests against it (in-process and real stdio subprocess), not recalled from training data:
- **Server:** `from mcp.server.mcpserver import MCPServer` — `MCPServer("name")`, tools registered with `@server.tool()` (parens required; `@server.tool` without them raises on purpose). `server.run(transport="stdio" | "sse" | "streamable-http")`.
- **Client:** `from mcp.client import Client` — the new unified client, replacing direct `ClientSession` use for most cases. Accepts an in-process `Server`/`MCPServer`, a URL string, or a `Transport` (e.g. `stdio_client(StdioServerParameters(...))`).
- **Discovery:** `await client.list_tools()` → `ListToolsResult`, each tool carrying `name`, `description`, `input_schema` — the same field name Anthropic's tool-use format expects, so the client-side conversion is a near-direct mapping, not a schema translator.
- **Tool results:** `CallToolResult` carries both `.content` (list of MCP content blocks, always populated) and `.structured_content` (a typed `{"result": ...}` dict, populated when the tool function has a return type annotation — true for all four tools here).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues