workbench MCP server
# MCP-powered agent
A tool server built from scratch on the **Model Context Protocol**, and a **LangGraph** agent that
consumes it as an MCP client — plus **Claude Desktop** consuming the *same server*, unchanged.
That last part is the point. The server exposes GitHub, SQL and calendar tools over MCP and knows
nothing about LangChain, LangGraph, or any agent framework. Two completely different clients drive
it without a line of server code changing. That is what MCP is *for*.
MCP is the protocol Anthropic published and is standardising tool access around; OpenAI, Google
DeepMind and Microsoft have since adopted it. It is, in effect, the USB-C port for LLM tooling.
```
┌──────────────────────────┐ ┌──────────────────────────┐
│ LangGraph agent │ │ Claude Desktop │
│ (hand-built StateGraph) │ │ (off-the-shelf client) │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
│ MCP over stdio ─── or ─── streamable HTTP │
└───────────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────┐
│ workbench MCP server (FastMCP) │
│ │
│ Tools 12 github / db / cal │
│ Resources 3 db://schema, … │
│ Prompts 3 triage_issues, … │
└─────────────────────────────────────┘
│ │ │
GitHub REST SQLite SQLite
(real API) (read-only) (calendar)
```
---
## Why MCP, and not just LangChain tools?
The fair question an interviewer will ask. Defining `@tool` functions inside the agent is less code.
What you give up:
| | LangChain tools in-process | Tools behind MCP |
|---|---|---|
| **Reuse** | Bound to your agent | Any MCP client — Claude Desktop, Cursor, your agent |
| **Language** | Must be Python | Server can be any language |
| **Isolation** | Shares your process, deps and crashes | Separate process, own dependency tree |
| **Deployment** | Ships with the agent | Deployable and versioned independently |
| **Lock-in** | Rewrite if you leave LangGraph | Swap the agent framework, keep the tools |
The cost is a serialisation boundary and a handshake. Worth it as soon as more than one thing needs
the tools — which, in practice, is immediately.
---
## Quickstart
```bash
uv sync
cp .env.example .env # optional, see below
uv run mcp-agent
```
`uv sync` pins Python 3.12 and installs everything. The SQLite database is created and seeded on
first run — there is no setup step.
**Credentials are optional.** With no `.env` at all:
- the database and calendar tools are fully functional (they are local),
- the GitHub tools return clearly-labelled fixtures (`"mode": "fixture"`) rather than pretending,
- only the chat loop needs a key, since it needs a model.
To chat you need one of:
```bash
LLM_PROVIDER=anthropic ANTHROPIC_API_KEY=sk-ant-...
LLM_PROVIDER=openai OPENAI_API_KEY=sk-...
LLM_PROVIDER=ollama # no key; needs Ollama running locally
```
For live GitHub, add a fine-grained PAT with **Issues: read+write** and **Metadata: read**:
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_DEFAULT_REPO=your-name/scratch-repo
```
> `create_issue` and `comment_on_issue` post for real. The server refuses to guess a repository —
> `GITHUB_DEFAULT_REPO` must be set explicitly, and the agent is instructed to confirm before writing.
---
## Watch the protocol itself
Before any agent is involved, you can talk to the server by hand:
```bash
uv run python scripts/inspect_server.py
```
A ~40-line client — subprocess, newline-delimited JSON-RPC, no SDK — prints every frame in both
directions:
```
client --> server
{ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": { "name": "run_query", "arguments": {
"sql": "SELECT c.name, COUNT(*) AS open_tickets FROM tickets t ..." } } }
server --> client
{ "jsonrpc": "2.0", "id": 4, "result": {
"structuredContent": { "ok": true,
"rows": [ { "name": "Lumen Retail", "open_tickets": 3 }, ... ] },
"isError": false } }
```
The whole protocol is five messages: `initialize` → `notifications/initialized` → `tools/list` →
`tools/call`, plus `resources/list` and `prompts/list`. Everything else built on MCP is this
conversation with more ceremony.
Or use the official GUI:
```bash
uv run mcp-server --transport http --port 8765
npx @modelcontextprotocol/inspector # connect to http://127.0.0.1:8765/mcp
```
---
## What the server exposes
All three MCP primitives, not just Tools — most implementations stop at Tools.
### Tools (12)
| Domain | Tool | Notes |
|---|---|---|
| **database** | `list_tables`, `describe_schema` | Column types, foreign keys, row counts |
| | `run_query` | **SELECT only**, enforced by SQLite itself — see below |
| **calendar** | `list_events`, `create_event` | Refuses to double-book unless `allow_conflict=true` |
| | `find_free_slot`, `delete_event` | Working-hours aware, skips weekends and past times |
| **github** | `list_issues`, `get_issue`, `search_issues` | Real REST API; PRs filtered out |
| | `create_issue`, `comment_on_issue` | Real writes, gated on an explicit repo |
### Resources (3) — context you can pull *without* a tool call
`db://schema` · `calendar://today` · `calendar://day/{day}` (templated)
A Tool is a verb the model chooses to invoke; a Resource is a noun the client can attach up front.
Exposing the schema as a resource means the model often skips `describe_schema` entirely.
### Prompts (3) — workflows shipped by the server
`triage_issues` · `plan_my_week` · `customer_health_check`
The server knows what its tools can do, so it ships the workflows rather than making every client
reinvent them. In Claude Desktop these appear as slash commands; in the CLI, `/prompt <name> k=v`.
---
## The agent
A ReAct loop built by hand, because `create_react_agent` hides the only part worth understanding:
```
START ──▶ agent ──has tool calls?──▶ tools ──┐
▲ │
└────────────────────────────────┘
│
└──no──▶ END
```
```python
builder = StateGraph(AgentState)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route, {"tools": "tools", END: END})
builder.add_edge("tools", "agent") # tool results always go back to the model
```
Three decisions worth pointing at:
- **`AgentState` extends `MessagesState` with a `steps` counter**, and `route` stops the graph at a
cap. A model that only ever emits tool calls is the classic runaway; `recursion_limit` is a blunt
backstop, this is an explicit one.
- **The server's own `instructions` drive the agent.** The MCP handshake returns an `instructions`
field, and it is folded into the system prompt. The server describes how its tools should be
used — passing that through is most of what makes the agent behave.
- **One session for the whole conversation.** `MultiServerMCPClient.get_tools()` without a live
session reconnects per tool call, which on stdio means respawning the server every time.
Run the one-call equivalent side by side with `uv run mcp-agent --prebuilt`.
---
## The CLI
```bash
uv run mcp-agent # stdio
uv run mcp-agent --transport http # against a running HTTP server
uv run mcp-agent --provider ollama # override for one run
```
Every tool call is traced live. The trace format below is exactly what the CLI prints, and the tool
results are the real values this seed data returns — the assistant's wording is illustrative:
```
you which customer has the most open tickets, and when am I free tomorrow?
→ run_query(sql=SELECT c.name, COUNT(*) AS open_tickets FROM tickets t JOIN …)
✓ {"columns": ["name", "open_tickets"], "rows": [{"name": "Lumen Retail", "open_tickets": 3}, …
→ find_free_slot(duration_minutes=30, after=2026-09-15)
✓ {"slots": [{"start": "2026-09-15T09:00:00", "end": "2026-09-15T09:30:00"}, …
Lumen Retail has the most, with 3 open tickets — one urgent, one high, one
normal. Tomorrow you are free at 09:00, 09:45, 11:30 and 16:30.
```
`/tools` `/prompts` `/prompt <name> k=v` `/resources` `/resource <uri>` `/graph` `/new` `/help`
---
## The same server in Claude Desktop
```bash
uv run python scripts/make_desktop_config.py # print the JSON
uv run python scripts/make_desktop_config.py --write # merge it in (backs up first)
```
```json
{
"mcpServers": {
"workbench": {
"command": "/absolute/path/to/uv",
"args": ["--directory", "/absolute/path/to/MCP-powered-agent", "run", "mcp-server"]
}
}
}
```
Restart Claude Desktop and the same 12 tools appear. Two gotchas the generator handles: Claude
Desktop launches the server from an **arbitrary working directory** (so the project path is passed
explicitly, and `.env` is resolved relative to the package, not the cwd), and it does **not reliably
inherit your `PATH`** (so `uv` is resolved to an absolute path).
---
## Design decisions
**SQL is read-only, and SQLite enforces it.** Not a regex blocklist — the connection is opened
`mode=ro` *and* fitted with a SQLite authorizer that permits only `SELECT`/`READ`/`FUNCTION`, plus a
VM-step cap so a runaway join fails instead of hanging. `DROP`, `DELETE`, `UPDATE`, `INSERT`,
`CREATE`, `ALTER`, `PRAGMA`, `ATTACH` and stacked statements are each blocked, each with a test.
**Tools fail structurally, not with stack traces.** Every tool returns `{ok, error, hint}`:
```json
{ "ok": false,
"error": "Query rejected: not authorized",
"hint": "This database is read-only. Only SELECT is permitted … Check column names with describe_schema." }
```
A traceback tells the model nothing it can act on. A hint tells it how to retry, which is the
difference between an agent that recovers and one that gives up.
**GitHub payloads are trimmed before they reach the model.** A raw issue is ~40 fields; `_slim_issue`
keeps nine and caps the body. Dumping raw API JSON into context is the easiest way to burn a token
budget.
**No OAuth, no Docker, no cloud project.** The calendar is real scheduling logic over SQLite rather
than the Google Calendar API, so a stranger can clone this and have it working in one command.
---
## Tests
```bash
uv run pytest # 110 tests, ~20s
uv run ruff check .
```
No API key and no network required. The end-to-end agent tests run a **real MCP server subprocess**
with **real tool execution** and only the model faked, covering handshake → `tools/list` → LangChain
adaptation → graph routing → `tools/call`.
Two things these caught that are easy to get wrong:
- `"messages"` stream mode emits chunks from *every* node, the `ToolNode` included — so tool JSON
gets printed as the assistant's answer unless you filter on `langgraph_node`.
- MCP sessions are bound to the task that opened them (anyio cancel scopes), so a pytest-asyncio
generator fixture blows up on teardown. Sessions open inside the test body.
---
## Layout
```
src/mcp_agent/
server/ no LangChain imports anywhere in here
app.py FastMCP app, tool/resource/prompt registration
__main__.py --transport stdio | http
db.py SQLite engine + the read-only authorizer
tools/ github.py database.py calendar.py
resources.py prompts.py
agent/
graph.py hand-built StateGraph (+ prebuilt for comparison)
llm.py anthropic | openai | ollama factory
mcp_client.py MultiServerMCPClient -> LangChain tools
cli.py Rich chat with the live tool trace
scripts/
inspect_server.py raw JSON-RPC, no SDK
make_desktop_config.py Claude Desktop wiring
```
Built with `mcp` 1.30, `langgraph` 1.2, `langchain-mcp-adapters` 0.3, Python 3.12, uv.
TDQS
Scored across 12 tools
The tools form three clear clusters—database, calendar, and GitHub issues—and within each cluster every tool has a distinct resource and action. Even similar-sounding tools like list_events vs find_free_slot and list_issues vs search_issues are clearly separated by their descriptions.
All tool names follow a consistent lowercase snake_case verb_noun pattern, such as list_tables, create_event, delete_event, and get_issue. The singular/plural distinction (list_issues vs get_issue) is also applied predictably.
Twelve tools is well within the ideal range, and each tool serves a distinct, justified purpose across the three subdomains. There is no apparent bloat or redundancy.
The database read-only surface is complete, and calendar/issue creation and reading are covered, but there are notable gaps: no event update/reschedule tool and no issue update/close tool. Agents can work around some gaps but cannot fully manage issue state or reschedule events cleanly.