Skip to main content
Glama
kelvincushman

local-fusion MCP server

README.md
# local-fusion

[![CI](https://github.com/kelvincushman/local-fusion/actions/workflows/ci.yml/badge.svg)](https://github.com/kelvincushman/local-fusion/actions/workflows/ci.yml)

> Run an [OpenRouter **Fusion**](https://openrouter.ai/docs/guides/routing/routers/fusion-router)-style multi-model council **locally** — no OpenRouter, no hosted router, no billing middleman.

`local-fusion` sends one prompt to a panel of models, has a **judge** model compare their answers into structured analysis, then has a **synthesizer** model write the final answer. It runs against any OpenAI-compatible server (Ollama, LM Studio, llama.cpp, vLLM, MLX, LocalAI, …) **and/or** against the subscription models you have configured in [Pi](https://github.com/badlogic/pi) (ChatGPT, Claude, GLM/ZAI, Kimi) — without you holding any API keys.

It ships four runtimes from one small, dependency-free codebase:

| Runtime | Command | What it is |
|---|---|---|
| **One-shot council** | `ask` | Panel → judge → synthesizer, returns a final answer (+ optional full trace). |
| **OpenAI-compatible server** | `serve` | Exposes the council at `POST /v1/chat/completions` so any OpenAI client can call it. |
| **Looped PI Fusion** | `looped` | A *prompt driver*: four role-views + a conductor read run state and emit the next agent prompt. |
| **MCP server** | `mcp` | Exposes the council and a build/check loop as tools to Claude Code / any MCP client. |

There is a **Node.js** implementation (`src/`, zero dependencies) and a parallel **Python** implementation (`local_fusion/`) with matching core behavior.

---

## Table of contents

- [Why](#why)
- [How it works](#how-it-works)
- [Requirements](#requirements)
- [Install](#install)
- [Quick start](#quick-start)
- [Configuration reference](#configuration-reference)
- [Backends: local OpenAI vs Pi subscriptions](#backends-local-openai-vs-pi-subscriptions)
- [Commands](#commands)
  - [`ask` — one-shot council](#ask--one-shot-council)
  - [`serve` — OpenAI-compatible endpoint](#serve--openai-compatible-endpoint)
  - [`looped` — Looped PI Fusion](#looped--looped-pi-fusion)
  - [`mcp` — MCP server for Claude Code](#mcp--mcp-server-for-claude-code)
- [Using inside a Pi agent](#using-inside-a-pi-agent)
- [Python implementation](#python-implementation)
- [Output schema](#output-schema)
- [Project layout](#project-layout)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Limitations](#limitations)
- [License](#license)

---

## Why

OpenRouter's Fusion router has five useful ideas. `local-fusion` recreates all of them with no hosted dependency:

1. A **panel** of configurable models answers the same prompt.
2. Panel calls run **in parallel** when the runtimes can handle it.
3. A **judge** *compares* the answers instead of naively merging them.
4. The judge returns **structured analysis**: consensus, contradictions, partial coverage, unique insights, blind spots, notes.
5. A **synthesizer** writes the final answer from the raw responses **plus** the judge analysis.

The payoff is diversity plus a strict judge. Best results come from genuinely different model families; fusing one model under different "perspective" system prompts still helps, but that is mostly extra test-time compute rather than true diversity.

What it deliberately does **not** do: OpenRouter, web search, web fetch, hosted routing, or your-own-API-key billing.

## How it works

```
                    ┌──────────────────────────── panel (parallel or sequential) ───────────────────────────┐
   prompt  ───────► │  model A (perspective 1)     model B (perspective 2)     model C (perspective 3)  ...   │
                    └───────────────────────────────────────────────┬──────────────────────────────────────┘
                                                                     │ raw independent answers
                                                                     ▼
                                                           ┌──────── judge ────────┐
                                                           │ strict JSON analysis:  │
                                                           │  consensus / contradic │
                                                           │  partial / unique /    │
                                                           │  blind spots / notes   │
                                                           └───────────┬───────────┘
                                                                       │ analysis + raw answers
                                                                       ▼
                                                        ┌──────── synthesizer ────────┐
                                                        │  one final answer, resolving │
                                                        │  contradictions explicitly   │
                                                        └───────────┬─────────────────┘
                                                                    ▼
                                              final_answer  (+ full trace via --json / structuredContent)
```

The harness is **degradation-tolerant**:

- If **some** panel models fail, it proceeds with the survivors and records `degradation_reasons`.
- If **all** panel models fail, it returns `status: "error"` with an empty answer.
- If the **judge** returns non-JSON, it falls back to a heuristic analysis and notes the degradation.
- If the **synthesizer** fails, it returns the first panel answer rather than nothing.

## Requirements

- **Node.js ≥ 20** (the Node runtime is dependency-free — nothing to `npm install`).
- **Python 3.10+** — only if you use the Python implementation.
- At least one model source:
  - One or more **local OpenAI-compatible servers** exposing `/v1/chat/completions`, **and/or**
  - **[Pi](https://github.com/badlogic/pi)** installed and authenticated, to use subscription/OAuth models.

Known-compatible local servers:

| Server | Typical base URL |
|---|---|
| Ollama | `http://localhost:11434/v1` |
| LM Studio | `http://localhost:1234/v1` |
| llama.cpp server | `http://localhost:8080/v1` |
| vLLM / MLX / LocalAI | per their docs |

## Install

```sh
git clone https://github.com/kelvincushman/local-fusion.git
cd local-fusion

# Node runtime needs nothing installed. Verify it runs:
node src/cli.mjs --help

# Optional: link the `local-fusion` bin onto your PATH
npm link            # then: local-fusion ask "..."
```

## Quick start

### A) Local models (Ollama example)

```sh
ollama pull qwen2.5-coder:14b
ollama serve

cp config.example.json local-fusion.config.json   # then edit baseUrl/model to match your servers
node src/cli.mjs ask --config local-fusion.config.json "Compare ridge, lasso, and elastic-net regression. Where does each shine?"
```

> ⚠️ The committed `local-fusion.config.json` is configured for the **Pi backend** (subscription models). For pure local servers, base your config on `config.example.json` instead.

### B) Subscription models via Pi (no API keys of your own)

```sh
# Pi owns auth; log in once to the providers you want
pi /login

# The shipped config runs GLM-5.2 + GPT-5.4 + Kimi K2 this way
node src/cli.mjs ask "Design a local-first AI writing assistant architecture"
```

Print the full trace as JSON:

```sh
node src/cli.mjs ask --json "Design a local-first AI writing assistant architecture"
```

Pipe a prompt from stdin:

```sh
pbpaste | node src/cli.mjs ask --json
```

## Configuration reference

Config is a single JSON file (default `local-fusion.config.json`, override with `--config`). Paths are resolved relative to your current working directory.

### Top-level options

| Key | Type | Default | Meaning |
|---|---|---|---|
| `parallel` | boolean | `true` | Run panel calls concurrently. Set `false` if your machine can't hold several models at once. **All `pi`-backend calls are serialized through one subprocess regardless.** |
| `timeoutMs` | number | — | Per-call timeout in milliseconds. |
| `panel` | array | — | Model configs for the independent first-pass answers (≥ 1 required). |
| `judge` | object | — | Model config for the structured comparison JSON (required). |
| `synthesizer` | object | falls back to `judge` | Model config for final synthesis. |
| `model_roster` / `role_model_policy` | objects | — | **Only for `looped`** — see [Looped PI Fusion](#looped--looped-pi-fusion). |

### Per-model config

| Key | Applies to | Meaning |
|---|---|---|
| `name` | all | Readable name shown in the trace. |
| `backend` | all | `"openai"` (default) POSTs to `baseUrl`; `"pi"` drives a headless Pi subprocess. |
| `baseUrl` | `openai` | OpenAI-compatible base URL ending in `/v1`. |
| `apiKey` | `openai` | Dummy value is fine for local servers that ignore auth. |
| `apiKeyEnv` | `openai` | Name of an env var to read the key from instead of inlining it. |
| `model` | `openai` | Model name the server expects. |
| `provider` / `modelId` | `pi` | Pi provider + model id (e.g. `zai`/`glm-5.2`, `openai-codex`/`gpt-5.4`, `anthropic`/`claude-opus-4-8`, `kimi-coding`/`kimi-k2-thinking`). Discover with `pi --list-models`. |
| `temperature` | `openai` | Sampling temperature. (Ignored for `pi`.) |
| `maxTokens` | `openai` | Max response tokens. (Ignored for `pi`.) |
| `system` | all | Role/perspective system prompt — this is what shapes each panel member. |

### Example: pure local servers

See [`config.example.json`](config.example.json) — three local panel members (coder / generalist / skeptic), a strict judge, and a synthesizer, all pointed at Ollama / LM Studio / llama.cpp.

### Example: Pi subscription council

See [`local-fusion.config.json`](local-fusion.config.json) — GLM-5.2 (generalist) + GPT-5.4 (builder) + Kimi K2 (critic) panel, Kimi K2 judge, GPT-5.4 synthesizer.

## Backends: local OpenAI vs Pi subscriptions

`local-fusion` mixes two backends freely — even within a single panel.

**`backend: "openai"` (default).** A plain HTTP POST to `baseUrl/chat/completions`. Use for Ollama, LM Studio, llama.cpp, vLLM, MLX, LocalAI, or any OpenAI-compatible endpoint.

**`backend: "pi"`.** Spawns a headless `pi` subprocess and uses **Pi's stored auth** (`~/.pi/agent/auth.json`), including OAuth subscriptions (ChatGPT Plus/Pro, Claude Pro/Max) and ZAI/Kimi. `local-fusion` holds **no API keys of its own**. Use `provider` + `modelId` instead of `baseUrl`/`apiKey`. All `pi` calls funnel through one subprocess, so they are serialized even when `parallel: true`.

> **💳 Subscription billing note.** With `backend: "pi"`, Anthropic subscription access (Claude Pro/Max) through a third-party harness is billed per-token from your **extra usage** budget, not your plan limits. If Opus errors with `400 ... Add more at claude.ai/settings/usage`, add budget there. GLM-5.2 (free) and GPT-5.4 (ChatGPT subscription) are not affected.

> **🔒 Kimi Code / Kimi K2.** The `kimi-coding` provider is gated to approved coding-agent clients — a direct `backend: "openai"` call returns `access_terminated_error`. It works **only** through the `pi` backend (which presents the approved client identity). Auth it with `pi /login` → *Kimi For Coding*.

## Commands

All commands share these options where relevant:

```
--config <path>   Config file. Default: local-fusion.config.json
--json            (ask) print the full Fusion JSON result
--host <host>     (serve) default 127.0.0.1
--port <port>     (serve) default 8787
--rootDir <path>  (mcp) file-backed run state. Default: ./runs/mcp
--runDir <path>   (looped) artifact trail directory
--step <n>        (looped) current loop step, default 1
--runId <id>      (looped) override the generated run id
```

### `ask` — one-shot council

```sh
node src/cli.mjs ask "Your question or instruction"
node src/cli.mjs ask --json "Your question"           # full trace
node src/cli.mjs ask --config my.config.json "..."    # alternate config
echo "prompt from stdin" | node src/cli.mjs ask --json
```

Without `--json` it prints just `final_answer` (and any degradation notes to stderr). With `--json` it prints the [full result object](#output-schema).

### `serve` — OpenAI-compatible endpoint

```sh
node src/cli.mjs serve --port 8787
```

Then call it like any OpenAI chat endpoint:

```sh
curl http://127.0.0.1:8787/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{
    "model": "local/fusion",
    "messages": [
      { "role": "user", "content": "What are the strongest arguments for and against carbon taxes?" }
    ]
  }'
```

- The assistant answer is at `choices[0].message.content`.
- The full Fusion trace is attached at the top-level `local_fusion` field of the response.
- Health check: `GET /health` → `{ "ok": true }`.

> The `serve` endpoint is **prose-only** — it returns a synthesized natural-language answer. It is not intended to back a tool-calling agent; for that, use the [MCP server](#mcp--mcp-server-for-claude-code).

### `looped` — Looped PI Fusion

A **prompt driver** layer over an agent loop (implements [`docs/prd-pi-agent-fusion-loop.md`](docs/prd-pi-agent-fusion-loop.md)). It does **not** execute shell, edit files, or run tests — per run it:

1. Resolves model assignments for four view-roles + a conductor from a roster.
2. Dispatches **Explorer / Builder / Critic / Performance Sentinel** views (each a role-specialized council call).
3. The **Loop Conductor** reads the views + a run-state snapshot and emits a machine-parseable decision JSON.
4. Writes an artifact trail under `<runDir>/artifacts/pi-fusion/`.
5. Returns `{ decision, prompt, summary }` for the caller to inject as the next agent prompt.

```sh
node src/cli.mjs looped "Implement and test feature X" \
  --config looped-fusion.config.json \
  --runDir ./runs/last \
  --step 1 \
  --heartbeat fresh
```

This command requires `model_roster` **and** `role_model_policy` in the config — see [`looped-fusion.config.json`](looped-fusion.config.json) and [`docs/using-looped-fusion.md`](docs/using-looped-fusion.md).

| Role | Job |
|---|---|
| Explorer | Map what's actually true: files, symbols, facts vs assumptions, unknowns. |
| Builder | Identify the smallest safe implementation step and its risks. |
| Critic | Challenge the direction: hidden assumptions, failure modes, missing tests. |
| Performance Sentinel | Read loop health (heartbeat, elapsed vs expected, no-progress) and recommend a verdict. |
| Loop Conductor | Synthesize the views + run state into the next prompt (or `complete`). |

### `mcp` — MCP server for Claude Code

Lets **Claude Code / Opus stay the executor** while `local-fusion` becomes the checking + council layer it calls as tools. The flow is pull-based (MCP tools are client-initiated): Claude Code calls a tool, gets the next instruction, executes, reports evidence, and asks again.

**Start the server:**

```sh
node src/cli.mjs mcp --config local-fusion.config.json --rootDir ./runs/mcp
```

The server speaks the **MCP stdio transport (newline-delimited JSON)** that Claude Code uses; legacy LSP-style `Content-Length` framing is also accepted (auto-detected from the client's first bytes).

**Register with Claude Code** (user scope = available in every project):

```sh
claude mcp add local-fusion --scope user -- \
  node /absolute/path/to/local-fusion/src/cli.mjs mcp \
  --config /absolute/path/to/local-fusion/local-fusion.config.json \
  --rootDir /absolute/path/to/local-fusion/runs/mcp

claude mcp get local-fusion          # expect: ✔ Connected
```

Use `--scope project` instead to write a shared `.mcp.json` at the repo root (Claude Code requires explicit approval for project-scoped servers before first use).

**Tools exposed:**

| Tool | Purpose |
|---|---|
| `fusion_ask` | One-shot council on a question; returns final answer + disagreement trace. |
| `looped_start` | Start a build/check loop; freezes objective + acceptance criteria. |
| `looped_report` | Claude Code reports what changed, tests run, blockers, assumptions, evidence. |
| `looped_check_work` | Checker gate: decides `done` / `incomplete` / `blocked` / `uncertain`. |
| `looped_fuse_review` | Conditional full council on the frozen evidence (use only when uncertain/high-risk). |
| `looped_next` | Conductor returns `complete` or one bounded next prompt. |
| `looped_status` | Read current run state and artifact path. |

The default routing keeps the expensive path optional:

```
Opus executes → looped_report → checker gate
   ├─ done + high confidence  → looped_next returns complete
   ├─ incomplete + concrete fix → direct retry prompt
   ├─ uncertain / high-risk    → fusion council → conductor prompt
   └─ blocked                  → pause for human
```

Run artifacts are written under `runs/mcp/<run_id>/` (`state.json`, `report-N.json`, `check-N.json`, `fusion-review-N.json`, `next-N.json`). `runs/` is gitignored. Full details and the recommended Claude Code priming prompt are in [`docs/using-mcp-connector.md`](docs/using-mcp-connector.md).

## Using inside a Pi agent

`local-fusion` can run as a tool a [Pi](https://github.com/badlogic/pi) agent consults for a multi-model second opinion. The Pi skill and project `AGENTS.md` are already set up — launch `pi` from this repo and ask it to *"get a local-fusion council opinion on X"*. See [`docs/using-with-pi.md`](docs/using-with-pi.md).

## Python implementation

A parallel implementation lives in `local_fusion/` with matching core fusion + looped behavior (it does **not** serve MCP — that is Node-only).

```sh
python3 -m local_fusion ask "Compare ridge, lasso, and elastic-net regression."
python3 -m local_fusion ask --json "Design a local-first AI writing assistant architecture"
pbpaste | python3 -m local_fusion ask --json
python3 -m local_fusion serve --port 8787
```

## Output schema

`ask --json` and the MCP `fusion_ask` tool return:

```jsonc
{
  "status": "ok",                       // or "error" when all panel models fail
  "final_answer": "…synthesized answer…",
  "analysis": {
    "consensus": ["points most models agreed on"],
    "contradictions": [
      { "topic": "…", "stances": [{ "model": "…", "stance": "…" }] }
    ],
    "partial_coverage": [{ "models": ["…"], "point": "…" }],
    "unique_insights": [{ "model": "…", "insight": "…" }],
    "blind_spots": ["important missing topics"],
    "judge_notes": ["guidance for the synthesizer"]
  },
  "responses": [{ "model": "…", "content": "raw panel answer" }],
  "failed_models": [{ "model": "…", "error": "…" }],
  "degradation_reasons": ["Some panel models failed.", "…"],
  "raw_judge_output": "…optional raw judge text…"
}
```

When the judge returns non-JSON, `analysis` is a heuristic fallback and `degradation_reasons` explains why.

## Project layout

```
local-fusion/
├─ src/                        # Node implementation (zero dependencies)
│  ├─ cli.mjs                  # entrypoint: ask | looped | serve | mcp
│  ├─ fusion.mjs               # panel → judge → synthesizer core
│  ├─ looped.mjs               # Looped PI Fusion prompt driver
│  ├─ mcp.mjs                  # MCP stdio server (ndjson + Content-Length)
│  ├─ mcp-connector.mjs        # build/check loop logic behind the MCP tools
│  ├─ openai-compatible.mjs    # backend: "openai" transport
│  ├─ pi-transport.mjs         # backend: "pi" transport (headless subprocess)
│  └─ config.mjs               # JSON config loader
├─ local_fusion/              # Python implementation (core parity, no MCP)
├─ test/                       # Node tests (node --test)
├─ tests/                      # Python tests (unittest)
├─ docs/                       # PRD + usage guides + blog posts
├─ .pi/                        # Pi skill + extension for in-agent use
├─ config.example.json         # local-server starter config
├─ local-fusion.config.json    # Pi-subscription council config (default)
└─ looped-fusion.config.json   # roster + role policy for `looped`
```

## Testing

```sh
# Node (23 tests)
node --test

# Python (15 tests)
python3 -m unittest discover -s tests
```

The Node suite covers the fusion core, the looped driver, the MCP connector routing, **and the MCP wire transport** — including framing detection and a full newline-delimited `initialize`/`tools/list` handshake, so the Claude Code transport can't silently regress.

## Troubleshooting

| Symptom | Likely cause / fix |
|---|---|
| `claude mcp get` shows **✘ Failed to connect** | Confirm the `node … mcp` command runs standalone and the paths are absolute. The server now speaks newline-delimited JSON; older builds only spoke `Content-Length`. |
| MCP server shows **⏸ Pending approval** | Project-scoped servers need approval — run `claude` and approve, or register with `--scope user`. |
| `fusion_ask` / `looped_check_work` return errors with the Pi backend | Pi must be logged in (`pi /login`). The MCP transport being healthy is separate from the models behind it. |
| Opus errors `400 … Add more at claude.ai/settings/usage` | Subscription extra-usage budget exhausted — add budget. |
| `access_terminated_error` on Kimi | Don't call Kimi via `backend: "openai"`; use `backend: "pi"`. |
| `degradation_reasons` populated | Some panel models / the judge failed; check the listed reasons and `failed_models`. |
| `looped needs model_roster and role_model_policy` | Point `--config` at `looped-fusion.config.json` (or a config with those keys). |

## Limitations

- **MCP serving is Node-only.** Python has core fusion/looped parity but does not serve MCP stdio.
- **MCP is pull-based.** Claude Code must call the tools; `local-fusion` cannot push into an already-running conversation.
- **`pi`-backend calls are serialized** through one subprocess even with `parallel: true`.
- **GLM quota** can degrade the council — check `degradation_reasons` in `fusion_ask` / `looped_fuse_review` output.

## Contributing

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, the test
commands, and the PR flow. `main` is protected: all CI checks (Node 20/22, Python 3.10/3.12)
must pass before merge.

## License

[MIT](LICENSE) © Kelvin Cushman

Maintenance

ActivityStale
ResponsivenessNo issues