{
  "markdown": "# Legion\n\n> \"I am Legion, for we are many.\"\n\nAn [MCP](https://modelcontextprotocol.io)-native model council. Legion exposes\nLLMs as individual tools and orchestrates them into debates, juries, blind\npanels, private refinement gauntlets, workshops, and custom multi-model\ndeliberations.\n\nEvery model is reached through the OpenAI **Responses API** wire format. Use\nOpenAI or Azure directly, route other providers through a compatible gateway\n(such as a [LiteLLM](https://docs.litellm.ai) proxy), and configure the entire\ncouncil through hot-reloadable files.\n\n## Contents\n\n- [How it works](#how-it-works)\n- [Design decisions](#design-decisions)\n- [Requirements](#requirements)\n- [Setup](#setup)\n- [Configuration](#configuration)\n- [Logging](#logging)\n- [Run](#run)\n- [Try it](#try-it)\n- [Use in VS Code](#use-in-vs-code)\n- [Deploy](#deploy)\n\n## How it works\n\n```mermaid\nflowchart LR\n   AI[Calling AI] -->|claude / gpt / gemini …| Legion\n   Legion -->|Responses API| GPT[OpenAI / Azure — direct]\n   Legion -->|Responses API| GW[Gateway e.g. LiteLLM]\n   GW --> Claude & Gemini & Llama\n```\n\n- **One tool per model**, named after the slugified model name (e.g. `Claude` →\n  `claude`). Each accepts a `prompt` plus optional `context`, `role`, `system`,\n  `temperature`, and `maxTokens`.\n- **A `quorum` tool** fans one prompt out to several models — with roles,\n  multi-round discussion, visibility modes, and synthesis — and returns each\n  answer separately. See [Presets](#presets--configpresetsjson) for the\n  orchestration options.\n- **Presets** are named, pre-staffed councils (debate, courtroom, code review, …),\n  each exposed as its own tool.\n- Identity and telemetry ride in `structuredContent`, not the answer text.\n  Logging goes to **stderr** (safe for stdio).\n\n## Design decisions\n\n- **No provider adapters.** There is no provider-specific code and no built-in\n  model list. Legion speaks one wire format; models that don't speak it natively\n  go through a gateway. Supporting a new model requires no change here.\n- **Models are config, not code.** Adding a model means adding a JSON file. The\n  directory is re-read per request, so no rebuild or restart.\n- **One tool per model.** Each model appears to the calling AI as its own tool\n  with its own description, rather than a single tool with a model parameter.\n  The `quorum` tool covers the ad-hoc multi-model case, and each preset in\n  `config/presets/` is exposed as its own enforced, pre-staffed council tool.\n- **Stateless.** Every call is one-shot with `store: false`. Nothing is\n  persisted, so there is no database and no conversation state to manage.\n- **Small.** A few hundred lines of TypeScript, one bundled output file, six\n  dependencies.\n\n## Requirements\n\n- Node.js 24+\n- At least one OpenAI-Responses-compatible endpoint (a provider API directly, or\n  a gateway such as LiteLLM for models that need bridging)\n\n## Setup\n\nFrom npm — no clone, no build:\n\n```pwsh\nnpx legion-mcp\n```\n\nFrom a clone:\n\n```pwsh\nnpm install\ncopy .env.example .env   # then edit .env\n```\n\n## Configuration\n\nAll configuration lives in a `config/` directory. The bundled defaults are\n**always the base layer**; a `config/` folder in the current working directory\nis **overlaid on top of them, per file**:\n\n- **Directory resources** (`models/`, `roles/`, `presets/`, `tools/`): a local\n  file overrides the bundled file of the same name; a local-only file is added;\n  every bundled file you don't touch stays. So dropping in one\n  `config/presets/refine.json` overrides just that preset — the other bundled\n  presets remain.\n- **Single-file text** (`prompts.json`, `errors.json`, `schema.json`): merged\n  **per key** — bundled < local. A partial local file overrides only\n  the keys it sets.\n- **`description.md`**: local wins whole if present, else bundled.\n\nThe overlay can **override or add**, but not delete a bundled entry. To turn off\nbundled presets you don't want, use `DISABLE_PRESETS` (see below).\n\n> **Installing from npm? You must supply your own model files.** The bundled\n> config ships only key-free `*.example.json` model files, which the scanner\n> deliberately ignores — so the bundle contributes **zero** real models. With no\n> real model file the server **fails fast at startup** (`No model files found\n> in ...`). Drop one `config/models/<name>.json` next to where you run the\n> server (see below) — the rest falls back to the bundled defaults.\n\nThe layout below is identical either way, and everything hot-reloads per\nrequest.\n\n### Models — `config/models/*.json`\n\nAt least one model file is **required** — the server fails fast without one.\nEach JSON file becomes a tool, named after the slugified file name\n(`config/models/fable.json` → tool `fable`):\n\n```json\n{\n   \"model\": \"claude-fable-5\",\n   \"description\": \"Claude Fable — fast, creative, general purpose.\",\n   \"baseUrl\": \"https://api.example.com\",\n   \"apiKey\": \"sk-optional-per-model-key\"\n}\n```\n\n- `model` (required) — the deployed model id the endpoint routes to.\n- `description` — helps the calling AI pick the right model.\n- `system` — optional baseline system instructions baked into every call to\n  this model.\n- `baseUrl` / `apiKey` — optional; omitted values fall back to\n  `DEFAULT_BASE_URL` / `DEFAULT_API_KEY`.\n- `omitParams` — optional list of request params to drop for this model, e.g.\n  `[\"temperature\"]`. The server stays provider-agnostic: it never assumes which\n  models reject which params — you declare each model's quirks here. Useful for\n  reasoning models and some deployments that reject `temperature`.\n\n**Hot-drop:** the directory is re-scanned per request — add or edit a model\nfile and it's live on the next call, no restart.\n\n**Secrets & git:** model files can contain API keys, so `config/models/*.json`\nis git-ignored. Copy a `*.example.json` (tracked, key-free, ignored by the\nscanner) to get started:\n\n```pwsh\ncopy config\\models\\gpt.example.json config\\models\\gpt.json   # then add your key\n```\n\n### Roles — `config/roles/*.md`\n\nOptional hot-droppable instruction files. Each `.md` file becomes a named role\n(slugified from filename). Drop a file, it's live on the next call. This repo\nships `skeptic.md`, `builder.md`, `judge.md`, and `short.md` (a terse \"answer\nimmediately, no deliberation\" role useful for constrained-output turns) as\nready-to-use starters — edit or delete them freely (they hold no secrets).\n\nAvailable selectors in tools become `roleName`, e.g. passing `role: \"skeptic\"`\nor using `\"model:skeptic\"` in `quorum.models`.\n\n### Presets — `config/presets/*.json`\n\nOptional hot-droppable **council recipes**, one JSON file per preset (named\nafter the slugified file name, like models). **Each preset becomes its own\ntool** — drop `config/presets/code_review.json` and a `code_review` tool appears\non the next request. Each preset has a `description`, a `roles` list, and\noptional authoritative `mode` / `synthesizer` defaults. Each role defines its\nbehavior **inline** — a role's `description` *is* its instructions (the behavior\ncontract); a role with no `description` falls back to a matching\n`config/roles/<role>.md` file:\n\n```json\n{\n   \"description\": [\n      \"Free-for-all: pit several contestants against each other, then crown a winner.\",\n      \"\",\n      \"Staff `contestant` with as many models as you like; one `judge` decides.\"\n   ],\n   \"mode\": \"parallel\",\n   \"synthesizer\": \"judge\",\n   \"roles\": [\n      { \"role\": \"contestant\", \"description\": \"Argue why your answer beats the others.\", \"min\": 2, \"max\": null },\n      { \"role\": \"judge\",      \"description\": \"Crown a single winner and justify it.\", \"min\": 1, \"max\": 1 }\n   ]\n}\n```\n\nThe calling AI invokes the preset tool directly (e.g. `code_review`) and still\nwrites the `models` selectors, assigning any model to any preset role. Presets\nare **enforced**: every selector must use a preset role and every role must be\nstaffed within its cardinality, else the result is an error saying what to fix.\n\nKeys:\n\n| Key | Type | Default | Description |\n| --- | --- | --- | --- |\n| `description` | `string \\| string[]` | required | MCP description for the preset tool. |\n| `roles` | `PresetRole[]` | required | Roles accepted by the preset. |\n| `mode` | `\"sequential\" \\| \"parallel\" \\| \"private\" \\| \"independent\"` | `\"sequential\"` | Controls which prior turns each round speaker sees. |\n| `defaultRounds` | positive integer | `1` | Rounds used when the call omits `rounds`. |\n| `synthesizer` | `string` | none | Neutral role that produces synthesis turns. |\n| `synthesizeEvery` | `\"end\" \\| non-negative integer` | `\"end\"` | Runs synthesis at the end or every Nth round. |\n| `framer` | `string` | none | Neutral role that opens and redirects the discussion. |\n| `reframeEvery` | `\"end\" \\| non-negative integer` | `\"end\"` | Reframes only at opening or every Nth round after opening. |\n| `closingStatements` | `boolean` | `false` | Runs a closing phase before final synthesis. |\n| `eliminateEvery` | non-negative integer | `0` | Lets the synthesizer remove one speaker every Nth round. Preset-only. |\n| `eliminationsOptional` | `boolean` | `false` | Lets the synthesizer decline an elimination. Preset-only. |\n| `enterEvery` | non-negative integer | `0` | Starts one speaker per team, then adds one benched speaker every Nth round. Preset-only. |\n| `vote` | `string` | none | Ballot instructions; enables anonymous voting. |\n| `voteEvery` | `\"end\" \\| non-negative integer` | `\"end\"` | Votes at the end or every Nth round. |\n| `voteVisibility` | `\"aggregate\" \\| \"ballots\"` | `\"aggregate\"` | Includes only totals or also anonymized ballot choices in the transcript. |\n| `allowSelfVote` | `boolean` | `true` | Includes each voter's own seat in its candidate menu. |\n| `voteByTeam` | `boolean` | `false` | Presents one choice per `@team` and aggregates votes by team. |\n\nRole object keys:\n\n| Key | Type | Default | Description |\n| --- | --- | --- | --- |\n| `role` | `string` | required | Role name used in `model:role` selectors. |\n| `description` | `string \\| string[]` | matching role file | Inline instructions; arrays are joined with newlines. Otherwise `config/roles/<role>.md` must exist. |\n| `min` | non-negative integer | `1` | Minimum speakers; `0` makes the role optional. |\n| `max` | positive integer or `null` | `1` | Maximum speakers; `null` is unbounded. |\n| `silent` | `boolean` | `false` | Lets the role observe and vote without speaking in normal rounds. |\n| `voter` | `boolean` | all eligible roles | Restricts anonymous ballots to marked roles when any role is marked. |\n| `candidate` | `boolean` | all eligible roles | Restricts ballot choices to marked roles when any role is marked. |\n| `closing` | `boolean` | all eligible roles | Restricts closings to marked roles; only the first marked speaker per team or unteamed role closes. |\n| `closingLast` | `boolean` | `false` | Runs this closer after parallel closings with their statements in context; requires `closing: true`. |\n| `tagTeam` | `boolean` | `false` | Rotates one marked speaker per `@team` into each normal round. Cannot combine with `enterEvery`. |\n\nFor example, a courtroom call assigns lawyers to sides with `@team` tags. The\nfirst lawyer listed for each side gives that side's closing statement:\n\n```json\n{\n   \"models\": [\n      \"gpt:lawyer@prosecution\",\n      \"grok:lawyer@prosecution\",\n      \"claude:lawyer@defense\",\n      \"kimi:juror\",\n      \"llama:juror\",\n      \"mistral:juror\",\n      \"opus:judge\"\n   ],\n   \"objectives\": {\n      \"prosecution\": \"Prove liability.\",\n      \"defense\": \"Defeat liability.\"\n   }\n}\n```\n\nThis repo ships these presets — edit or delete freely:\n\n<dl>\n<dt><code>code_review</code></dt>\n<dd>Structured multi-model code review.</dd>\n<dt><code>debate</code></dt>\n<dd>Opposing sides argue a question to a synthesis.</dd>\n<dt><code>brainstorm</code></dt>\n<dd>Divergent idea generation across models.</dd>\n<dt><code>quick_take</code></dt>\n<dd>Fast one-shot reactions from several models.</dd>\n<dt><code>tiebreak</code></dt>\n<dd>A decisive third voice resolves a stalemate.</dd>\n<dt><code>battle_royale</code></dt>\n<dd>Free-for-all contest; an overseer crowns a winner.</dd>\n<dt><code>courtroom</code></dt>\n<dd>Team-tagged lawyers argue opposing sides, jurors vote by side, and a judge rules.</dd>\n<dt><code>election</code></dt>\n<dd>Candidates campaign, then the field decides by secret ballot — the anonymous vote is the verdict, not a judge's call. Optional <code>incumbent</code> defends a record; an optional silent <code>electorate</code> reads every round and votes without campaigning.</dd>\n<dt><code>double_blind</code></dt>\n<dd>Independent blind panel — no one sees the others.</dd>\n<dt><code>gauntlet</code></dt>\n<dd>Private self-refinement race across rounds.</dd>\n<dt><code>refine</code></dt>\n<dd>Relay polish of an existing artifact.</dd>\n<dt><code>workshop</code></dt>\n<dd>Differentiated creative team.</dd>\n<dt><code>focus_group</code></dt>\n<dd>Moderated panel that riffs off each other.</dd>\n<dt><code>final_girl</code></dt>\n<dd>Survivors culled one per round until one remains.</dd>\n<dt><code>war_games</code></dt>\n<dd>A staggered-entry team cage match: <code>@team</code>-tagged combatants enter one at a time while a neutral ref calls fouls and names the winning team, with an optional <code>booker</code> who sets the match.</dd>\n</dl>\n\nEmpty/missing folder → no preset tools.\n\n> **Role text nudges output, it doesn't cap it** — use `maxTokens` for a hard\n> limit, and budget generously for reasoning models and multi-round quorums.\n\n### AI guidance — `config/description.md`\n\nOptional markdown served to clients as MCP `instructions` — describe your\nmodels and when the AI should use each. See this repo's copy for a template.\n\n### Tool, field & message text — `config/*.json` and `config/tools/*.md`\n\nAll user-facing text lives in config, not code, and hot-reloads per request.\nEach file merges over the bundled JSON base per key, so override only what you want;\nopen the shipped copies to see the full key set and `{token}` placeholders:\n\n- `config/tools/<tool>.md` — a tool's description (e.g. `quorum.md`). Delete to\n  fall back to the built-in string.\n- `config/schema.json` — input-field descriptions (`prompt` = shared fields,\n  `quorum` = quorum-only; a `quorum` key wins on a name clash).\n- `config/prompts.json` — the prompt-shaping templates models read: role\n  contract, context block, transcript header, round banners. Tune how strongly\n  roles bind and how rounds are framed here.\n- `config/errors.json` — runtime error messages shown to the calling AI.\n\n(Startup/config-validation errors stay in code — a message that reports a broken\nconfig file can't live inside it.)\n\n### Environment variables\n\n| Variable | Required | Description |\n| --- | --- | --- |\n| `DEFAULT_BASE_URL` | no* | API root for models without a `baseUrl` — the SDK appends `/responses`. E.g. `https://api.openai.com/v1`, `https://<res>.openai.azure.com/openai/v1`; a LiteLLM proxy works at its plain root. |\n| `DEFAULT_API_KEY` | no* | API key for models without an `apiKey`. Stays server-side. |\n| `ALLOW_NO_MODELS` | no | `true` boots even when **no model files exist**: zero model tools; `quorum` and preset tools register but fail on use until a `config/models/*.json` appears (hot-reloaded per request). For demos and registry sandboxes that only list tools. Default `false` — missing models stay fatal. |\n| `MCP_TRANSPORT` | no | `http` (default) or `stdio`. |\n| `HOST` | no | HTTP bind address (default `127.0.0.1`). Set `0.0.0.0` to expose — then set `ALLOWED_HOSTS`. |\n| `ALLOWED_HOSTS` | no | Comma-separated hostnames for DNS-rebinding protection on non-localhost binds. |\n| `PORT` | no | HTTP port (default `5000`; ignored by stdio). |\n| `MAX_ROUNDS` | no | Max discussion rounds the `quorum` tool accepts (default `5`). |\n| `TOKEN_BUDGET` | no | Default **soft** cumulative token budget for a `quorum` run (unset = no limit; per-call `tokenBudget` overrides). |\n| `DYNAMIC_ROLES` | no | Allow the calling AI to define ad-hoc `quorum` roles inline (default `true`). |\n| `DISABLE_PRESETS` | no | Comma-separated preset slugs to **not** register as tools (e.g. `battle_royale,courtroom`). Applies to bundled and local presets alike; unknown names are ignored. Unset = all presets registered. |\n| `LOG_LEVEL` | no | `debug` \\| `info` \\| `warn` \\| `error` (default `info`). |\n\n\\* Every model must resolve a `baseUrl` and `apiKey` from its file or the\ndefaults — validated at startup.\n\nThe server **fails fast** at startup on a missing/empty models directory\n(unless `ALLOW_NO_MODELS=true`), invalid model files, an unresolvable endpoint\nor key, or two file names that slugify to the same tool.\n\n### Routing\n\nEvery tool call is a stateless, one-shot Responses API request. Models whose\nendpoints natively speak Responses (OpenAI, Azure OpenAI / Foundry) set a\n`baseUrl` to be called **directly**; the rest fall back to the defaults —\ntypically an OpenAI-compatible gateway like LiteLLM that bridges to their native\nAPIs.\n\n## Logging\n\n- `info` (blue): server start and one metadata line per model call — model,\n  latency, token usage, role, context presence. No prompt/response content.\n- `debug` (gray): additionally logs the full prompt and response (context is\n  noted as present, not printed).\n- `warn` (orange) / `error` (red): fallbacks and failures.\n\nColor is auto-disabled when stderr is not a TTY.\n\n## Run\n\nOne entrypoint; the transport comes from `MCP_TRANSPORT` (`http` is the\ndefault, set `stdio` for desktop MCP clients).\n\nFrom npm (`legion-mcp` bin — run from a directory holding your `config/`):\n\n```pwsh\nnpx legion-mcp                             # Streamable HTTP transport on :$PORT/mcp\n$env:MCP_TRANSPORT='stdio'; npx legion-mcp # stdio transport\n```\n\nInstalled globally or as a dependency, the same binary is on `PATH`:\n\n```pwsh\nnpm install -g legion-mcp\nlegion-mcp\n```\n\nDevelopment (no build step, via `tsx`):\n\n```pwsh\nnpm run dev         # Streamable HTTP transport on :$PORT/mcp\nnpm run dev:stdio   # stdio transport\n```\n\nProduction (compiled to `bin/server.js`):\n\n```pwsh\nnpm run build\nnpm start           # http\nnpm run start:stdio # stdio\n```\n\n## Try it\n\nList the tools with the MCP Inspector:\n\n```pwsh\nnpx @modelcontextprotocol/inspector -e MCP_TRANSPORT=stdio npx tsx ts/server.ts\n```\n\n## Use in VS Code\n\nAdd to your `mcp.json` — from npm:\n\n```json\n{\n   \"servers\": {\n      \"legion\": {\n         \"command\": \"npx\",\n         \"args\": [\"-y\", \"legion-mcp\"],\n         \"cwd\": \"path/to/your/config/parent\",\n         \"env\": {\n            \"MCP_TRANSPORT\": \"stdio\",\n            \"DEFAULT_BASE_URL\": \"https://your-gateway.example.com\",\n            \"DEFAULT_API_KEY\": \"sk-your-key\"\n         }\n      }\n   }\n}\n```\n\nOr from a clone:\n\n```json\n{\n   \"servers\": {\n      \"legion\": {\n         \"command\": \"node\",\n         \"args\": [\"bin/server.js\"],\n         \"cwd\": \"path/to/legion\",\n         \"env\": {\n            \"MCP_TRANSPORT\": \"stdio\",\n            \"DEFAULT_BASE_URL\": \"https://your-gateway.example.com\",\n            \"DEFAULT_API_KEY\": \"sk-your-key\"\n         }\n      }\n   }\n}\n```\n\nFor the HTTP transport, point your client at `http://<host>:<PORT>/mcp`.\n\n### Health\n\n- `GET /health` — cheap **liveness**: confirms the process is up and config\n  loaded. Returns `{ status: \"ok\", name, version, models }` (a count). Makes no\n  external calls. This is what container `HEALTHCHECK`s and Kubernetes\n  liveness/readiness probes should hit.\n- `GET /health?deep` — optional **connectivity** check: sends a tiny prompt to\n  every model and reports per-model reachability (`503` if any fail). Makes a\n  real billable call per model, so use it manually — **don't** wire it to an\n  automatic probe.\n\n## Deploy\n\nReady-to-use container deployment examples (Azure App Service, Azure Container\nApps, Docker Compose, Kubernetes, and Compose + Caddy for HTTPS) live in\n[`examples/`](examples/) — each installs Legion from npm and ships a complete\ndrop-in `config/`.\n",
  "bytes": 19879,
  "sha": "cd2ffc46f01a13b0d41b1e17b5ef8490433949241267a9b99a8bb6bb2893867d",
  "repo_slug": "faulkj/legion-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_faulkj_legion_00b4861e/readme"
}