writeonce/docs/examples/mcp-think/server.py
shoney.arickathil 49872a4b11 docs: six-bucket status board; recover lost vision doc; stop ignoring docs/
- 00-kanban.md rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus,
  `woc build`) then six buckets — stories, in progress, done, pending,
  discarded, learnings. It tracked only the Rust runtime before, so the whole
  OOP track (wovm shipped, woc Tasks 1-8 shipped) was invisible.
- Board now records iteration 3's known gaps instead of silently owing them:
  `?T` plumbed but unenforced; E205/E201/E203/E204 dead, so structural
  interface satisfaction is unchecked.
- New discarded.md — settled rejections with reasons so they are not
  re-proposed: inheritance, `abstract` newtypes, Money/SKU/Float, Dynamic/cast/
  macro/extern, AOT-to-C, Menhir, shared engine state, external deployer.
- RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk,
  never committed (gitignored path), cited by five docs incl. principle 12.
- Root cause was broader: all seven forward-roadmap plans in
  docs/superpowers/plans/ were untracked and ignored, on one disk only. Rules
  were half-fiction — 33 of 34 exploration files were already tracked, so they
  swallowed only *new* files.
- Dropped the docs ignore rules (exploration, oop-vm, superpowers/plans,
  examples/agent-loop, examples/mcp-think) with a do-not-re-add comment; added
  __pycache__/*.pyc. `tests/` stays ignored but warns that the next plan lands
  the corpus there.
2026-08-10 21:52:59 +02:00

147 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""mcp-think — offload thinking to a LOCAL model, exposed to Claude over MCP.
A minimal MCP server (stdio transport) whose tools run entirely on your
machine: each tool sends a prompt to a local model served by Ollama and
returns the answer. Nothing in the tool inputs ever leaves the box.
Why offload to a local model from Claude?
* privacy — summarize/critique content that must not leave the machine
* cost — bulk work (summaries, drafts, idea generation) at zero token cost
* diversity — a second opinion from a different model family
* bandwidth — Claude stays on the main task while the local model grinds
Run (Claude Code):
claude mcp add think -- uv run --with mcp python3 /abs/path/to/server.py
Configuration (environment):
OLLAMA_URL base URL of the Ollama daemon (default http://localhost:11434)
THINK_MODEL model to use (default qwen3:4b)
THINK_TIMEOUT per-call timeout in seconds (default 300)
Dependencies: the `mcp` package only (`uv run --with mcp` provisions it).
The Ollama call uses the Python standard library — no SDK, no httpx.
"""
import json
import os
import re
import urllib.error
import urllib.request
# The SDK is renaming FastMCP → MCPServer; support both so the example works
# with the released `mcp` package today and with the renamed one later.
try:
from mcp.server.mcpserver import MCPServer as Server # unreleased SDK head
except ImportError:
from mcp.server.fastmcp import FastMCP as Server # mcp <= 1.x (PyPI)
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
THINK_MODEL = os.environ.get("THINK_MODEL", "qwen3:4b")
THINK_TIMEOUT = int(os.environ.get("THINK_TIMEOUT", "300"))
mcp = Server("think")
def ask_local_model(system: str, prompt: str) -> str:
"""One chat round against the local model. Errors come back as text so
the calling model can read them and tell the user what to fix."""
body = json.dumps({
"model": THINK_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"stream": False,
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/api/chat",
data=body,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=THINK_TIMEOUT) as resp:
data = json.load(resp)
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:300]
return (f"[mcp-think] the local model rejected the request "
f"(HTTP {e.code}): {detail}\n"
f"Is the model pulled? Try: ollama pull {THINK_MODEL}")
except (urllib.error.URLError, TimeoutError, OSError) as e:
return (f"[mcp-think] cannot reach the local model at {OLLAMA_URL} ({e}).\n"
f"Start it with `ollama serve`, pull the model with "
f"`ollama pull {THINK_MODEL}`, then retry.")
text = (data.get("message") or {}).get("content", "")
# Reasoning models may inline their chain of thought as <think>…</think>;
# strip it — the caller wants the conclusion, not the scratchpad.
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
return text or "[mcp-think] the local model returned an empty response"
@mcp.tool()
def think(task: str, context: str = "") -> str:
"""Reason through a problem on the local model and return its conclusion.
Call this to get an independent, private, zero-cost analysis of a
question — weighing a design trade-off, sanity-checking a plan, working
through logic — especially when a second opinion from a different model
family is valuable. `context` carries any background the task needs
(code, notes, constraints); it never leaves this machine.
"""
system = ("You are a careful reasoning assistant. Think the problem "
"through step by step, then answer with your conclusion first, "
"followed by the key reasoning in a few short paragraphs.")
prompt = f"{task}\n\n--- context ---\n{context}" if context else task
return ask_local_model(system, prompt)
@mcp.tool()
def critique(work: str, focus: str = "") -> str:
"""Adversarially review a piece of work (code, prose, a plan, a design)
on the local model and return the strongest objections.
Call this before committing to a decision or shipping a draft, when an
independent devil's advocate is useful, or when the material is private
and must be reviewed without leaving the machine. `focus` optionally
narrows the review (e.g. "error handling", "argument structure").
"""
system = ("You are a rigorous, adversarial reviewer. Find the strongest "
"objections: errors, gaps, risks, unstated assumptions. Rank "
"them most-severe first. Be specific — point at the exact "
"part you object to and say why. Do not pad with praise.")
prompt = f"Review the following{f', focusing on {focus}' if focus else ''}:\n\n{work}"
return ask_local_model(system, prompt)
@mcp.tool()
def brainstorm(topic: str, n: int = 5) -> str:
"""Generate `n` distinct ideas on a topic using the local model.
Call this to widen the option space cheaply before converging — naming,
approaches, test cases, failure modes, feature ideas. The local model's
different training makes its ideas usefully different from yours.
"""
system = ("You are a prolific idea generator. Produce genuinely distinct "
"ideas — different mechanisms, not rephrasings. One line of "
"pitch plus one line of how it would work, per idea.")
return ask_local_model(system, f"Generate {n} distinct ideas for: {topic}")
@mcp.tool()
def summarize(text: str, max_words: int = 200) -> str:
"""Summarize text on the local model — the content never leaves this
machine and costs no API tokens.
Call this to condense large material (logs, documents, transcripts,
diffs) before reasoning about it, or when the content is sensitive and
must stay local. The summary comes back; the original stays here.
"""
system = (f"Summarize faithfully in at most {max_words} words. Keep "
"concrete facts, numbers, names, and conclusions; drop filler. "
"Note anything surprising or anomalous explicitly.")
return ask_local_model(system, text)
if __name__ == "__main__":
mcp.run() # stdio — Claude launches this as a subprocess