Skip to main content

House of Wisdom MCP — multi-model consultation council. Diverse AI model families investigate independently and return their own perspectives; the orchestrator weighs them. Inspired by the medieval Bayt al-Hikma.

Project description

House of Wisdom MCP

What this is — an MCP (Model Context Protocol) server that asks the same question to several different AI model families at once and hands you back every answer, unmerged.

How to read itWhat it isQuick startThe three modesThe two toolsHow a call flowsInstallationConfiguration. Go to Sharp edges when something surprises you.

Requires — Python 3.10+, uv/uvx, and at least two enabled models.

Reflects code as of — 2026-07-25, master, package version 0.9.3.

The medieval Bayt al-Hikma worked because it was diverse: scholars, translators, and copyists from many traditions read the same questions through different lenses, and the reader weighed the results. This server does the same with models — OpenAI, Anthropic and Google via OpenRouter, DeepSeek, local Ollama, or any OpenAI-compatible endpoint.


What it is, and what it is not

It does It does not
Fire N models in parallel on one question Merge, rank, vote, or summarize their answers
Return one complete, self-contained analysis per model Return a single "council answer"
Optionally let each model read your codebase first (read-only) Ever write, execute, or network beyond each model's own endpoint
Tag each answer with the mode it was asked to run in Tell you which answer is correct

There is no synthesizer. The caller — your IDE agent — reads every perspective and decides. Treating any single perspective as ground truth defeats the design.

When it earns its cost. A call spends several model API calls and tens of seconds, so it pays off when a different model family seeing the problem would plausibly change the outcome: a contested design decision, a bug you have been circling without converging, a high-stakes or hard-to-reverse conclusion, or an explicit request for a second opinion. Work that just needs one mind's focused reasoning does not need a council.


Quick start

# 1. Get a config file
curl -O https://raw.githubusercontent.com/EzzoHamdan/house-of-wisdom-mcp/master/config.example.yaml

# 2. Edit it — the only field you MUST set is `models`, and at least 2 must be enabled.

# 3. Register the server with your MCP client (see Installation for per-client syntax):
#    command: uvx
#    args:    --from house-of-wisdom-mcp
#             ai-council --config /absolute/path/to/config.yaml

# 4. Restart the client, then ask your agent to call `list_models`.

If list_models returns your roster, the server is loaded. Then try one consult call in scribe mode — it is the fast path and needs no filesystem access.


The three modes

One argument, mode, decides how much freedom each consultant gets. It is the only knob that changes behavior at call time.

Mode Tools Tool budget Scope discipline Use when
scribe none You already pasted the relevant code into context, or the question is a judgment call needing no lookup
translator read-only max_tool_iterations (built-in 8) scope_hint is a cage — the prompt forbids wandering You know which files matter and want each model to verify against them
scholar read-only scholar_max_tool_iterations (built-in 64) scope_hint is a starting point — the prompt permits following leads You do not know which files matter

With default budgets, wall-clock ordering runs scribe < translator < scholar; absolute numbers depend entirely on which models you configured and whether they are local or remote. Nothing enforces that ordering, though — raising max_tool_iterations above scholar_max_tool_iterations inverts it.

Since v0.8.0 the depths between the presets are reachable per call: tool_budget lowers the resolved mode's round budget and timeout lowers the wall-clock deadline, both clamped to the configured values — see the consult arguments. A caller can never raise either above what the config allows.

How the effective mode is resolved

Precedence, highest first (synthesis.py::collect_perspectives):

# Source Result
1 mode argument, if it is one of scribe / translator / scholar (case-insensitive) that mode
2 mode argument set to anything else logs a warning, falls through to 3
3 legacy agentic argument falsescribe, truetranslator
4 synthesizer_tools.enabled in config truetranslator, falsescribe

agentic is a deprecated boolean kept so older callers keep working; mode wins whenever both are passed. scholar can only be reached by asking for it explicitly.

synthesizer_tools.enabled only picks the default in row 4. It is not a gate: an explicit mode: "translator" or mode: "scholar" runs the tool loop even when enabled: false.


The two tools

Renamed in v0.5.0. ai_councilconsult and ai_council_list_modelslist_models. The old names still work — they are accepted as backward-compatible aliases, so clients or scripts registered before the rename keep functioning. Only the new names are advertised, so your agent will discover and use consult / list_models going forward.

How your agent discovers them

At initialize the server sends a block of instructions (main.py::SERVER_INSTRUCTIONS) that MCP clients surface as always-in-context text. This matters because many clients load tool descriptions lazily — behind a search, or not at all when the tool list is long — so an orchestrator may see nothing but the bare names consult and list_models, neither of which says what this server is. The instructions block is the only text guaranteed to arrive, so it stands on its own: what the server does, which tool to call, and the concrete situations worth calling it in.

Keep it short if you edit it — it costs context on every turn of every conversation.

list_models

Takes no arguments. Returns the configured roster straight from loaded config.

{
  "status": "success",
  "data": {
    "models": [
      {"name": "GLM", "model_id": "glm-5.2:cloud", "provider": "custom", "enabled": true},
      {"name": "Kimi", "model_id": "kimi-k2.7-code:cloud", "provider": "custom", "enabled": true}
    ],
    "max_models": 3,
    "enabled_count": 2
  }
}

It does not contact any endpoint and does not validate API keys. enabled: true means "present in config and not switched off", nothing more. Note that models lists every configured entry; when a consult call names no subset, only the first max_models enabled ones fire, but any enabled model can be requested by name — see Who actually fires.

consult

Argument Type Required Meaning
question string yes The only required argument. Must be non-empty, max 10,000 characters.
context string no Background, max 200,000 characters. Optional since v0.7.0. In scribe mode this is the only material the models see, so paste file contents here; in translator/scholar a sentence or two is plenty, because consultants read files themselves.
mode string no scribe | translator | scholar. See resolution order.
workspace_root string no Absolute path used as the read-only sandbox root. Ignored in scribe. Must be an existing directory — a bad path fails the call with INVALID_INPUT (v0.7.1), it no longer degrades to a silent no-tools run. Falls back to synthesizer_tools.workspace_root, then to the server process's current working directory — which is set by your MCP client, not by you, and is refused when it is a home directory or filesystem root. Pass it explicitly.
scope_hint string no Free text injected into each consultant's system prompt, e.g. "Start with main.py and config.py". Ignored in scribe.
models array of strings no Subset of consultant name values to fire. Resolves against all enabled models, so any enabled model is reachable regardless of max_models or its position in the YAML. Unknown names are dropped silently; if none survive, the call fails with NO_MATCHING_MODELS. Omit to fire the default window (first max_models enabled).
agentic boolean no Deprecated alias: falsescribe, truetranslator. Overridden by mode.
tool_budget integer ≥ 1 no v0.8.0. Max tool-call rounds per consultant for this call — the depth dial between the mode presets (scholar + tool_budget: 20 sits between translator's 8 and scholar's 64). Clamped to the resolved mode's configured budget, never above it. Ignored in scribe. The effective (clamped) value is reported in each perspective's tool_rounds_budget.
timeout integer ≥ 5 no v0.8.0. Wall-clock seconds for this call, applied per consultant and to the whole batch — the same double application as parallel_timeout, which it overrides downward. Clamped to the configured parallel_timeout, never above it — it makes a quick check cheap; it cannot extend a deep run.

Success shape. One entry per model that was dispatched, in roster order:

{
  "status": "success",
  "data": {
    "perspectives": [
      {
        "label": "GLM", "model_name": "GLM", "code_name": "Alpha",
        "analysis": "...", "status": "ok", "mode": "translator",
        "telemetry": {
          "duration_s": 31.4, "tokens_in": 3000, "tokens_out": 220, "api_calls": 2,
          "cost_usd": 0.002284, "tool_rounds_used": 1, "tool_rounds_budget": 4,
          "files_read": ["src/math.py"], "paths_listed": [], "tool_calls": {"read_file": 1}
        }
      },
      {
        "label": "Kimi", "model_name": "Kimi", "code_name": "Beta",
        "analysis": "...", "status": "ok", "mode": "translator",
        "telemetry": {
          "duration_s": 12.0, "tokens_in": 800, "tokens_out": 60, "api_calls": 1,
          "cost_usd": null, "tool_rounds_used": 0, "tool_rounds_budget": 4,
          "files_read": [], "paths_listed": [], "tool_calls": {}
        }
      }
    ],
    "consensus": {
      "models_queried": 2, "models_succeeded": 2, "models_failed": 0,
      "wall_clock_s": 31.4, "total_tokens_in": 3800, "total_tokens_out": 280,
      "total_cost_usd": 0.002284
    }
  }
}
  • Failed consultants are returned in-band with status: "error" and the error text sitting in analysis. The call as a whole still reports "status": "success" as long as at least one consultant succeeded.
  • label equals model_name. code_name (Alpha/Beta/…) is also always present as a short handle.
  • consensus counts nothing about agreement — despite the name, it is a dispatch tally, and models_failed is simply the number of status: "error" entries.
  • wall_clock_s is elapsed time, not the sum of the per-consultant durations: consultants run in parallel, so summing them would report a number nobody actually waited.

telemetry — what each perspective cost, and what it rests on

Added in v0.7.0. It is measurement, never judgment: the server still merges, ranks, and votes on nothing. It just stops you from having to take each analysis on faith.

Field Meaning
files_read Files this consultant actually opened, in first-touch order, deduplicated. Only successful reads — a miss or a sandbox rejection is not evidence.
paths_listed Directories it listed.
tool_calls Per-tool call counts. Measures effort, so failed calls count here even though they contribute no files_read.
tool_rounds_used / tool_rounds_budget Rounds spent against the budget it was given. Equal values mean it was cut off, and its answer may be partial.
tokens_in / tokens_out / api_calls Summed across every completion, including retry nudges and forced-final turns. Taken from the provider's own usage block; providers that omit it contribute zero.
cost_usd Computed from the model's configured rates. null when unpriced.
duration_s Wall-clock for this one consultant, reported even when it errored or timed out — a failed call still cost you something.
finish_reason v0.9.1. The provider's own stop reason for the completion whose text became the answer (stop, length, …). null when the provider reported none.
truncated v0.9.1. Derived from finish_reason: true means the answer was cut off at the output-token cap and may be incomplete. Tri-statenull means the provider never said, which is "unknown", not "no".

Read files_read first. It is the difference between a perspective grounded in your codebase and one that merely sounds grounded, and it changes what disagreement means:

GLM   read src/auth.py   → "the token refresh is racy"
Kimi  read nothing       → "looks fine to me"

That is not a two-way split to be weighed evenly. In scribe mode every consultant reports empty files_read by design — the mode has no file access at all, so the field carries no signal there.

Progress notifications. If your MCP client sends a progressToken, the server emits one notification per consultant as it finishes (3/3 consultants), so a 60-second scholar run stops looking like a hang. Clients that don't ask for progress get none, and the council behaves identically either way.

Error shape.

{"status": "error", "error": {"code": "...", "message": "...", "type": "...", "details": "..."}, "data": null}
code Fires when
INVALID_INPUT question is empty, question/context is over its character cap, tool_budget/timeout is not an integer within its floor (1 / 5), or a translator/scholar call has no usable sandbox root (nonexistent path, or an implicit cwd fallback landing on a home/filesystem root)
NO_MATCHING_MODELS A models array was passed and matched no enabled model
NOT_ENOUGH_MODELS_ENABLED The fireable roster is empty (startup validation normally prevents this)
ALL_MODELS_FAILED Every consultant errored or the whole batch timed out
UNKNOWN_TOOL Tool name is neither consult nor list_models
INTERNAL_ERROR Unhandled exception; details carries the Python error string

data is null on every error except ALL_MODELS_FAILED, which carries {"attempted_models": N, "failed_responses": N}. Note that no individual analyses survive that path — if you need partial results from a slow batch, raise parallel_timeout rather than retrying.


How a call flows

MCP client (the orchestrator)
  │ consult(context, question, mode?, workspace_root?, scope_hint?, models?)
  ▼
main.py::_process_ai_council
  ├─ validate      context 1..200,000 chars · question 1..10,000 chars   → INVALID_INPUT
  └─ roster        `models` passed?  yes → keep FULL enabled list entries whose name matches
                                          nothing left?  → NO_MATCHING_MODELS
                                     no  → default window = enabled[:max_models]
  ▼
synthesis.py::collect_perspectives
  ├─ mode          mode arg > agentic bool > synthesizer_tools.enabled
  ├─ sandbox       workspace_root arg > config workspace_root > process cwd
  │                must be an existing dir · implicit cwd refused when home or
  │                filesystem root · bad root → INVALID_INPUT (v0.7.1)
  ├─ budget        scholar → scholar_max_tool_iterations · else max_tool_iterations
  │                per-call tool_budget lowers it (min of the two), never raises
  └─ deadline      parallel_timeout · per-call timeout lowers it, never raises
  │
  ├── scribe ───────────► models.py::call_models_parallel
  │                        one chat call per model · temp 0.7 · max_tokens 8000
  │                        semaphore = max_concurrent_consultants (same as agentic)
  │
  └── translator ───────► models.py::call_models_parallel_agentic
      scholar              semaphore = max_concurrent_consultants
                           per consultant, repeat until it answers or budget runs out:
                             chat(tools=[read_file,list_dir,glob_search,content_search,think])
                               ├─ tool_calls? → dispatch in sandbox → append results → loop
                               └─ plain text? → that is the analysis
                           budget exhausted → one forced "answer from what you have" turn
                           temp 0.4 · max_tokens 16000
  ▼
perspectives[]   one per dispatched model, roster order, failures included
  ▼
MCP client weighs them.  No synthesizer runs, in any mode.

How a call flows (rendered)

%%{init: {'themeVariables': {'lineColor': '#8b949e'}}}%%
flowchart TD
    C([MCP client]) -->|consult| V{{"validate<br/>context ≤200k · question ≤10k"}}
    V -->|invalid| E1["INVALID_INPUT"]
    V --> S{{"models arg passed?"}}
    S -->|"yes: match against ALL enabled"| R1["roster = named enabled models"]
    S -->|"no: default window"| R2["roster = first max_models enabled"]
    R1 -->|none match| E2["NO_MATCHING_MODELS"]
    R1 --> M{{"resolve mode"}}
    R2 --> M
    M -->|scribe| P["call_models_parallel<br/>no tools · semaphore-capped"]
    M -->|translator| A["call_models_parallel_agentic<br/>budget = max_tool_iterations"]
    M -->|scholar| A2["call_models_parallel_agentic<br/>budget = scholar_max_tool_iterations"]
    A --> L
    A2 --> L
    L{{"tool loop, per consultant<br/>capped by max_concurrent_consultants"}}
    L -->|tool_calls| T[("sandbox: read_file · list_dir<br/>glob_search · content_search · think")]
    T --> L
    L -->|plain text| O["one perspective per model<br/>errors included in-band"]
    P --> O
    O --> C

    classDef gate stroke-dasharray:4 3;
    class V,S,M,L gate;

Installation

Prerequisites

Need Why
Python 3.10+ requires-python = ">=3.10"
uv / uvx How the server is launched. Verify with uvx --version.
Ollama on localhost:11434 Only for local models. Pull tags first (ollama pull glm-5.2:cloud), confirm with ollama list.
Provider API keys Only for paid models (OpenAI, OpenRouter, DeepSeek, …).

Step 1 — write a config file

Start from config.example.yaml, which is fully commented:

curl -O https://raw.githubusercontent.com/EzzoHamdan/house-of-wisdom-mcp/master/config.example.yaml
mkdir -p ~/.config/ai-council && mv config.example.yaml ~/.config/ai-council/config.yaml

~/.config/ai-council/config.yaml is the path the server checks when --config is omitted. Any other location works if you pass --config /absolute/path.yaml.

If the path you pass to --config does not exist, the server refuses to start with Configuration error: Config file not found: <path> (v0.7.1 — it was previously ignored silently, booting on built-in OpenRouter defaults instead). There is still no fallback to ~/.config/ai-council/config.yaml when --config is passed; that path is only probed when the flag is omitted entirely.

Step 2 — register the server with your MCP client

The launch command is identical everywhere; only the surrounding JSON/TOML differs.

command:  uvx
args:     --from  house-of-wisdom-mcp
          ai-council
          --config  /absolute/path/to/config.yaml

Why --from is not optional. The published package is house-of-wisdom-mcp; the console script it installs is ai-council. Because the two names differ, uvx cannot infer the second from the first — a bare uvx house-of-wisdom-mcp fails with "An executable named house-of-wisdom-mcp is not provided by package house-of-wisdom-mcp". Both tokens must stay.

Pinning, and tracking master. uvx resolves to the newest release each launch. Pin with --from house-of-wisdom-mcp==0.9.3 to freeze it. To run unreleased code instead, swap the package name for git+https://github.com/EzzoHamdan/house-of-wisdom-mcp@master — every other token is unchanged.

The server key is the display name. The key you give the entry under mcpServers (House-of-Wisdom below) is what your IDE shows as the tool prefix, e.g. House-of-Wisdom [consult]. Rename it to whatever you like — it is purely cosmetic and lives in your client config, not in this repo. The ai-council token inside args is a different thing (the installed console-script name) and must stay as-is.

Claude Desktop — Settings → Developer → Edit Config, which opens ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):

{
  "mcpServers": {
    "House-of-Wisdom": {
      "command": "uvx",
      "args": ["--from", "house-of-wisdom-mcp",
               "ai-council", "--config", "/absolute/path/to/config.yaml"]
    }
  }
}

Cursor — same JSON, in .cursor/mcp.json (project) or via Settings → MCP (global).

Kilo Code — same JSON, in the file opened by the MCP Servers panel (Edit Global MCPmcp_settings.json, or Edit Project MCP.kilocode/mcp.json). Kilo kills a server that is slow to answer, so raise its per-server timeout if you use scholar mode:

{
  "mcpServers": {
    "House-of-Wisdom": {
      "command": "uvx",
      "args": ["--from", "house-of-wisdom-mcp",
               "ai-council", "--config", "/absolute/path/to/config.yaml"],
      "timeout": 240
    }
  }
}

Claude Code (CLI) — the -- separator matters, otherwise claude parses --from as its own flag:

claude mcp add House-of-Wisdom -- uvx --from house-of-wisdom-mcp \
  ai-council --config /absolute/path/to/config.yaml

Codex CLI~/.codex/config.toml:

[mcp_servers."House-of-Wisdom"]
command = "uvx"
args = ["--from", "house-of-wisdom-mcp",
        "ai-council", "--config", "/absolute/path/to/config.yaml"]

Anything else — any client that speaks stdio MCP can run this server. Only the registration syntax changes.

Step 3 — restart the client

Config is read once at process start. Every config edit or server upgrade needs a client restart.

Step 4 — verify

  1. Call list_models → your roster comes back.
  2. Call consult with mode: "scribe", a short context, and a short question → each model answers. This isolates model connectivity from filesystem/sandbox concerns.
  3. Only then try translator with an explicit workspace_root.

Configuration reference

Every key, with both defaults

"Built-in" is what you get when the key is absent from your YAML. "Example file" is what config.example.yaml ships with — these differ, so do not read the example file as documentation of defaults.

Key Built-in Example file Valid range
max_models 3 3 1–10
parallel_timeout 60 240 5–600 (seconds)
log_level INFO INFO DEBUG INFO WARNING ERROR CRITICAL
log_format text text text json
max_concurrent_consultants 3 3 1–32
openai_api_key unset commented out string
openrouter_api_key unset commented out string
synthesizer_tools.enabled false true boolean
synthesizer_tools.workspace_root null → process cwd (refused when home / fs root) null absolute path to an existing directory
synthesizer_tools.max_tool_iterations 8 12 1–128
synthesizer_tools.scholar_max_tool_iterations 64 64 1–256
synthesizer_tools.allowed_tools all five all five subset of the five tool names — ⚠ omit the key (null) for all five; [] permits none. A config pinning an explicit pre-v0.9.0 list must add content_search by name to get it
models 3 OpenRouter models 6 enabled Ollama + 4 disabled paid 2–10 entries (10 configured max, 2 enabled min)

A minimal working config is just:

models:
  - name: "GLM"
    provider: "custom"
    model_id: "glm-5.2:cloud"
    base_url: "http://localhost:11434/v1"
    api_key: "ollama"
    enabled: true
  - name: "Kimi"
    provider: "custom"
    model_id: "kimi-k2.7-code:cloud"
    base_url: "http://localhost:11434/v1"
    api_key: "ollama"
    enabled: true

Startup validation — what stops the server from booting

These are checked at load time. Each exits with Configuration error: … on stderr — which your MCP client usually surfaces as "server failed to start" — wrapped in a Pydantic ValidationError, so the strings below appear as a fragment of a longer message rather than on their own.

