local-model
by alkrcaaa
README.md
# local-model-mcp
MCP (Model Context Protocol) server that lets Claude Code (or any MCP client)
delegate coding tasks to a local model through a coding-agent CLI — pointed at
whatever OpenAI-compatible endpoint you run: a local vLLM instance, Ollama, or
any other self-hosted server. Ships configured for the
[Qwen Code CLI](https://github.com/QwenLM/qwen-code) by default (`WORKER_CLI_BIN=qwen`),
but the model behind `MODEL_BASE_URL` — and the CLI binary itself — are both
configurable.
## Architecture
```
┌─────────────────────────┐
│ Claude Code / Claude │
│ (MCP Client) │
└────────────┬────────────┘
│ MCP Protocol
▼
┌─────────────────────────┐
│ local-model-mcp │
│ (this project) │
│ │
│ - execute_task │
│ - health_check │
│ - execute_task_with_ │
│ context │
└────────────┬────────────┘
│ subprocess + env vars
▼
┌─────────────────────────┐
│ Worker CLI │
│ (WORKER_CLI_BIN, │
│ default: qwen) │
│ │
│ http://YOUR_SERVER: │
│ 8002/v1 │
└─────────────────────────┘
```
## Installation
### Prerequisites
- Python 3.9+
- The worker CLI in `PATH` (Qwen Code CLI by default)
- An OpenAI-compatible model server reachable at some `http://HOST:PORT/v1`
### Install
```bash
./install.sh
```
This will:
1. Copy `.env.example` to `.env` (edit it to point at your server)
2. Create a Python virtual environment and install dependencies
3. Register the server as `local-model` in `~/.claude/claude.json` if present
4. If `WORKER_CLI_BIN` is the default (`qwen`), patch `~/.qwen/settings.json` with the write/exec permissions the CLI needs to run unattended
### Configure
Edit `.env`:
```bash
MODEL_BASE_URL=http://your-server:8002/v1
MODEL_NAME=/models/your-model # optional — auto-detected from the server if unset
WORKER_CLI_BIN=qwen # optional — set to a different coding-agent CLI if not using Qwen Code
```
Verify:
```bash
.venv/bin/python check_env.py
```
### Register manually (if `install.sh` didn't find `claude.json`)
Add to `.mcp.json` or `~/.claude/claude.json`:
```json
{
"mcpServers": {
"local-model": {
"command": "local-model-mcp/.venv/bin/python",
"args": ["local-model-mcp/server.py"]
}
}
}
```
`.env` in this directory supplies `MODEL_BASE_URL`/`MODEL_NAME`/`WORKER_CLI_BIN` if you don't set them in the client's own `env` block — either works, the client's `env` block wins.
## Tools
### health_check()
Check if the model server is online.
**Returns:**
```json
{
"status": "online" | "offline",
"models": ["model1", "model2"],
"url": "http://YOUR_SERVER:8002/v1",
"error": "optional error message"
}
```
**Usage:**
```python
result = await client.call_tool("health_check", {})
if result["status"] == "online":
# Safe to proceed with execute_task
```
---
### execute_task(task, working_dir, files?)
Execute a task on the model.
**Args:**
- `task` (str): Detailed task description
- `working_dir` (str): Absolute path to working directory (must exist)
- `files` (list[str], optional): File paths to mention in context
**Returns:**
```json
{
"status": "success" | "error" | "timeout",
"return_code": 0,
"stdout": "task output",
"stderr": "error output if any",
"files_changed": ["src/file.py", "tests/test.py"],
"warning": "null or warning string if the model responded but made no file changes"
}
```
**Return fields:**
- `files_changed` — Git-based diff of files modified or created (snapshots dirty state before and after execution). Requires a git repo; returns empty list for non-git projects.
- `warning` — Non-null when the run succeeded but `files_changed` is empty on a previously clean repo. Usually the task description was too vague. Rephrase with explicit file paths and concrete instructions.
**Example:**
```python
result = await client.call_tool("execute_task", {
"task": "Write unit tests for add() function in src/utils.py",
"working_dir": "/Users/you/workspace/myproject",
"files": ["src/utils.py"]
})
```
---
### execute_task_with_context(task, working_dir, context_files)
Execute task with file contents prepended to the prompt.
**Args:**
- `task` (str): Task description
- `working_dir` (str): Absolute path to project root
- `context_files` (list[str]): File paths to read and include as context
**Returns:**
Same as `execute_task`
**Example:**
```python
result = await client.call_tool("execute_task_with_context", {
"task": "Add error handling to the request function",
"working_dir": "/Users/you/workspace/api",
"context_files": [
"/Users/you/workspace/api/src/client.py",
"/Users/you/workspace/api/src/errors.py"
]
})
```
---
## Usage from Claude Code
In your prompt when invoking tools:
```markdown
AVAILABLE MCP:
- local-model (health_check, execute_task, execute_task_with_context)
WORKFLOW:
1. Call health_check() → verify the model server is online
2. IF online → call execute_task() with full task details
3. IF offline → implement yourself, notify user
```
### Best Practices
1. **Always health check first:**
```
status = health_check()
if status["status"] != "online":
# Fall back to manual implementation
```
2. **Use absolute paths:**
- `working_dir`: Must be absolute, must exist
- `context_files`: Should be absolute for clarity
3. **Include full context in task string:**
- The model has no memory of previous calls
- Explicitly list file names, requirements, constraints
4. **Parse output carefully:**
- `files_changed` uses git snapshots (before vs after)
- Read full `stdout` for detailed results
5. **For multi-file tasks:**
- Use `execute_task_with_context` to pass existing code
- Or include file paths in task string with full instructions
## Prompt Engineering
### Sandwich Instruction Pattern
Every `execute_task` call automatically wraps the task with a PREFIX and SUFFIX to combat "description instead of code" failures. The model is instructed to write complete, runnable code — not explanations, partial snippets, or markdown-wrapped output.
The prompt structure is:
```
PREFIX + [project conventions] + task + SUFFIX
```
**PREFIX:**
```
[INSTRUCTION: You are a code-writing worker.
Write complete, runnable code only.
No descriptions or explanations instead of code.
No markdown fences around output.
Write the full file content, not partial snippets.]
```
**SUFFIX:**
```
[REMINDER: Output only complete, runnable code.
No descriptions. No markdown fences. Full file content.]
```
This "sandwich" approach combats the "lost in the middle" problem on long prompts — the model sees code-writing instructions both before and after the task.
### Automatic Project Conventions Injection
If the working directory contains `qwen-memory/project-conventions.md`, its contents are automatically injected between the PREFIX and the task. This directory name is kept as-is (not generalized along with the rest of this project) so existing per-project convention files keep working without a silent migration. This lets projects define conventions once and have them applied to every call without repeating them each time.
```bash
mkdir -p /path/to/project/qwen-memory
cat > /path/to/project/qwen-memory/project-conventions.md << 'EOF'
- Use Python type hints on all function signatures
- Write docstrings using Google style
- Run black and ruff before committing
- Tests live in tests/ and use pytest
EOF
```
The final prompt structure when conventions exist:
```
PREFIX
PROJECT CONVENTIONS:
(contents of qwen-memory/project-conventions.md)
task
SUFFIX
```
## Troubleshooting
### "<worker CLI> CLI not found"
```bash
which qwen # or whatever WORKER_CLI_BIN points to
# If not in PATH, add to ~/.zshrc or ~/.bashrc:
export PATH="/path/to/qwen/bin:$PATH"
```
### "Cannot connect to http://YOUR_SERVER:8002/v1"
```bash
curl http://YOUR_SERVER:8002/v1/models
# If not running, start your model server first.
```
### Task timeout (>300s)
- Reduce task scope
- Break into smaller tasks
- Increase `EXECUTION_TIMEOUT` in `server.py` if needed
### Files not detected in output
- `files_changed` uses git snapshots (before vs after the run)
- If the repo was already dirty beforehand, pre-existing changes are not counted
- Non-git projects return an empty list
- Always read full `stdout` for file paths
## Development
```bash
.venv/bin/python -m pytest tests/ # run tests
.venv/bin/python server.py # start server manually
DEBUG=true .venv/bin/python server.py # enable MCP debug logging
```
## Environment Variables
Set via `.env` (see `.env.example`) or exported directly:
```bash
MODEL_BASE_URL=http://custom-host:8002/v1
MODEL_NAME=/models/custom-model
WORKER_CLI_BIN=qwen
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues