House of Wisdom MCP
Enables querying multiple AI models simultaneously and returns all answers unmerged for the user to weigh.
README
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 it — What it is → Quick start → The three modes → The two tools → How a call flows → Installation → Configuration. 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-20, commit
7ac2449, package version0.6.1.
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.
Do not fire the council on every prompt. It costs several model calls and tens of seconds. Use it only when a different model family seeing the problem would plausibly change the outcome. For work that just needs your own focused reasoning, use a single-mind thinking tool instead.
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 git+https://github.com/EzzoHamdan/house-of-wisdom-mcp@master
# 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.
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 |
false → scribe, true → translator |
| 4 | synthesizer_tools.enabled in config |
true → translator, false → scribe |
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_council→consultandai_council_list_models→list_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 useconsult/list_modelsgoing forward.
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 |
|---|---|---|---|
context |
string | yes | Background. Must be non-empty, max 200,000 characters. In scribe mode this is the only material the models see, so paste file contents here. |
question |
string | yes | Must be non-empty, max 10,000 characters. |
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. 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. 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: false → scribe, true → translator. Overridden by mode. |
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"},
{"label": "Kimi", "model_name": "Kimi", "code_name": "Beta", "analysis": "...", "status": "error", "mode": "translator"}
],
"consensus": {"models_queried": 2, "models_succeeded": 1, "models_failed": 1}
}
}
- Failed consultants are returned in-band with
status: "error"and the error text sitting inanalysis. The call as a whole still reports"status": "success"as long as at least one consultant succeeded. labelismodel_namenormally, orcode_namewhenanonymous_perspectives: true.consensuscounts nothing about agreement — despite the name, it is a dispatch tally, andmodels_failedis simply the number ofstatus: "error"entries.
Error shape.
{"status": "error", "error": {"code": "...", "message": "...", "type": "...", "details": "..."}, "data": null}
code |
Fires when |
|---|---|
INVALID_INPUT |
context or question is empty or over its character cap |
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
└─ budget scholar → scholar_max_tool_iterations · else max_tool_iterations
│
├── scribe ───────────► models.py::call_models_parallel
│ one chat call per model · temp 0.7 · max_tokens 8000
│ ⚠ NO concurrency cap — every model fires at once
│
└── 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,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 · no semaphore"]
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 · 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, it is ignored without an error. The
server then boots on built-in defaults, whose roster is three OpenRouter models — so the symptom
of a typo'd path is usually OpenRouter API key is required, not "file not found".
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 git+https://github.com/EzzoHamdan/house-of-wisdom-mcp@master
ai-council
--config /absolute/path/to/config.yaml
The server key is the display name. The key you give the entry under
mcpServers(House-of-Wisdombelow) 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. Theai-counciltoken insideargsis 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", "git+https://github.com/EzzoHamdan/house-of-wisdom-mcp@master",
"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 MCP → mcp_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", "git+https://github.com/EzzoHamdan/house-of-wisdom-mcp@master",
"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 git+https://github.com/EzzoHamdan/house-of-wisdom-mcp@master \
ai-council --config /absolute/path/to/config.yaml
Codex CLI — ~/.codex/config.toml:
[mcp_servers."House-of-Wisdom"]
command = "uvx"
args = ["--from", "git+https://github.com/EzzoHamdan/house-of-wisdom-mcp@master",
"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
- Call
list_models→ your roster comes back. - Call
consultwithmode: "scribe", a shortcontext, and a shortquestion→ each model answers. This isolates model connectivity from filesystem/sandbox concerns. - Only then try
translatorwith an explicitworkspace_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 |
anonymous_perspectives |
false |
false |
boolean |
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 |
null |
absolute path |
synthesizer_tools.max_tool_iterations |
8 |
12 |
1–128 |
synthesizer_tools.scholar_max_tool_iterations |
64 |
64 |
1–256 |
synthesizer_tools.allowed_tools |
all four | all four | subset of the four tool names — ⚠ [] means all four, not none |
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) |
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. Only surfaces when anonymous_perspectives: true. ⚠ Setting it by hand on some entries while running close to the 10-model cap can crash startup — see Sharp edges. |
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:
api_keyon the model entry — always wins for that entry, whatever the provider.- The provider-level key (
openai_api_keyforprovider: openai,openrouter_api_keyforprovider: 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_MAX_CONCURRENT_CONSULTANTS,
AI_COUNCIL_ANONYMOUS_PERSPECTIVES.
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:
-
Environment variables — set the
AI_COUNCIL_-prefixed names. For MCP the cleanest place is your client'senvblock, so the server inherits them at launch:"env": { "AI_COUNCIL_OPENAI_API_KEY": "sk-...", "AI_COUNCIL_OPENROUTER_API_KEY": "sk-or-..." } -
A
.envfile — the server reads one next to the config file and one in the working directory, before it parses any keys. Copy the committed.env.exampleto.env(it's gitignored) and fill it in; use the sameAI_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
envblock) always wins over the.envfile. -
${ENV_VAR}placeholders — anyapi_keyorbase_urlvalue may reference an env var, which is expanded at load time. This is the only secret-free path for a per-modelapi_keyon aprovider: customentry (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 (translator/scholar only)
max_models(1–10) caps the default fan-out — the roster used when a call names no subset. The window is the firstmax_modelsenabled entries by position in the YAML list, not by preference.- The per-call
modelsargument resolves against the full enabled list, so any enabled model is reachable by name regardless ofmax_modelsor its position. With eight enabled models andmax_models: 3, asking for the seventh by name fires exactly that model. A named subset is not re-capped tomax_models— the caller chose the models, andmax_concurrent_consultantsstill bounds how many run at once. max_concurrent_consultants(1–32) caps how many tool loops run simultaneously; the rest queue. Match it to your provider's concurrency allowance (Ollama Cloud Pro = 3, Max = 10). ⚠ It is not applied inscribemode — there, every model in the roster fires 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. |
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. ⚠ An empty list is treated as permissive,
not restrictive — allowed_tools: [] advertises and permits all four. To actually narrow the
surface, name the tools you want, e.g. ["read_file", "think"].
What never happens
- No writes, no shell, no network from the tools. The four above are the entire surface
(
tools.py::ToolRegistry.call). - No read outside
workspace_root. Paths are resolved withPath.resolve()and checked withrelative_to(), so symlinks pointing out of the root are rejected asSandboxViolation(tools.py::ToolRegistry._resolve). - No tool call at all in
scribemode. - 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. The system prompt tells
the model that each call costs one unit, so most models behave as if the stricter accounting were
real, but a batching model can legitimately read far more of your workspace than the budget number
suggests.
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 |
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.
⚠ 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 |
| Concurrency cap | max_concurrent_consultants is only honored in translator/scholar. models.py::call_models_parallel (the scribe path) has no semaphore. |
Reported mode |
Is the mode you asked for. If agentic setup raises, the run silently degrades to plain no-tool calls but the perspectives are still tagged translator/scholar. synthesis.py:169-180, stamped at synthesis.py:194 |
anonymous_perspectives |
Changes label only. model_name and code_name are always both present in the payload, so this hides nothing from the orchestrator. |
| Missing config path | --config /typo.yaml is ignored silently and built-in defaults take over — including their OpenRouter roster. There is no fallback to ~/.config/ai-council/config.yaml; that path is only probed when --config is omitted entirely. config.py:274-277 |
| Tool budget | Counts assistant turns containing tool calls, not individual calls. |
Fixed in v0.6.1. The per-call
modelsargument now resolves against the full enabled list, so an explicitly named enabled model is reachable regardless ofmax_modelsor its position in the YAML.max_modelsagain caps only the default (no-subset) fan-out, and theNO_MATCHING_MODELSdetail 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 bareOPENAI_API_KEYis no longer silently adopted); a batch timeout keeps consultants that already finished;statusis carried explicitly instead of guessed from the text; acustommodel withoutbase_urlis rejected instead of talking toapi.openai.com;allowed_tools: []now permits nothing;code_nameauto-assignment no longer skips names or crashes at 10 models; the advertised version tracks the package;synthesis_model_selection(dead) was removed; and theconsultdescription 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.) |
--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, 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 four 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). They cover config
parsing and sandbox path resolution; there is no test that exercises a live model call.
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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。