Rule Message contains
At least 2 models must have enabled: true At least two models must be enabled
At most 10 models configured in total Cannot configure more than 10 models (found N)
name values must be unique Duplicate model names found in model configuration
code_name values must be unique Duplicate code names found in model configuration
Every provider: custom entry needs a base_url Custom endpoint '<name>' requires a base_url
Every provider: custom entry needs an api_key Custom endpoint '<name>' requires an api_key
provider: openai needs a key from somewhere OpenAI API key is required if using OpenAI models
provider: openrouter needs a key from somewhere OpenRouter API key is required if using OpenRouter models

The base_url requirement is checked independently of api_key (v0.5.1). Previously validation short-circuited on the presence of api_key, so a custom entry with a key but no base_url was never checked and the client fell back to OpenAI's default endpoint — silently sending prompts to api.openai.com. A base_url-less custom entry is now rejected at startup (and guarded again at client construction).

Model entry fields

Field Required Notes
name yes Human label. This is the string the models call argument matches against.
model_id yes Provider's identifier: an Ollama tag, an OpenRouter slug, an OpenAI model name.
provider no (default openrouter) openai | openrouter | custom
base_url for custom OpenAI-compatible /v1 endpoint. Not enforced when api_key is set — see the warning above.
api_key for custom, else optional Overrides the provider-level key for this entry.
enabled no (default true) false keeps the entry configured but dormant.
code_name no Auto-assigned from Alpha, Beta, Gamma, … if omitted. A short stable handle, always returned in the payload alongside model_name. ⚠ Setting it by hand on some entries while running close to the 10-model cap can crash startup — see Sharp edges.
input_cost_per_1m no USD per 1M input tokens. Purely for reporting: turns the provider's own token counts into telemetry.cost_usd.
output_cost_per_1m no USD per 1M output tokens. Same.

Leave both pricing fields unset for local or free endpoints. cost_usd is then reported as null rather than 0.0 — "not priced" and "measured as free" are different claims, and only one of them is true for an unpriced cloud model.

Consultant recipes

Local Ollama — free, api_key can be any non-empty string:

models:
  - name: "GLM"
    provider: "custom"
    model_id: "glm-5.2:cloud"          # a tag from `ollama list`
    base_url: "http://localhost:11434/v1"
    api_key: "ollama"
    enabled: true

OpenAI — one shared key at the top of the file:

openai_api_key: "sk-..."
models:
  - name: "GPT-5.6-Terra"
    provider: "openai"
    model_id: "gpt-5.6-terra"
    enabled: true

Claude / Gemini / most others via OpenRouter — one key, many families. model_id is the slug from openrouter.ai/models:

openrouter_api_key: "sk-or-..."
models:
  - name: "Claude Opus"
    provider: "openrouter"
    model_id: "anthropic/claude-opus-4"
    enabled: true
  - name: "Gemini Pro"
    provider: "openrouter"
    model_id: "google/gemini-2.5-pro"
    enabled: true

DeepSeek direct, Perplexity, Groq, Together, vLLM, LM Studio — anything OpenAI-compatible uses provider: custom with its own base_url and api_key. Use a ${ENV_VAR} placeholder to keep the secret out of the file (see Keeping keys out of the YAML):

models:
  - name: "DeepSeek-Pro"
    provider: "custom"
    model_id: "deepseek-chat"
    base_url: "https://api.deepseek.com/v1"
    api_key: "${DEEPSEEK_API_KEY}"   # resolved from the environment at load time
    enabled: true

API keys — where they are read from

Per model, first match wins:

  1. api_key on the model entry — always wins for that entry, whatever the provider.
  2. The provider-level key (openai_api_key for provider: openai, openrouter_api_key for provider: openrouter), which is itself resolved as CLI flag → YAML top-level field → environment variable.

provider: custom entries never fall back to a provider-level key; they require their own api_key.

The environment variable names are AI_COUNCIL_OPENAI_API_KEY and AI_COUNCIL_OPENROUTER_API_KEY (v0.5.1 — the fields no longer declare a Pydantic alias, so the AI_COUNCIL_ prefix applies to them like every other setting). A bare, unprefixed OPENAI_API_KEY / OPENROUTER_API_KEY in the environment your MCP client launches is not picked up — set the prefixed name, or pass the key via YAML or the CLI flags.

All settings use the prefix and match case-insensitively: AI_COUNCIL_OPENAI_API_KEY, AI_COUNCIL_OPENROUTER_API_KEY, AI_COUNCIL_MAX_MODELS, AI_COUNCIL_PARALLEL_TIMEOUT, AI_COUNCIL_LOG_LEVEL, AI_COUNCIL_LOG_FORMAT, AI_COUNCIL_MAX_CONCURRENT_CONSULTANTS.

Keeping keys out of the YAML

