local-mcp-postgres
README.md
# π Operation LOCAL-MCP
A fully local Postgres-backed MCP (Model Context Protocol) server, connected two ways:
first to **Claude Desktop** (which speaks MCP natively), then to a **fully local Ollama
model** via a hand-built bridge β since Ollama has no native MCP client. Both paths sit
behind a FastAPI + Streamlit chat console.
Built as a diagnostic exercise as much as a build exercise: the core question was
*"where exactly does local-model tool-calling reliability break down?"*, and this
project isolates that question with a small, controlled two-tool schema.
## What it does
Ask questions in plain English about a local inventory/orders database:
- "What's in the inventory?"
- "What's John's total order amount?"
- "How many Widget B do we have?"
The model decides which tool to call, the tool runs a safe parameterized query
against Postgres, and the result gets turned back into a natural-language answer.
## Architecture
```
βββββββββββββββ stdio (MCP) ββββββββββββββββββββ
βClaude DesktopββββββββββββββββββββββΊβ β
βββββββββββββββ β server.py ββββΊ Postgres
β (FastMCP) β (Docker)
βββββββββββββββ hand-built bridge β β
β Ollama ββββββββββββββββββββββΊβ β
β (qwen2.5:7b) β (bridge.py) ββββββββββββββββββββ
ββββββββ¬ββββββββ
β
βΌ
βββββββββββββββ HTTP βββββββββββββββ
β FastAPI βββββββββββΊβ Streamlit β
β (main.py) β β (app.py) β
βββββββββββββββ βββββββββββββββ
```
`server.py` is the single source of truth for the tools β both Claude Desktop and
the Ollama bridge talk to the exact same FastMCP server. This was deliberate: it
turns "does Claude Desktop succeed where Ollama fails?" into a clean A/B test,
since the only variable that changes is the model, not the tool definitions.
## Key finding
The starting hypothesis (from prior local-LLM tooling work) was that 7Bβ12B
models are broadly unreliable at emitting well-formed tool calls. That held for
dense, high-overhead schemas (e.g. Claude Code's tool surface), but **did not
hold here**: `qwen2.5:7b`, given this project's small two-tool schema, correctly
selected the right tool on every test β including inferring a sensible default
parameter (`min_quantity: 1`) that wasn't stated in the prompt β and never once
emitted malformed tool-call JSON.
Takeaway: the earlier reliability problem looks like it's driven more by
**schema size / context overhead** than by a hard local-model capability
ceiling. Worth revisiting the `claude-code-router` / hosted-API tradeoff with
that in mind β schema simplification may solve more of the problem than
switching providers does.
## Setup
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
docker compose up -d # Postgres, seeded via init.sql
ollama pull qwen2.5:7b # or llama3.1:8b β confirm `tools` under
ollama show qwen2.5:7b # Capabilities before relying on it
```
## Running it
**Path 1 β Claude Desktop (MCP native):**
Add an entry to `claude_desktop_config.json` pointing at `src/server.py` using
your venv's Python (see repo history / commit log for the exact config that
worked after a couple of path-resolution false starts β see Gotchas below).
**Path 2 β Local Ollama console:**
```bash
# terminal 1
uvicorn src.main:app --reload
# terminal 2
streamlit run src/app.py
```
Then open the Streamlit URL and ask questions in the chat box.
## Gotchas hit along the way (kept for the next person, including future me)
- **`python -m src.server` works from a terminal but not from Claude Desktop's
launched subprocess** β its `cwd` handling doesn't reliably put the project
root on Python's module search path for `-m` resolution. Fix: point
`claude_desktop_config.json` directly at the script file
(`args: ["/abs/path/src/server.py"]`) instead of using `-m`.
- **`tool.inputSchema` vs `tool.input_schema`** β the wire-protocol field name
and the installed `mcp` SDK's Python attribute name don't match; the SDK
uses `snake_case`. Worth checking installed SDK attribute names directly
rather than assuming they mirror the protocol spec.
- **`init.sql` only runs against Postgres on a *fresh* Docker volume.** If
you've already got a `pgdata` volume from a prior run, editing `init.sql`
and re-running `docker compose up` silently does nothing β you need
`docker compose down -v` first to force re-seeding.
- **Model function-call arguments arrive as either a dict or a JSON string**
depending on the model/quantization β `bridge.py`'s loop handles both.
## Known limitations
- **No multi-turn memory.** Each `/chat` request (and each `run_tool_call_loop`
call) starts a fresh conversation with no history from prior turns. The
Streamlit UI *displays* prior turns for readability, but doesn't send them
back to the backend β a deliberate scope cut for this iteration, not an
oversight.
- Each request respawns the MCP server subprocess rather than keeping one
long-lived session β simpler and correct for this scale, but adds latency
you'd want to remove for anything beyond a local demo.
- Read-only by design: both tools run fixed, parameterized `SELECT`s. No
write/update tools exist, intentionally, to keep the safety surface small.
## Stack
Python 3.12 Β· FastMCP Β· MCP SDK Β· Ollama (`qwen2.5:7b`) Β· PostgreSQL (Docker) Β·
FastAPI Β· Streamlit Β· psycopg2
## Repo layout
```
local-mcp-postgres/
βββ README.md
βββ MISSION_PLAN.md # phase-by-phase build log / checklist
βββ docker-compose.yml
βββ init.sql
βββ requirements.txt
βββ src/
βββ server.py # FastMCP server: fetch_inventory, fetch_order_total
βββ bridge.py # Ollama-MCP bridge + tool-call loop
βββ main.py # FastAPI backend
βββ app.py # Streamlit console
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues