
Security News
Happy Birthday, Shai-Hulud
It has been one year since Shai-Hulud made its first appearance on npm.
I am Legion, for we are many ~ MCP-native LLM councils for debates, juries, blind panels, refinement, and custom multi-model deliberation.
"I am Legion, for we are many."
An MCP-native model council. Legion exposes LLMs as individual tools and orchestrates them into debates, juries, blind panels, private refinement gauntlets, workshops, and custom multi-model deliberations.
Every model is reached through the OpenAI Responses API wire format. Use OpenAI or Azure directly, route other providers through a compatible gateway (such as a LiteLLM proxy), and configure the entire council through hot-reloadable files.
flowchart LR
AI[Calling AI] -->|claude / gpt / gemini …| Legion
Legion -->|Responses API| GPT[OpenAI / Azure — direct]
Legion -->|Responses API| GW[Gateway e.g. LiteLLM]
GW --> Claude & Gemini & Llama
Claude →
claude). Each accepts a prompt plus optional context, role, system,
temperature, and maxTokens.quorum tool fans one prompt out to several models — with roles,
multi-round discussion, visibility modes, and synthesis — and returns each
answer separately. See Presets for the
orchestration options.structuredContent, not the answer text.
Logging goes to stderr (safe for stdio).quorum tool covers the ad-hoc multi-model case, and each preset in
config/presets/ is exposed as its own enforced, pre-staffed council tool.store: false. Nothing is
persisted, so there is no database and no conversation state to manage.From npm — no clone, no build:
npx legion-mcp
From a clone:
npm install
copy .env.example .env # then edit .env
All configuration lives in a config/ directory. The bundled defaults are
always the base layer; a config/ folder in the current working directory
is overlaid on top of them, per file:
models/, roles/, presets/, tools/): a local
file overrides the bundled file of the same name; a local-only file is added;
every bundled file you don't touch stays. So dropping in one
config/presets/refine.json overrides just that preset — the other bundled
presets remain.prompts.json, errors.json, schema.json): merged
per key — bundled < local. A partial local file overrides only
the keys it sets.description.md: local wins whole if present, else bundled.The overlay can override or add, but not delete a bundled entry. To turn off
bundled presets you don't want, use DISABLE_PRESETS (see below).
Installing from npm? You must supply your own model files. The bundled config ships only key-free
*.example.jsonmodel files, which the scanner deliberately ignores — so the bundle contributes zero real models. With no real model file the server fails fast at startup (No model files found in ...). Drop oneconfig/models/<name>.jsonnext to where you run the server (see below) — the rest falls back to the bundled defaults.
The layout below is identical either way, and everything hot-reloads per request.
config/models/*.jsonAt least one model file is required — the server fails fast without one.
Each JSON file becomes a tool, named after the slugified file name
(config/models/fable.json → tool fable):
{
"model": "claude-fable-5",
"description": "Claude Fable — fast, creative, general purpose.",
"baseUrl": "https://api.example.com",
"apiKey": "sk-optional-per-model-key"
}
model (required) — the deployed model id the endpoint routes to.description — helps the calling AI pick the right model.system — optional baseline system instructions baked into every call to
this model.baseUrl / apiKey — optional; omitted values fall back to
DEFAULT_BASE_URL / DEFAULT_API_KEY.omitParams — optional list of request params to drop for this model, e.g.
["temperature"]. The server stays provider-agnostic: it never assumes which
models reject which params — you declare each model's quirks here. Useful for
reasoning models and some deployments that reject temperature.Hot-drop: the directory is re-scanned per request — add or edit a model file and it's live on the next call, no restart.
Secrets & git: model files can contain API keys, so config/models/*.json
is git-ignored. Copy a *.example.json (tracked, key-free, ignored by the
scanner) to get started:
copy config\models\gpt.example.json config\models\gpt.json # then add your key
config/roles/*.mdOptional hot-droppable instruction files. Each .md file becomes a named role
(slugified from filename). Drop a file, it's live on the next call. This repo
ships skeptic.md, builder.md, judge.md, and short.md (a terse "answer
immediately, no deliberation" role useful for constrained-output turns) as
ready-to-use starters — edit or delete them freely (they hold no secrets).
Available selectors in tools become roleName, e.g. passing role: "skeptic"
or using "model:skeptic" in quorum.models.
config/presets/*.jsonOptional hot-droppable council recipes, one JSON file per preset (named
after the slugified file name, like models). Each preset becomes its own
tool — drop config/presets/code_review.json and a code_review tool appears
on the next request. Each preset has a description, a roles list, and
optional authoritative mode / synthesizer defaults. Each role defines its
behavior inline — a role's description is its instructions (the behavior
contract); a role with no description falls back to a matching
config/roles/<role>.md file:
{
"description": [
"Free-for-all: pit several contestants against each other, then crown a winner.",
"",
"Staff `contestant` with as many models as you like; one `judge` decides."
],
"mode": "parallel",
"synthesizer": "judge",
"roles": [
{ "role": "contestant", "description": "Argue why your answer beats the others.", "min": 2, "max": null },
{ "role": "judge", "description": "Crown a single winner and justify it.", "min": 1, "max": 1 }
]
}
The calling AI invokes the preset tool directly (e.g. code_review) and still
writes the models selectors, assigning any model to any preset role. Presets
are enforced: every selector must use a preset role and every role must be
staffed within its cardinality, else the result is an error saying what to fix.
Keys:
| Key | Type | Default | Description |
|---|---|---|---|
description | string | string[] | required | MCP description for the preset tool. |
roles | PresetRole[] | required | Roles accepted by the preset. |
mode | "sequential" | "parallel" | "private" | "independent" | "sequential" | Controls which prior turns each round speaker sees. |
defaultRounds | positive integer | 1 | Rounds used when the call omits rounds. |
synthesizer | string | none | Neutral role that produces synthesis turns. |
synthesizeEvery | "end" | non-negative integer | "end" | Runs synthesis at the end or every Nth round. |
framer | string | none | Neutral role that opens and redirects the discussion. |
reframeEvery | "end" | non-negative integer | "end" | Reframes only at opening or every Nth round after opening. |
closingStatements | boolean | false | Runs a closing phase before final synthesis. |
eliminateEvery | non-negative integer | 0 | Lets the synthesizer remove one speaker every Nth round. Preset-only. |
eliminationsOptional | boolean | false | Lets the synthesizer decline an elimination. Preset-only. |
enterEvery | non-negative integer | 0 | Starts one speaker per team, then adds one benched speaker every Nth round. Preset-only. |
vote | string | none | Ballot instructions; enables anonymous voting. |
voteEvery | "end" | non-negative integer | "end" | Votes at the end or every Nth round. |
voteVisibility | "aggregate" | "ballots" | "aggregate" | Includes only totals or also anonymized ballot choices in the transcript. |
allowSelfVote | boolean | true | Includes each voter's own seat in its candidate menu. |
voteByTeam | boolean | false | Presents one choice per @team and aggregates votes by team. |
Role object keys:
| Key | Type | Default | Description |
|---|---|---|---|
role | string | required | Role name used in model:role selectors. |
description | string | string[] | matching role file | Inline instructions; arrays are joined with newlines. Otherwise config/roles/<role>.md must exist. |
min | non-negative integer | 1 | Minimum speakers; 0 makes the role optional. |
max | positive integer or null | 1 | Maximum speakers; null is unbounded. |
silent | boolean | false | Lets the role observe and vote without speaking in normal rounds. |
voter | boolean | all eligible roles | Restricts anonymous ballots to marked roles when any role is marked. |
candidate | boolean | all eligible roles | Restricts ballot choices to marked roles when any role is marked. |
closing | boolean | all eligible roles | Restricts closings to marked roles; only the first marked speaker per team or unteamed role closes. |
closingLast | boolean | false | Runs this closer after parallel closings with their statements in context; requires closing: true. |
tagTeam | boolean | false | Rotates one marked speaker per @team into each normal round. Cannot combine with enterEvery. |
For example, a courtroom call assigns lawyers to sides with @team tags. The
first lawyer listed for each side gives that side's closing statement:
{
"models": [
"gpt:lawyer@prosecution",
"grok:lawyer@prosecution",
"claude:lawyer@defense",
"kimi:juror",
"llama:juror",
"mistral:juror",
"opus:judge"
],
"objectives": {
"prosecution": "Prove liability.",
"defense": "Defeat liability."
}
}
This repo ships these presets — edit or delete freely:
code_reviewdebatebrainstormquick_taketiebreakbattle_royalecourtroomelectionincumbent defends a record; an optional silent electorate reads every round and votes without campaigning.double_blindgauntletrefineworkshopfocus_groupfinal_girlwar_games@team-tagged combatants enter one at a time while a neutral ref calls fouls and names the winning team, with an optional booker who sets the match.Empty/missing folder → no preset tools.
Role text nudges output, it doesn't cap it — use
maxTokensfor a hard limit, and budget generously for reasoning models and multi-round quorums.
config/description.mdOptional markdown served to clients as MCP instructions — describe your
models and when the AI should use each. See this repo's copy for a template.
config/*.json and config/tools/*.mdAll user-facing text lives in config, not code, and hot-reloads per request.
Each file merges over the bundled JSON base per key, so override only what you want;
open the shipped copies to see the full key set and {token} placeholders:
config/tools/<tool>.md — a tool's description (e.g. quorum.md). Delete to
fall back to the built-in string.config/schema.json — input-field descriptions (prompt = shared fields,
quorum = quorum-only; a quorum key wins on a name clash).config/prompts.json — the prompt-shaping templates models read: role
contract, context block, transcript header, round banners. Tune how strongly
roles bind and how rounds are framed here.config/errors.json — runtime error messages shown to the calling AI.(Startup/config-validation errors stay in code — a message that reports a broken config file can't live inside it.)
| Variable | Required | Description |
|---|---|---|
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. |
DEFAULT_API_KEY | no* | API key for models without an apiKey. Stays server-side. |
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. |
MCP_TRANSPORT | no | http (default) or stdio. |
HOST | no | HTTP bind address (default 127.0.0.1). Set 0.0.0.0 to expose — then set ALLOWED_HOSTS. |
ALLOWED_HOSTS | no | Comma-separated hostnames for DNS-rebinding protection on non-localhost binds. |
PORT | no | HTTP port (default 5000; ignored by stdio). |
MAX_ROUNDS | no | Max discussion rounds the quorum tool accepts (default 5). |
TOKEN_BUDGET | no | Default soft cumulative token budget for a quorum run (unset = no limit; per-call tokenBudget overrides). |
DYNAMIC_ROLES | no | Allow the calling AI to define ad-hoc quorum roles inline (default true). |
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. |
LOG_LEVEL | no | debug | info | warn | error (default info). |
* Every model must resolve a baseUrl and apiKey from its file or the
defaults — validated at startup.
The server fails fast at startup on a missing/empty models directory
(unless ALLOW_NO_MODELS=true), invalid model files, an unresolvable endpoint
or key, or two file names that slugify to the same tool.
Every tool call is a stateless, one-shot Responses API request. Models whose
endpoints natively speak Responses (OpenAI, Azure OpenAI / Foundry) set a
baseUrl to be called directly; the rest fall back to the defaults —
typically an OpenAI-compatible gateway like LiteLLM that bridges to their native
APIs.
info (blue): server start and one metadata line per model call — model,
latency, token usage, role, context presence. No prompt/response content.debug (gray): additionally logs the full prompt and response (context is
noted as present, not printed).warn (orange) / error (red): fallbacks and failures.Color is auto-disabled when stderr is not a TTY.
One entrypoint; the transport comes from MCP_TRANSPORT (http is the
default, set stdio for desktop MCP clients).
From npm (legion-mcp bin — run from a directory holding your config/):
npx legion-mcp # Streamable HTTP transport on :$PORT/mcp
$env:MCP_TRANSPORT='stdio'; npx legion-mcp # stdio transport
Installed globally or as a dependency, the same binary is on PATH:
npm install -g legion-mcp
legion-mcp
Development (no build step, via tsx):
npm run dev # Streamable HTTP transport on :$PORT/mcp
npm run dev:stdio # stdio transport
Production (compiled to bin/server.js):
npm run build
npm start # http
npm run start:stdio # stdio
List the tools with the MCP Inspector:
npx @modelcontextprotocol/inspector -e MCP_TRANSPORT=stdio npx tsx ts/server.ts
Add to your mcp.json — from npm:
{
"servers": {
"legion": {
"command": "npx",
"args": ["-y", "legion-mcp"],
"cwd": "path/to/your/config/parent",
"env": {
"MCP_TRANSPORT": "stdio",
"DEFAULT_BASE_URL": "https://your-gateway.example.com",
"DEFAULT_API_KEY": "sk-your-key"
}
}
}
}
Or from a clone:
{
"servers": {
"legion": {
"command": "node",
"args": ["bin/server.js"],
"cwd": "path/to/legion",
"env": {
"MCP_TRANSPORT": "stdio",
"DEFAULT_BASE_URL": "https://your-gateway.example.com",
"DEFAULT_API_KEY": "sk-your-key"
}
}
}
}
For the HTTP transport, point your client at http://<host>:<PORT>/mcp.
GET /health — cheap liveness: confirms the process is up and config
loaded. Returns { status: "ok", name, version, models } (a count). Makes no
external calls. This is what container HEALTHCHECKs and Kubernetes
liveness/readiness probes should hit.GET /health?deep — optional connectivity check: sends a tiny prompt to
every model and reports per-model reachability (503 if any fail). Makes a
real billable call per model, so use it manually — don't wire it to an
automatic probe.Ready-to-use container deployment examples (Azure App Service, Azure Container
Apps, Docker Compose, Kubernetes, and Compose + Caddy for HTTPS) live in
examples/ — each installs Legion from npm and ships a complete
drop-in config/.
FAQs
I am Legion, for we are many ~ MCP-native LLM councils for debates, juries, blind panels, refinement, and custom multi-model deliberation.
The npm package legion-mcp receives a total of 40 weekly downloads. As such, legion-mcp popularity was classified as not popular.
We found that legion-mcp demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.