The config file describes structure (which models, timeouts, modes); secrets belong in the environment. Keeping config.yaml secret-free means you can hand it to an AI agent to edit, or even commit it, without leaking a key. Three ways to supply keys without touching the file:

  1. Environment variables — set the AI_COUNCIL_-prefixed names. For MCP the cleanest place is your client's env block, so the server inherits them at launch:

    "env": {
      "AI_COUNCIL_OPENAI_API_KEY": "sk-...",
      "AI_COUNCIL_OPENROUTER_API_KEY": "sk-or-..."
    }
    
  2. A .env file — the server reads one next to the config file and one in the working directory, before it parses any keys. Copy the committed .env.example to .env (it's gitignored) and fill it in; use the same AI_COUNCIL_-prefixed names:

    AI_COUNCIL_OPENAI_API_KEY=sk-...
    AI_COUNCIL_OPENROUTER_API_KEY=sk-or-...
    

    A real environment variable (e.g. from the client env block) always wins over the .env file.

  3. ${ENV_VAR} placeholders — any api_key or base_url value may reference an env var, which is expanded at load time. This is the only secret-free path for a per-model api_key on a provider: custom entry (DeepSeek, Groq, …), since those never fall back to a provider-level key:

    api_key: "${DEEPSEEK_API_KEY}"
    

    A referenced-but-unset variable fails loudly at startup, naming the missing var — it is never silently sent as an empty key.

config.yaml, *.local.yaml, and .env are gitignored, so a filled-in config can't be committed by accident.

Who actually fires

The per-call roster is built one of two ways, then bounded by concurrency:

configured models        ── enabled: true ──►  enabled models
                              │
        no `models` arg ◄─────┴─────► `models` arg passed
              │                              │
      [:max_models]                  match names against ALL enabled models
              │                              │
      default window                 this call's roster (any enabled model, any position)
              └──────────────┬───────────────┘
                             ▼
this call's roster       ── semaphore ──►  N running at once (all modes)
  • max_models (1–10) caps the default fan-out — the roster used when a call names no subset. The window is the first max_models enabled entries by position in the YAML list, not by preference.
  • The per-call models argument resolves against the full enabled list, so any enabled model is reachable by name regardless of max_models or its position. With eight enabled models and max_models: 3, asking for the seventh by name fires exactly that model. A named subset is not re-capped to max_models — the caller chose the models, and max_concurrent_consultants still bounds how many run at once.
  • max_concurrent_consultants (1–32) caps how many consultants run simultaneously; the rest queue. Match it to your provider's concurrency allowance (Ollama Cloud Pro = 3, Max = 10). It applies in all modes — scribe included, since v0.6.2 — so a large roster no longer fires every model at once.

The consultant sandbox

In translator and scholar modes each consultant gets its own read-only tool loop, rooted at workspace_root. These tools are internal to the consultant; they are never exposed to your MCP client.

Tool Signature Behavior
read_file path UTF-8 read, truncated at 200,000 bytes with a ...[truncated] marker. Undecodable bytes are replaced, not fatal.
list_dir path (default root) One entry per line, relative to root, / suffix on directories.
glob_search pattern Glob relative to root, e.g. **/*.py. Capped at 100 results.
content_search pattern, glob? v0.9.0. Regex grep over file contents; returns path:line: text hits. Bounded on every axis: 100 matches, 2,000 files scanned, 200,000 bytes read per file, 300 chars per shown line. Binary files and dot-directories (.git, caches) are skipped — dot-files stay searchable. Every candidate is re-checked against the sandbox boundary, so a symlink cannot leak outside content.
think thought Echoes the thought back. No I/O. Costs budget on the same terms as the others.

Restrict the set with synthesizer_tools.allowed_tools; a call to a tool outside the list returns an error string to the model rather than executing. ⚠ Distinguish the two "empty" cases: omitting the key (null) permits all five, while allowed_tools: [] permits none — an empty allowlist advertises no tools, so every consultant loses read access. To narrow the surface, name the tools you want, e.g. ["read_file", "think"]; to grant everything, leave the key out.

What never happens

  • No writes, no shell, no network from the tools. The five above are the entire surface (tools.py::ToolRegistry.call).
  • No read outside workspace_root. Paths are resolved with Path.resolve() and checked with relative_to(), so symlinks pointing out of the root are rejected as SandboxViolation (tools.py::ToolRegistry._resolve).
  • No tool call at all in scribe mode.
  • No endpoint contact from list_models.
  • No merging of perspectives, in any mode.

Budget accounting

The loop stops when the model replies with plain text instead of tool calls, or when the budget is spent — after which it gets one forced turn to answer from what it gathered, and a second nudge if that comes back empty.

The budget counts rounds, not individual tool calls: one unit per assistant turn that contains tool calls, however many it contains. A model that requests four files in a single turn spends one unit, not four — and a think batched alongside them is free. As of v0.6.3 the system prompt says this plainly ("you have at most N rounds; a round is one turn, batched calls cost one round"), so the prompt matches the accounting. A batching model can therefore legitimately read more of your workspace per round than a naive per-call reading of the number would suggest — the frugality guidance ("read only what matters, do not read speculatively") is what keeps reads bounded.


Operational envelope

Values are hardcoded in models.py; listed here because they are not otherwise visible from the outside.

Path Temperature max_tokens Timeout
scribe (call_model) 0.7 8,000 parallel_timeout, applied to the whole batch
translator / scholar (call_model_with_tools) 0.4 16,000 parallel_timeout, applied both per consultant and to the whole batch

A per-call timeout argument (v0.8.0) substitutes for parallel_timeout in both rows — same double application — but only downward: it is clamped to the configured value.

Empty responses get one automatic retry. Models that return their answer in a reasoning, thinking, or reasoning_content field instead of content — common with Ollama's cloud thinking models — are handled by models.py::_extract_text.

Per-provider request adaptation (v0.5.2, models.py::_create_completion): for provider: openai the output cap is sent as max_completion_tokens instead of max_tokens (newer OpenAI models reject the latter). If a model still rejects a parameter — e.g. reasoning models accept only the default temperature: 1 — that parameter is dropped and the call retried (up to three strips). custom / openrouter endpoints are sent the classic max_tokens / temperature unchanged.

One rejection needs the opposite adaptation (v0.9.1): OpenAI reasoning models refuse function tools on /v1/chat/completions unless reasoning_effort is 'none' — a parameter the server never sends, so there is nothing to strip. That specific 400 is retried once with reasoning_effort: "none", and the model is remembered so a tool loop pays the discovery request once, not once per round. The trade is explicit: on such models, translator/scholar run without extended reasoning rather than not at all; scribe (no tools) is unaffected.

⚠ Because parallel_timeout also bounds the whole batch, and because queued consultants spend their wait inside that window, a scholar run with more models than max_concurrent_consultants needs a generous value. When the batch timeout fires, only the consultants still running are cancelled and marked as timed out; perspectives that already finished are kept (v0.5.1), so a slow straggler no longer collapses a partial success into ALL_MODELS_FAILED.


Sharp edges

Known divergences between what the system looks like it does and what it does. Each is verified in code.

Detail
synthesizer_tools.enabled A default-mode switch, not a capability gate — explicit mode bypasses it. synthesis.py:126-135

Fixed in v0.9.1 — reasoning models with tools, and visible truncation. OpenAI reasoning models rejected every tool-bearing call (translator/scholar died on round one) because chat completions requires reasoning_effort: 'none' for function tools — now detected and adapted automatically, at the documented cost of running those modes without extended reasoning. And telemetry gained finish_reason / truncated, closing the gap where an answer cut off at the token cap was indistinguishable from a complete one. models.py, main.py.

Added in v0.9.0 — content search, CI, and packaging. A fifth sandbox tool, content_search, greps file contents by regex (path:line: text hits, bounded at 100 matches / 2,000 files / 200 KB per file, binary and dot-directories skipped, sandbox boundary re-checked per candidate). Locating a symbol previously meant globbing names and reading whole files — spending the round budget on enumeration. It is in the default allowed_tools; configs pinning an explicit older list must add it by name. Separately: GitHub Actions now runs the full test suite and ruff on every push/PR (Python 3.10–3.13), and a release-triggered workflow publishes to PyPI via trusted publishing. tools.py, config.py, models.py, .github/workflows/.

Added in v0.8.0 — per-call depth dials. consult gained optional tool_budget (max tool rounds per consultant) and timeout (wall-clock seconds) arguments. Both are clamped to the operator's configured ceilings — the resolved mode's round budget and parallel_timeout respectively — so a caller can dial a call down (a 20-round scholar, a 30-second sanity check) but never past what the config allows. Invalid values fail as INVALID_INPUT; the effective budget is visible in each perspective's tool_rounds_budget. main.py, synthesis.py, models.py.

Fixed in v0.7.1 — the lying edges. Two long-documented sharp edges were resolved. (1) A translator/scholar call now validates its sandbox root before any consultant fires: a nonexistent workspace_root fails the call with INVALID_INPUT instead of silently degrading to a no-tools run still tagged with the requested mode — and if the fallback path ever does fire (unexpected setup failure), the perspectives are stamped with the mode that actually ran (scribe). The implicit cwd fallback is refused when it lands on a home directory or filesystem root; passing such a path explicitly still works. (2) A typo'd --config path is now a startup error instead of a silent boot on built-in OpenRouter defaults. synthesis.py, main.py, config.py.

Added in v0.7.0 — telemetry and progress. Every perspective now carries a telemetry block: which files that consultant actually read, how many tool rounds it spent against its budget, its token counts and wall-clock, and cost_usd when the model has input_cost_per_1m / output_cost_per_1m configured (unpriced models report null, never 0.0). consensus gained wall_clock_s and batch token/cost totals. None of this merges or ranks anything — it is the evidence behind each answer, and files_read in particular distinguishes a grounded analysis from a confident one. Separately, the server now emits an MCP progress notification per consultant when the client sends a progressToken. main.py, models.py, synthesis.py, tools.py, config.py.

Changed in v0.7.0 — discoverability. The server was functionally correct long before it was reachable: models rarely called it. Six causes were fixed. (1) The server now sends instructions at initialize, so a client that lazily loads tool schemas still knows what this server is — previously it sent none, and an orchestrator could see only the bare tool names. (2) The consult description no longer opens by redirecting the caller to a different, possibly uninstalled MCP server. (3) It now leads with concrete situations worth calling it in, instead of leading with latency figures and "do NOT fire reflexively". (4) The agentic migration trivia moved out of the description into the field schema where it belongs — the blurb went from ~2,300 to ~1,250 characters. (5) context is now optional; only question is required, so the cheapest valid call is one argument. A blank context emits no header into the consultant prompt. (6) The README's own "do not fire on every prompt" framing was rewritten to say when the tool earns its cost. No behavioral change to dispatch, modes, or the sandbox.

Changed in v0.6.3. anonymous_perspectives was removed — it only rewrote the display label while the real model_name stayed in the payload, so it hid nothing from an AI orchestrator (which reads every field). Model identity is intentional signal the caller may weigh; code_name remains as a short handle. An old config that still sets the key is silently ignored. Also: the consultant tool-budget prompt now states its accounting honestly (N rounds, batched calls cost one round), a log_format: text | json option (and --log-format) adds line-delimited JSON logs, and duplicate model name values are rejected at startup.

Fixed in v0.6.2. A full-source audit resolved a batch of defects: glob_search no longer escapes the sandbox via .. patterns; SCHOLAR mode's prompt no longer contradicts itself (mode guidance sits outside the strict scope cage); the budget-exhaustion forced-final turn answers its pending tool_calls so strict endpoints no longer 400; allowed_tools: [] sends an omitted tools param instead of tools: []; read_file's truncation marker is byte-accurate and its cap is no longer overridable; the .env parser strips inline comments. Plus two reliability changes: transient API failures (429 / 5xx / connection) now retry with bounded backoff, and the max_concurrent_consultants semaphore applies in scribe mode too (it previously fired every model at once). models.py, tools.py, synthesis.py, config.py.

Fixed in v0.6.1. The per-call models argument now resolves against the full enabled list, so an explicitly named enabled model is reachable regardless of max_models or its position in the YAML. max_models again caps only the default (no-subset) fan-out, and the NO_MATCHING_MODELS detail reports the true full enabled set. config.py::get_enabled_models, main.py::_process_ai_council.

Fixed in v0.5.1. Nine long-standing sharp edges were resolved: the AI_COUNCIL_ env prefix now works (and a bare OPENAI_API_KEY is no longer silently adopted); a batch timeout keeps consultants that already finished; status is carried explicitly instead of guessed from the text; a custom model without base_url is rejected instead of talking to api.openai.com; allowed_tools: [] now permits nothing; code_name auto-assignment no longer skips names or crashes at 10 models; the advertised version tracks the package; synthesis_model_selection (dead) was removed; and the consult description no longer quotes example-config defaults.


CLI arguments

Flag Effect
--config PATH Config file. Without it, ~/.config/ai-council/config.yaml is tried.
--max-models N Overrides max_models.
--parallel-timeout N Overrides parallel_timeout, in seconds.
--log-level LEVEL DEBUG | INFO | WARNING | ERROR. (CRITICAL is valid in YAML but not accepted here.)
--log-format FMT text | json. Overrides log_format.
--openai-api-key KEY Overrides the YAML value.
--openrouter-api-key KEY Overrides the YAML value.

Logs go to stderr, which is where MCP clients collect server output. --log-level DEBUG prints every tool call each consultant makes.


Code map

Concern Source
MCP wiring, server instructions, tool schemas, request validation, error shapes ai_council/main.py
Mode enum, mode resolution, per-mode prompt suffixes, perspective assembly ai_council/synthesis.py
Client construction, parallel dispatch, the tool loop, concurrency semaphore ai_council/models.py
Config schema, defaults, startup validation, YAML/env loading ai_council/config.py
Sandbox, the five tools, path resolution, tool schemas ai_council/tools.py
Structured stderr logging ai_council/logger.py

Run the tests with uv run pytest (or pytest -q inside an activated venv). 170 tests cover config parsing, sandbox path resolution, the tool loop, mode resolution, workspace-root validation, per-call override clamping, content search, prompt assembly, transient retry, telemetry, progress notifications, and the discoverability contract. All model I/O is stubbed — no test hits a live endpoint. CI (.github/workflows/test.yml) runs the suite plus ruff check on every push and PR across Python 3.10–3.13; .github/workflows/publish.yml builds and uploads to PyPI on a GitHub release via trusted publishing.


Acknowledgments

Inspired by Cognition Wheel, which established the wisdom-of-crowds approach to multi-model consultation. This project diverges from it in dropping the synthesis step entirely, adding the three named modes, giving every consultant its own read-only investigation loop, and pushing the weighing of perspectives back onto the caller. The medieval House of Wisdom supplied the name and the principle: many lenses, weighed by the reader, not merged by an authority.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

house_of_wisdom_mcp-0.9.3.tar.gz (98.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

house_of_wisdom_mcp-0.9.3-py3-none-any.whl (92.1 kB view details)

Uploaded Python 3

File details

Details for the file house_of_wisdom_mcp-0.9.3.tar.gz.

File metadata

  • Download URL: house_of_wisdom_mcp-0.9.3.tar.gz
  • Upload date:
  • Size: 98.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for house_of_wisdom_mcp-0.9.3.tar.gz
Algorithm Hash digest
SHA256 514317db5331ffab9e2083345e739325d9e8996a8d3a3a52f208ec8e4b97de53
MD5 809447937d4e28c8bcefbe4b6f42dbe8
BLAKE2b-256 e29caf83dd309fb1064fda3cfa170bb9ecb1b333e4c564c6b18deec54171b04b

See more details on using hashes here.

Provenance

The following attestation bundles were made for house_of_wisdom_mcp-0.9.3.tar.gz:

Publisher: publish.yml on EzzoHamdan/house-of-wisdom-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file house_of_wisdom_mcp-0.9.3-py3-none-any.whl.

File metadata

File hashes

Hashes for house_of_wisdom_mcp-0.9.3-py3-none-any.whl
Algorithm Hash digest
SHA256 79c76ef1d1541298b20b4d8efd7fd17e6676de0246d1bc53fce3a576e129c209
MD5 6513a576d6e9f9daf24e7c22ccfeef01
BLAKE2b-256 55c799087e3a9652be6bc68119f1b665e797d533d375b1399f5ab9c60393581e

See more details on using hashes here.

Provenance

The following attestation bundles were made for house_of_wisdom_mcp-0.9.3-py3-none-any.whl:

Publisher: publish.yml on EzzoHamdan/house-of-wisdom-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page