legion-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,44 @@
1
+ # Kopimi License
2
+
3
+ This software is released under the Kopimi License.
4
+
5
+ ## Permission
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
8
+ this software and associated documentation files (the "Software"), to copy,
9
+ modify, merge, publish, distribute, sublicense, sell, use, study,
10
+ reverse-engineer, decompile, and otherwise deal in the Software without
11
+ restriction, including without limitation for commercial or non-commercial
12
+ purposes.
13
+
14
+ Commercial use, sale, resale, or distribution for profit must include clear
15
+ attribution to the original author(s) in the distributed copy and in any
16
+ customary public-facing product notices, documentation, credits, or legal
17
+ notices associated with that use.
18
+
19
+ ## Kopimi Propagation
20
+
21
+ The Software is Kopimi. Copies, substantial portions, and derivative works must
22
+ preserve the freedom to copy, share, modify, remix, and redistribute the
23
+ Software. No copy, substantial portion, or derivative work may be distributed
24
+ under terms that prohibit those freedoms.
25
+
26
+ License notices, author notices, and Kopimi notices included with the Software
27
+ may not be removed from redistributed copies, substantial portions, or
28
+ derivative works.
29
+
30
+ Attribution to the original author(s) is appreciated for non-commercial use and
31
+ required for commercial use as described above.
32
+
33
+ ## Disclaimer
34
+
35
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
36
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
37
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE
38
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
39
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
40
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
41
+
42
+ This license is itself Kopimi. Copy it, use it, and remix it.
43
+
44
+ For more information, please refer to <https://kopimi.com>
package/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # Legion
2
+
3
+ > "I am Legion, for we are many."
4
+
5
+ An [MCP](https://modelcontextprotocol.io) server that exposes LLMs (Claude, GPT,
6
+ Gemini, Llama, …) as individual tools. Each configured model becomes a tool,
7
+ named after the model, that the calling AI can invoke to get a second opinion.
8
+
9
+ Every model is reached through the OpenAI **Responses API** wire format.
10
+ Endpoints that speak it natively (OpenAI, Azure OpenAI / Foundry) are called
11
+ directly; anything else routes through an OpenAI-compatible gateway such as a
12
+ [LiteLLM](https://docs.litellm.ai) proxy. Nothing here depends on any particular
13
+ gateway.
14
+
15
+ ## How it works
16
+
17
+ ```mermaid
18
+ flowchart LR
19
+ AI[Calling AI] -->|claude / gpt / gemini …| Legion
20
+ Legion -->|Responses API| GPT[OpenAI / Azure — direct]
21
+ Legion -->|Responses API| GW[Gateway e.g. LiteLLM]
22
+ GW --> Claude & Gemini & Llama
23
+ ```
24
+
25
+ - One tool per model, named after the slugified model name (e.g. `Claude` →
26
+ `claude`, `GPT 4o` → `gpt_4o`).
27
+ - Each tool accepts a `prompt` plus optional `context`, `role`, `system`,
28
+ `temperature`, and `maxTokens`.
29
+ - A `quorum` tool fans one prompt to two or more models and returns each answer
30
+ as a separate content item. Supports roles via `model:role` selectors (the
31
+ same model can appear multiple times with different roles), inline ad-hoc
32
+ `roles`, multi-round discussion (`rounds`, `mode`), and an optional synthesis
33
+ turn (`synthesize`). The calling AI acts as moderator: feed
34
+ `structuredContent.transcript` back as `context` with new guidance to steer a
35
+ live debate. An optional `tokenBudget` (or the `TOKEN_BUDGET` default) caps the
36
+ cumulative tokens a run may spend: once crossed, remaining turns are skipped
37
+ gracefully (recorded as `skipped: budget` in telemetry, synthesis still runs),
38
+ so the moderator can see what was dropped and re-invoke with more headroom.
39
+ - Identity and telemetry are returned in `structuredContent`, not embedded in
40
+ answer text.
41
+ - Returns the model's text response; on failure returns an MCP error result the
42
+ AI can react to.
43
+ - Colored, level-gated logging goes to **stderr** (safe for stdio), with a
44
+ pluggable sink for future DB/API logging.
45
+
46
+ ## Design decisions
47
+
48
+ - **No provider adapters.** There is no provider-specific code and no built-in
49
+ model list. Legion speaks one wire format; models that don't speak it natively
50
+ go through a gateway. Supporting a new model requires no change here.
51
+ - **Models are config, not code.** Adding a model means adding a JSON file. The
52
+ directory is re-read per request, so no rebuild or restart.
53
+ - **One tool per model.** Each model appears to the calling AI as its own tool
54
+ with its own description, rather than a single tool with a model parameter.
55
+ The `quorum` tool covers the multi-model case.
56
+ - **Stateless.** Every call is one-shot with `store: false`. Nothing is
57
+ persisted, so there is no database and no conversation state to manage.
58
+ - **Small.** A few hundred lines of TypeScript, one bundled output file, six
59
+ dependencies.
60
+
61
+ ## Requirements
62
+
63
+ - Node.js 24+
64
+ - At least one OpenAI-Responses-compatible endpoint (a provider API directly, or
65
+ a gateway such as LiteLLM for models that need bridging)
66
+
67
+ ## Setup
68
+
69
+ ```pwsh
70
+ npm install
71
+ copy .env.example .env # then edit .env
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ All configuration lives in a `config/` directory. The server resolves it in one
77
+ of two ways:
78
+
79
+ - **Installed from npm** (or run from any other directory): if a `config/`
80
+ folder exists in the current working directory, it is used and **overrides all
81
+ the built-in defaults**. Otherwise the defaults bundled with the package are
82
+ used.
83
+ - **Running from the repo:** the repo's own `config/` folder is the working
84
+ directory config.
85
+
86
+ > **Installing from npm? You must supply your own model files.** The bundled
87
+ > config ships only key-free `*.example.json` model files, which the scanner
88
+ > deliberately ignores. With no real model file the server **fails fast at
89
+ > startup** (`No model files found in ...`). Resolution is all-or-nothing at the
90
+ > directory level: a `config/` folder in your working directory replaces the
91
+ > bundled one entirely — it is not merged. So the practical path is to copy the
92
+ > shipped `config/` next to where you run the server, then add at least one
93
+ > `config/models/<name>.json` (see below). Edit the rest freely.
94
+
95
+ Either way the layout below is identical, and everything hot-reloads per
96
+ request.
97
+
98
+ ### Models — `config/models/*.json`
99
+
100
+ At least one model file is **required** — the server fails fast without one.
101
+ Each JSON file becomes a tool, named after the slugified file name
102
+ (`config/models/fable.json` → tool `fable`):
103
+
104
+ ```json
105
+ {
106
+ "model": "claude-fable-5",
107
+ "description": "Claude Fable — fast, creative, general purpose.",
108
+ "baseUrl": "https://api.example.com",
109
+ "apiKey": "sk-optional-per-model-key"
110
+ }
111
+ ```
112
+
113
+ - `model` (required) — the deployed model id the endpoint routes to.
114
+ - `description` — helps the calling AI pick the right model.
115
+ - `system` — optional baseline system instructions baked into every call to
116
+ this model.
117
+ - `baseUrl` / `apiKey` — optional; omitted values fall back to
118
+ `DEFAULT_BASE_URL` / `DEFAULT_API_KEY`.
119
+ - `omitParams` — optional list of request params to drop for this model, e.g.
120
+ `["temperature"]`. The server stays provider-agnostic: it never assumes which
121
+ models reject which params — you declare each model's quirks here. Useful for
122
+ reasoning models and some deployments that reject `temperature`.
123
+
124
+ **Hot-drop:** the directory is re-scanned per request — add or edit a model
125
+ file and it's live on the next call, no restart.
126
+
127
+ **Secrets & git:** model files can contain API keys, so `config/models/*.json`
128
+ is git-ignored. Copy a `*.example.json` (tracked, key-free, ignored by the
129
+ scanner) to get started:
130
+
131
+ ```pwsh
132
+ copy config\models\gpt.example.json config\models\gpt.json # then add your key
133
+ ```
134
+
135
+ ### Roles — `config/roles/*.md`
136
+
137
+ Optional hot-droppable instruction files. Each `.md` file becomes a named role
138
+ (slugified from filename). Drop a file, it's live on the next call. This repo
139
+ ships `skeptic.md`, `builder.md`, `judge.md`, and `short.md` (a terse "answer
140
+ immediately, no deliberation" role useful for constrained-output turns) as
141
+ ready-to-use starters — edit or delete them freely (they hold no secrets).
142
+
143
+ Available selectors in tools become `roleName`, e.g. passing `role: "skeptic"`
144
+ or using `"model:skeptic"` in `quorum.models`.
145
+
146
+ ### AI guidance — `config/description.md`
147
+
148
+ Optional markdown served to clients as MCP `instructions` — describe your
149
+ models and when the AI should use each. See this repo's copy for a template.
150
+
151
+ ### Tool, field & message text — `config/*.json` and `config/tools/*.md`
152
+
153
+ All user-facing text lives in config, not code, and hot-reloads per request:
154
+
155
+ - `config/tools/<tool>.md` — a tool's description (e.g.
156
+ `config/tools/quorum.md`). For `quorum`, the live `Available models:` line is
157
+ always appended automatically. Delete the file to fall back to a built-in
158
+ string.
159
+ - `config/schema.json` — **the source of all input-field descriptions** (no
160
+ descriptions are hardcoded in the server). Grouped into sections (`prompt` for
161
+ shared model-tool fields, `quorum` for quorum-only fields) since JSON can't
162
+ carry comments; flattened into one lookup at load, so a `quorum` key overrides
163
+ a `prompt` key of the same name. Omit a key and that field simply ships
164
+ without a description.
165
+ - `config/prompts.json` — the **prompt-shaping templates the models actually
166
+ read**: the role contract wrapper (`roleContract`), the context block
167
+ (`contextBlock`), the transcript header (`transcriptBlock`), and the quorum
168
+ round banners (`roundExploring`, `roundFinal`, `synthesis`). Tokens in
169
+ `{braces}` (`{role}`, `{instructions}`, `{prompt}`, `{context}`,
170
+ `{transcript}`, `{round}`, `{rounds}`) are filled at runtime. Omit any key or
171
+ the whole file to use built-in defaults. This is where you tune how strongly
172
+ roles bind, how context is framed, and how rounds are announced.
173
+ - `config/errors.json` — the **runtime error messages** shown to the calling
174
+ AI: `unknownRole`, `unknownSelector`, `adhocDisabled`, `adhocEmptyName`,
175
+ `unresolvableSelector`, `modelFailed`. Tokens in `{braces}` (`{role}`,
176
+ `{available}`, `{selector}`, `{model}`, `{message}`) are filled at runtime.
177
+ Omit any key or the whole file to use built-in defaults.
178
+ (Startup/config-validation errors stay in code — a message that reports a
179
+ broken config file can't live inside it.)
180
+
181
+ ### Environment variables
182
+
183
+ | Variable | Required | Description |
184
+ | --- | --- | --- |
185
+ | `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. |
186
+ | `DEFAULT_API_KEY` | no* | API key for models without an `apiKey`. Stays server-side. |
187
+ | `HOST` | no | HTTP bind address (default `127.0.0.1`). Set `0.0.0.0` to expose — then set `ALLOWED_HOSTS`. |
188
+ | `ALLOWED_HOSTS` | no | Comma-separated hostnames for DNS-rebinding protection on non-localhost binds. |
189
+ | `PORT` | no | HTTP port (default `5000`; ignored by stdio). |
190
+ | `MAX_ROUNDS` | no | Max discussion rounds the `quorum` tool accepts (default `5`). |
191
+ | `TOKEN_BUDGET` | no | Default cumulative token ceiling for a whole `quorum` run. Unset = no limit. A per-call `tokenBudget` overrides it. |
192
+ | `DYNAMIC_ROLES` | no | Allow the calling AI to define ad-hoc `quorum` roles inline (default `true`). |
193
+ | `LOG_LEVEL` | no | `debug` \| `info` \| `warn` \| `error` (default `info`). |
194
+
195
+ \* Every model must resolve a `baseUrl` and `apiKey` from its file or the
196
+ defaults — validated at startup.
197
+
198
+ The server **fails fast** at startup on a missing/empty models directory,
199
+ invalid model files, an unresolvable endpoint or key, or two file names that
200
+ slugify to the same tool.
201
+
202
+ ### Routing
203
+
204
+ Every tool call is a stateless, one-shot Responses API request (`store: false`
205
+ — nothing is persisted anywhere). Models whose endpoints natively support the
206
+ Responses API (OpenAI, Azure OpenAI / Foundry) set `baseUrl` (and optionally
207
+ `apiKey`) to be called **directly**; models that don't (Claude, Gemini, Llama,
208
+ …) fall back to the defaults — typically an OpenAI-compatible gateway like
209
+ LiteLLM that bridges Responses to their native APIs. Because nothing multi-turn
210
+ is used, such a gateway needs **no database** for this workload.
211
+
212
+ ## Logging
213
+
214
+ - `info` (blue): server start and one metadata line per model call — model,
215
+ latency, token usage, role, context presence. No prompt/response content.
216
+ - `debug` (gray): additionally logs the full prompt and response (context is
217
+ noted as present, not printed).
218
+ - `warn` (orange) / `error` (red): fallbacks and failures.
219
+
220
+ Color is auto-disabled when stderr is not a TTY.
221
+
222
+ ## Run
223
+
224
+ One entrypoint, transport as an argument (`stdio` is the default):
225
+
226
+ Development (no build step, via `tsx`):
227
+
228
+ ```pwsh
229
+ npm run dev # stdio transport
230
+ npm run dev:http # Streamable HTTP transport on :$PORT/mcp
231
+ ```
232
+
233
+ Production (compiled to `bin/server.js`):
234
+
235
+ ```pwsh
236
+ npm run build
237
+ npm start # node bin/server.js (stdio)
238
+ npm run start:http # node bin/server.js http
239
+ ```
240
+
241
+ ## Try it
242
+
243
+ List the tools with the MCP Inspector:
244
+
245
+ ```pwsh
246
+ npx @modelcontextprotocol/inspector npx tsx ts/server.ts
247
+ ```
248
+
249
+ ## Use in VS Code
250
+
251
+ Add to your `mcp.json`:
252
+
253
+ ```json
254
+ {
255
+ "servers": {
256
+ "legion": {
257
+ "command": "node",
258
+ "args": ["bin/server.js"],
259
+ "cwd": "path/to/legion",
260
+ "env": {
261
+ "DEFAULT_BASE_URL": "https://your-gateway.example.com",
262
+ "DEFAULT_API_KEY": "sk-your-key"
263
+ }
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ For the HTTP transport, point your client at `http://<host>:<PORT>/mcp`.
270
+
271
+ ### Health
272
+
273
+ - `GET /health` — cheap **liveness**: confirms the process is up and config
274
+ loaded. Returns `{ status: "ok", name, version, models }` (a count). Makes no
275
+ external calls. This is what container `HEALTHCHECK`s and Kubernetes
276
+ liveness/readiness probes should hit.
277
+ - `GET /health?deep` — optional **connectivity** check: fans a tiny one-token
278
+ prompt to every model and returns a per-model `{ name, model, ok, latencyMs }`
279
+ array. `200` when all reachable, `503` (`status: "degraded"`) if any fail.
280
+ Every model reaching its endpoint counts as reachable — even reasoning models
281
+ that spend the whole budget thinking and emit no text. **Do not** wire this to
282
+ an automatic probe: it makes a real (billable) request per model on every hit,
283
+ and a downstream outage would needlessly restart a perfectly healthy
284
+ container. Use it manually or from a monitoring dashboard.
285
+
286
+ ## Deploy
287
+
288
+ Ready-to-use container deployment examples (Azure App Service, Azure Container
289
+ Apps, Docker Compose, Kubernetes, and Compose + Caddy for HTTPS) live in
290
+ [`examples/`](examples/) — each installs Legion from npm and ships a complete
291
+ drop-in `config/`.
package/bin/server.js ADDED
@@ -0,0 +1,519 @@
1
+ // ts/server.ts
2
+ import { createMcpExpressApp } from "@modelcontextprotocol/express";
3
+ import { toNodeHandler } from "@modelcontextprotocol/node";
4
+ import { createMcpHandler } from "@modelcontextprotocol/server";
5
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
6
+
7
+ // ts/log.ts
8
+ var log = (level, message, meta) => sink.level(level, message, meta);
9
+ var banner = (message) => console.error(stamp(process.stderr.isTTY === true ? `\x1B[1;32m${message}\x1B[0m` : message));
10
+ var logPrompt = (entry) => sink.prompt(entry);
11
+ var okStatus = (r) => [r.truncated ? "truncated" : "", r.reasoningHeavy ? "reasoning-heavy" : ""].filter(Boolean).join(", ") || "ok";
12
+ var promptEntry = (def, input, outcome, toolName) => ({
13
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
14
+ toolName,
15
+ modelName: def.name,
16
+ modelId: def.model,
17
+ params: { temperature: input.temperature, maxTokens: input.maxTokens, systemPresent: input.system !== void 0, contextPresent: input.context !== void 0, role: input.role },
18
+ usage: outcome.usage ?? {},
19
+ latencyMs: outcome.latencyMs ?? 0,
20
+ prompt: input.prompt,
21
+ response: outcome.response ?? "",
22
+ ...outcome.error === void 0 ? {} : { error: outcome.error }
23
+ });
24
+ var setLogLevel = (level) => {
25
+ threshold = order[level];
26
+ };
27
+ var colorize = (level, text) => enabled ? `\x1B[${codes[level]}m${text}\x1B[0m` : text;
28
+ var isDev = process.argv[1]?.endsWith(".ts") === true;
29
+ var stamp = (text) => isDev ? `${localNow()} ${text}` : text;
30
+ var localNow = () => {
31
+ const d = /* @__PURE__ */ new Date();
32
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
33
+ };
34
+ var pad = (n) => String(n).padStart(2, "0");
35
+ var codes = {
36
+ debug: "90",
37
+ info: "34",
38
+ warn: "38;5;208",
39
+ error: "31"
40
+ };
41
+ var enabled = process.stderr.isTTY === true;
42
+ var order = { debug: 0, info: 1, warn: 2, error: 3 };
43
+ var consoleSink = {
44
+ level(level, message, meta) {
45
+ if (order[level] < threshold) return;
46
+ const suffix = meta && Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : "";
47
+ console.error(stamp(colorize(level, `${message}${suffix}`)));
48
+ },
49
+ prompt(entry) {
50
+ const { toolName, modelId, latencyMs, usage, params } = entry;
51
+ this.level("info", `\u{1F5E3}\uFE0F ${toolName} responded`, { modelId, latencyMs, usage, role: params.role, contextPresent: params.contextPresent });
52
+ if (order.debug < threshold) return;
53
+ console.error(stamp(colorize("debug", ` \u{1F4AC} prompt: ${entry.prompt}`)));
54
+ entry.params.role && console.error(stamp(colorize("debug", ` \u{1F3AD} role: ${entry.params.role}`)));
55
+ entry.params.contextPresent && console.error(stamp(colorize("debug", ` \u{1F4CE} context: (present \u2014 see structuredContent)`)));
56
+ console.error(stamp(colorize("debug", ` \u2705 response: ${entry.response}`)));
57
+ }
58
+ };
59
+ var threshold = order.info;
60
+ var sink = consoleSink;
61
+
62
+ // ts/bootstrap.ts
63
+ import { McpServer } from "@modelcontextprotocol/server";
64
+
65
+ // ts/config.ts
66
+ import { readdirSync, readFileSync as readFileSync2 } from "fs";
67
+ import { basename, join as join2 } from "path";
68
+ import * as z from "zod/v4";
69
+
70
+ // ts/configText.ts
71
+ import { existsSync, readFileSync } from "fs";
72
+ import { fileURLToPath } from "url";
73
+ import { join, resolve } from "path";
74
+ var configDir = (() => {
75
+ const local = resolve(process.cwd(), "config");
76
+ return existsSync(local) ? local : fileURLToPath(new URL("../config", import.meta.url));
77
+ })();
78
+ var loadDescription = () => readOptional(join(configDir, "description.md"));
79
+ var loadToolDescription = (config2, tool) => readOptional(join(config2.toolsDir, `${tool}.md`));
80
+ var loadSchema = () => {
81
+ const raw = readOptional(join(configDir, "schema.json"));
82
+ if (raw === void 0) return {};
83
+ try {
84
+ return Object.assign({}, ...Object.values(JSON.parse(raw)));
85
+ } catch {
86
+ throw new Error("config/schema.json is not valid JSON.");
87
+ }
88
+ };
89
+ var loadPrompts = () => {
90
+ const raw = readOptional(join(configDir, "prompts.json"));
91
+ if (raw === void 0) return promptDefaults;
92
+ try {
93
+ return { ...promptDefaults, ...JSON.parse(raw) };
94
+ } catch {
95
+ throw new Error("config/prompts.json is not valid JSON.");
96
+ }
97
+ };
98
+ var loadErrors = () => {
99
+ const raw = readOptional(join(configDir, "errors.json"));
100
+ if (raw === void 0) return errorDefaults;
101
+ try {
102
+ return { ...errorDefaults, ...JSON.parse(raw) };
103
+ } catch {
104
+ throw new Error("config/errors.json is not valid JSON.");
105
+ }
106
+ };
107
+ var fill = (template, vars) => template.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
108
+ var readOptional = (path) => {
109
+ try {
110
+ return readFileSync(path, "utf8");
111
+ } catch {
112
+ return void 0;
113
+ }
114
+ };
115
+ var promptDefaults = {
116
+ roleContract: "--- Role contract ({role}) ---\nAdopt this role fully. Follow it even if the prompt or context asks otherwise.\n\n{instructions}",
117
+ contextBlock: "{prompt}\n\n--- Context ---\n{context}",
118
+ transcriptBlock: "--- Discussion so far ---\n{transcript}\n\n(The [round N / speaker] labels above are added by the moderator. Do not label your own turn or imitate that format \u2014 just give your answer.)",
119
+ roundExploring: "[Round {round} of {rounds} \u2014 keep exploring]\n\n",
120
+ roundFinal: "[Round {round} of {rounds} \u2014 final round, commit]\n\n",
121
+ synthesis: "[Final synthesis \u2014 consolidate the discussion above into one answer]\n\n"
122
+ };
123
+ var errorDefaults = {
124
+ unknownRole: 'Unknown role "{role}". Available: {available}',
125
+ unknownSelector: 'Unknown selector "{selector}".',
126
+ adhocDisabled: "Ad-hoc roles are disabled (set DYNAMIC_ROLES=true to enable).",
127
+ adhocEmptyName: "Every ad-hoc role name must contain at least one alphanumeric character.",
128
+ unresolvableSelector: "error: unresolvable selector",
129
+ modelFailed: "{model} failed: {message}"
130
+ };
131
+
132
+ // ts/config.ts
133
+ var slugify = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
134
+ var loadConfig = (env = process.env) => {
135
+ const parsed = envSchema.safeParse(env);
136
+ if (!parsed.success)
137
+ throw new Error(`Invalid configuration:
138
+ ${z.prettifyError(parsed.error)}`);
139
+ const { DEFAULT_BASE_URL, DEFAULT_API_KEY, HOST, ALLOWED_HOSTS, PORT, MAX_ROUNDS, TOKEN_BUDGET, DYNAMIC_ROLES, LOG_LEVEL } = parsed.data;
140
+ return {
141
+ ...readPackage(),
142
+ modelsDir: join2(configDir, "models"),
143
+ rolesDir: join2(configDir, "roles"),
144
+ toolsDir: join2(configDir, "tools"),
145
+ defaultBaseUrl: DEFAULT_BASE_URL?.replace(/\/+$/, ""),
146
+ defaultApiKey: DEFAULT_API_KEY,
147
+ host: HOST,
148
+ allowedHosts: ALLOWED_HOSTS?.split(",").map((h) => h.trim()).filter(Boolean),
149
+ port: PORT,
150
+ maxRounds: MAX_ROUNDS,
151
+ tokenBudget: TOKEN_BUDGET,
152
+ dynamicRoles: DYNAMIC_ROLES === "true",
153
+ logLevel: LOG_LEVEL
154
+ };
155
+ };
156
+ var loadRoles = (config2) => {
157
+ let files;
158
+ try {
159
+ files = readdirSync(config2.rolesDir).filter((f) => f.endsWith(".md"));
160
+ } catch {
161
+ return [];
162
+ }
163
+ const roles = files.map((f) => ({ name: slugify(basename(f, ".md")), instructions: readFileSync2(join2(config2.rolesDir, f), "utf8") })), seen = /* @__PURE__ */ new Set();
164
+ roles.forEach((r) => seen.has(r.name) ? (() => {
165
+ throw new Error(`Role slug collision: "${r.name}". Rename one.`);
166
+ })() : seen.add(r.name));
167
+ return roles;
168
+ };
169
+ var loadModels = (config2) => {
170
+ let files;
171
+ try {
172
+ files = readdirSync(config2.modelsDir).filter((f) => f.endsWith(".json") && !f.endsWith(".example.json"));
173
+ } catch {
174
+ throw new Error(`Models directory "${config2.modelsDir}" not found.`);
175
+ }
176
+ if (!files.length)
177
+ throw new Error(`No model files found in "${config2.modelsDir}". Add e.g. ${config2.modelsDir}/fable.json`);
178
+ const models2 = files.map((f) => parseModelFile(config2.modelsDir, f));
179
+ assertNoSlugCollisions(models2);
180
+ assertResolvable(models2, config2.defaultBaseUrl, config2.defaultApiKey);
181
+ return models2;
182
+ };
183
+ var modelSchema = z.object({
184
+ model: z.string().min(1),
185
+ description: z.string().optional(),
186
+ system: z.string().optional(),
187
+ baseUrl: z.url().optional(),
188
+ apiKey: z.string().min(1).optional(),
189
+ omitParams: z.array(z.string()).optional()
190
+ });
191
+ var envSchema = z.object({
192
+ DEFAULT_BASE_URL: z.url("DEFAULT_BASE_URL must be a valid URL").optional(),
193
+ DEFAULT_API_KEY: z.string().min(1).optional(),
194
+ HOST: z.string().min(1).default("127.0.0.1"),
195
+ ALLOWED_HOSTS: z.string().optional(),
196
+ PORT: z.coerce.number().int().positive().default(5e3),
197
+ MAX_ROUNDS: z.coerce.number().int().positive().default(5),
198
+ TOKEN_BUDGET: z.coerce.number().int().positive().optional(),
199
+ DYNAMIC_ROLES: z.enum(["true", "false"]).default("true"),
200
+ LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info")
201
+ });
202
+ var readPackage = () => {
203
+ const { name = "mcp-server", version = "0.0.0" } = JSON.parse(readOptional(new URL("../package.json", import.meta.url)) ?? "{}");
204
+ return { name, version };
205
+ };
206
+ var parseModelFile = (dir, file) => {
207
+ let json;
208
+ try {
209
+ json = JSON.parse(readFileSync2(join2(dir, file), "utf8"));
210
+ } catch {
211
+ throw new Error(`${file} is not valid JSON.`);
212
+ }
213
+ const result = modelSchema.safeParse(json);
214
+ if (!result.success)
215
+ throw new Error(`Invalid ${file}:
216
+ ${z.prettifyError(result.error)}`);
217
+ return { name: basename(file, ".json"), ...result.data };
218
+ };
219
+ var assertNoSlugCollisions = (models2) => {
220
+ const seen = /* @__PURE__ */ new Map();
221
+ for (const m of models2) {
222
+ const slug = slugify(m.name), prior = seen.get(slug);
223
+ if (prior)
224
+ throw new Error(`Model files "${prior}" and "${m.name}" both map to tool "${slug}". Rename one.`);
225
+ seen.set(slug, m.name);
226
+ }
227
+ };
228
+ var assertResolvable = (models2, baseUrl, apiKey) => {
229
+ for (const m of models2) {
230
+ if (!m.baseUrl && !baseUrl) throw new Error(`Model "${m.name}" has no baseUrl and DEFAULT_BASE_URL is not set.`);
231
+ if (!m.apiKey && !apiKey) throw new Error(`Model "${m.name}" has no apiKey and DEFAULT_API_KEY is not set.`);
232
+ }
233
+ };
234
+
235
+ // ts/llm.ts
236
+ import OpenAI from "openai";
237
+ var emptyOutputError = "no output before hitting maxTokens";
238
+ var createPrompt = (config2) => {
239
+ const clients = /* @__PURE__ */ new Map(), clientFor = (def) => {
240
+ const url2 = (def.baseUrl ?? config2.defaultBaseUrl).replace(/\/+$/, ""), apiKey = def.apiKey ?? config2.defaultApiKey, key = `${url2}|${apiKey}`;
241
+ !clients.has(key) && clients.set(key, new OpenAI({ baseURL: url2, apiKey }));
242
+ return clients.get(key);
243
+ };
244
+ const attempt = async (def, input, roles, templates) => {
245
+ const started = performance.now(), params = {
246
+ model: def.model,
247
+ input: composeInput(input, templates),
248
+ store: false,
249
+ max_output_tokens: input.maxTokens ?? defaultMaxTokens,
250
+ ...composeInstructions(def, input, roles, templates),
251
+ ...input.temperature === void 0 ? {} : { temperature: input.temperature }
252
+ };
253
+ for (const key of def.omitParams ?? []) delete params[key];
254
+ const res = await clientFor(def).responses.create(params);
255
+ return { res, text: res.output_text ?? "", started };
256
+ };
257
+ return async (def, input, roles = [], templates = loadPrompts()) => {
258
+ let { res, text, started } = await attempt(def, input, roles, templates);
259
+ if (res.status !== "completed" && text === "")
260
+ ({ res, text, started } = await attempt(def, input, roles, templates));
261
+ if (res.status !== "completed" && text === "")
262
+ throw new Error(incompleteMessage(res.status, res.incomplete_details?.reason));
263
+ const reasoningTokens = res.usage?.output_tokens_details?.reasoning_tokens, visibleTokens = (res.usage?.output_tokens ?? 0) - (reasoningTokens ?? 0), reasoningHeavy = !!reasoningTokens && reasoningTokens >= visibleTokens;
264
+ return {
265
+ text,
266
+ usage: {
267
+ inputTokens: res.usage?.input_tokens,
268
+ outputTokens: res.usage?.output_tokens,
269
+ totalTokens: res.usage?.total_tokens,
270
+ ...reasoningTokens ? { reasoningTokens } : {}
271
+ },
272
+ latencyMs: Math.round(performance.now() - started),
273
+ ...res.status === "completed" ? {} : { truncated: true },
274
+ ...reasoningHeavy ? { reasoningHeavy: true } : {}
275
+ };
276
+ };
277
+ };
278
+ var defaultMaxTokens = 8192;
279
+ var incompleteMessage = (status, reason) => reason && reason !== "max_output_tokens" ? `response ${status ?? "incomplete"} (${reason})` : `${emptyOutputError} \u2014 raise maxTokens (reasoning models can spend the full budget thinking before emitting any text)`;
280
+ var composeInput = (input, t) => input.context === void 0 ? input.prompt : fill(t.contextBlock, { prompt: input.prompt, context: input.context });
281
+ var composeInstructions = (def, input, roles, t) => {
282
+ const role = input.role === void 0 ? void 0 : roles.find((r) => r.name === input.role), roleContract = role ? fill(t.roleContract, { role: role.name, instructions: role.instructions }) : void 0, parts = [def.system, input.system, roleContract].filter(Boolean);
283
+ return parts.length ? { instructions: parts.join("\n\n") } : {};
284
+ };
285
+
286
+ // ts/health.ts
287
+ var makeProbe = (config2) => {
288
+ const prompt = createPrompt(config2);
289
+ return async () => {
290
+ const models2 = await Promise.all(loadModels(config2).map(async (def) => {
291
+ const started = performance.now();
292
+ try {
293
+ await prompt(def, { prompt: "ping", maxTokens: probeTokens });
294
+ return { name: def.name, model: def.model, ok: true, latencyMs: Math.round(performance.now() - started) };
295
+ } catch (e) {
296
+ const message = e instanceof Error ? e.message : String(e);
297
+ return message.startsWith(emptyOutputError) ? { name: def.name, model: def.model, ok: true, latencyMs: Math.round(performance.now() - started) } : { name: def.name, model: def.model, ok: false, latencyMs: Math.round(performance.now() - started), error: message };
298
+ }
299
+ }));
300
+ return { status: models2.every((m) => m.ok) ? "ok" : "degraded", name: config2.name, version: config2.version, models: models2 };
301
+ };
302
+ };
303
+ var probeTokens = 16;
304
+
305
+ // ts/tools.ts
306
+ import * as z2 from "zod/v4";
307
+
308
+ // ts/quorumHelpers.ts
309
+ var resolve2 = (selector, models2, roles) => {
310
+ const [modelPart, rolePart] = selector.split(":", 2), def = models2.find((m) => slugify(m.name) === modelPart);
311
+ if (!def) return null;
312
+ if (rolePart !== void 0 && !roles.find((r) => r.name === rolePart)) return null;
313
+ return { def, role: rolePart };
314
+ };
315
+ var dedup = (selectors) => [...new Set(selectors)];
316
+ var banner2 = (round, rounds, isSynthesis, t) => isSynthesis ? t.synthesis : rounds < 2 ? "" : fill(round < rounds ? t.roundExploring : t.roundFinal, { round, rounds });
317
+ var toContext = (turns, t, callerContext) => {
318
+ if (!turns.length) return callerContext;
319
+ const transcript = turns.map((turn) => `[round ${turn.round} / ${turn.selector}]
320
+ ${turn.text}`).join("\n\n"), block = fill(t.transcriptBlock, { transcript });
321
+ return callerContext ? `${callerContext}
322
+
323
+ ${block}` : block;
324
+ };
325
+
326
+ // ts/quorum.ts
327
+ var runQuorum = async (args, models2, roles, prompt, maxRounds, dynamicRoles, templates, errors, tokenBudget) => {
328
+ const err = (text) => ({ content: [{ type: "text", text }], isError: true });
329
+ if (args.roles && Object.keys(args.roles).length && !dynamicRoles)
330
+ return err(errors.adhocDisabled);
331
+ const adHoc = Object.entries(args.roles ?? {}).map(([name, instructions]) => ({ name: slugify(name), instructions }));
332
+ if (adHoc.some((r) => !r.name))
333
+ return err(errors.adhocEmptyName);
334
+ const effectiveRoles = [...roles.filter((r) => !adHoc.some((a) => a.name === r.name)), ...adHoc], rounds = Math.min(maxRounds, Math.max(1, args.rounds ?? 1)), mode = args.mode ?? "sequential", selectors = dedup(args.models), telemetry = [], turns = [], content = [];
335
+ const resolved = selectors.map((s) => resolve2(s, models2, effectiveRoles)), badIndex = resolved.indexOf(null);
336
+ if (badIndex !== -1)
337
+ return err(fill(errors.unknownSelector, { selector: selectors[badIndex] }));
338
+ let used = 0;
339
+ const skip = (round) => resolved.forEach((r, i) => telemetry.push({ selector: selectors[i], modelName: r.def.name, modelId: r.def.model, role: r.role, round, isSynthesis: false, usage: {}, latencyMs: 0, status: "skipped: budget" }));
340
+ const speakOne = async (selector, round, isSynthesis, extraContext) => {
341
+ const r = resolve2(selector, models2, effectiveRoles), roleInput = {
342
+ prompt: banner2(round, rounds, isSynthesis, templates) + args.prompt,
343
+ system: args.system,
344
+ temperature: args.temperature,
345
+ maxTokens: args.maxTokens,
346
+ role: r.role,
347
+ context: extraContext ?? args.context
348
+ }, started = performance.now();
349
+ try {
350
+ const result = await prompt(r.def, roleInput, effectiveRoles, templates), ci = content.length;
351
+ used += result.usage.totalTokens ?? 0;
352
+ content.push({ type: "text", text: result.text });
353
+ logPrompt(promptEntry(r.def, roleInput, { response: result.text, usage: result.usage, latencyMs: result.latencyMs }, selector));
354
+ telemetry.push({ selector, modelName: r.def.name, modelId: r.def.model, role: r.role, round, isSynthesis, usage: result.usage, latencyMs: result.latencyMs, status: okStatus(result), contentIndex: ci });
355
+ return result.text;
356
+ } catch (err2) {
357
+ const message = err2 instanceof Error ? err2.message : String(err2), latencyMs = Math.round(performance.now() - started);
358
+ logPrompt(promptEntry(r.def, roleInput, { error: message, latencyMs }, selector));
359
+ telemetry.push({ selector, modelName: r.def.name, modelId: r.def.model, role: r.role, round, isSynthesis, usage: {}, latencyMs, status: `error: ${message}` });
360
+ return null;
361
+ }
362
+ };
363
+ for (let round = 1; round <= rounds; round++) {
364
+ if (tokenBudget && used >= tokenBudget) {
365
+ for (let r = round; r <= rounds; r++) skip(r);
366
+ log("warn", `\u26A0\uFE0F token budget ${tokenBudget} exceeded (${used}) \u2014 skipping remaining turns`);
367
+ break;
368
+ }
369
+ if (mode === "sequential") {
370
+ for (const selector of selectors) {
371
+ const ctx = toContext(turns, templates, args.context), text = await speakOne(selector, round, false, ctx);
372
+ text !== null && turns.push({ selector, round, text });
373
+ }
374
+ } else {
375
+ const prevCtx = toContext(turns, templates, args.context), results = await Promise.allSettled(selectors.map((s) => speakOne(s, round, false, prevCtx)));
376
+ results.forEach((r, i) => {
377
+ r.status === "fulfilled" && r.value !== null && turns.push({ selector: selectors[i], round, text: r.value });
378
+ });
379
+ }
380
+ }
381
+ if (args.synthesize !== void 0) {
382
+ const synthResolved = resolve2(args.synthesize, models2, effectiveRoles);
383
+ if (!synthResolved)
384
+ telemetry.push({ selector: args.synthesize, modelName: "", modelId: "", round: 0, isSynthesis: true, usage: {}, latencyMs: 0, status: errors.unresolvableSelector });
385
+ else {
386
+ const synthText = await speakOne(args.synthesize, 0, true, toContext(turns, templates, args.context));
387
+ synthText !== null && turns.push({ selector: args.synthesize, round: 0, text: synthText });
388
+ const synthTurn = telemetry[telemetry.length - 1];
389
+ synthTurn?.status.includes("reasoning-heavy") && log("warn", `\u26A0\uFE0F synthesis (${args.synthesize}) spent most of its budget reasoning \u2014 raise maxTokens or use a lighter model for synthesize`);
390
+ }
391
+ }
392
+ return {
393
+ content,
394
+ structuredContent: {
395
+ turns: telemetry,
396
+ transcript: toContext(turns, templates) ?? "",
397
+ ...tokenBudget ? { budget: { limit: tokenBudget, used, exceeded: used > tokenBudget } } : {}
398
+ },
399
+ isError: content.length === 0
400
+ };
401
+ };
402
+
403
+ // ts/tools.ts
404
+ var registerModelTools = (server, models2, roles, prompt, errors, schema = {}) => {
405
+ const inputSchema = buildInputSchema(schema);
406
+ for (const def of models2) {
407
+ const toolName = slugify(def.name), description = def.description ?? `Prompt the ${def.name} model (${def.model}).`;
408
+ server.registerTool(toolName, { description, inputSchema }, async (input) => {
409
+ if (input.role !== void 0 && !roles.find((r) => r.name === input.role))
410
+ return { content: [{ type: "text", text: fill(errors.unknownRole, { role: input.role, available: roles.map((r) => r.name).join(", ") || "none" }) }], isError: true };
411
+ try {
412
+ const result = await prompt(def, input);
413
+ logPrompt(promptEntry(def, input, { response: result.text, usage: result.usage, latencyMs: result.latencyMs }, toolName));
414
+ return {
415
+ content: [{ type: "text", text: result.text }],
416
+ structuredContent: { tool: toolName, modelName: def.name, modelId: def.model, role: input.role, usage: result.usage, latencyMs: result.latencyMs, status: okStatus(result) }
417
+ };
418
+ } catch (err) {
419
+ const message = err instanceof Error ? err.message : String(err);
420
+ logPrompt(promptEntry(def, input, { error: message }, toolName));
421
+ return { content: [{ type: "text", text: fill(errors.modelFailed, { model: def.name, message }) }], isError: true };
422
+ }
423
+ });
424
+ }
425
+ };
426
+ var registerQuorumTool = (server, models2, roles, prompt, maxRounds, dynamicRoles, templates, errors, tokenBudget, description, schema = {}) => {
427
+ const d = (key) => schema[key] ?? "", names = models2.map((m) => slugify(m.name)), quorumSchema = z2.object({
428
+ models: z2.array(z2.string()).min(2).describe(`${d("models")} Available models: ${names.join(", ")}`),
429
+ roles: z2.record(z2.string(), z2.string()).optional().describe(d("roles")),
430
+ rounds: z2.number().int().min(1).max(maxRounds).optional().describe(d("rounds")),
431
+ mode: z2.enum(["sequential", "parallel"]).optional().describe(d("mode")),
432
+ synthesize: z2.string().optional().describe(d("synthesize")),
433
+ tokenBudget: z2.number().int().positive().optional().describe(d("tokenBudget")),
434
+ ...buildInputSchema(schema).omit({ role: true }).shape
435
+ });
436
+ const fallback = "Fan a prompt out to two or more models (see config/tools/quorum.md).";
437
+ server.registerTool(
438
+ "quorum",
439
+ {
440
+ description: `${description ?? fallback}
441
+
442
+ Available models: ${names.join(", ")}.`,
443
+ inputSchema: quorumSchema
444
+ },
445
+ (args) => runQuorum(args, models2, roles, prompt, maxRounds, dynamicRoles, templates, errors, args.tokenBudget ?? tokenBudget)
446
+ );
447
+ };
448
+ var buildInputSchema = (schema = {}) => {
449
+ const d = (key) => schema[key] ?? "";
450
+ return z2.object({
451
+ prompt: z2.string().min(1).describe(d("prompt")),
452
+ context: z2.string().optional().describe(d("context")),
453
+ role: z2.string().optional().describe(d("role")),
454
+ system: z2.string().optional().describe(d("system")),
455
+ temperature: z2.number().min(0).max(2).optional().describe(d("temperature")),
456
+ maxTokens: z2.number().int().positive().optional().describe(d("maxTokens"))
457
+ });
458
+ };
459
+
460
+ // ts/bootstrap.ts
461
+ var bootstrap = () => {
462
+ const config2 = loadConfig(), models2 = loadModels(config2);
463
+ setLogLevel(config2.logLevel);
464
+ return { config: config2, models: models2, createServer: makeServerFactory(config2), probe: makeProbe(config2) };
465
+ };
466
+ var makeServerFactory = (config2) => {
467
+ const basePrompt = createPrompt(config2);
468
+ return () => {
469
+ const description = loadDescription(), server = new McpServer(
470
+ { name: config2.name, version: config2.version },
471
+ description === void 0 ? {} : { instructions: description }
472
+ ), models2 = loadModels(config2), roles = loadRoles(config2), schema = loadSchema(), templates = loadPrompts(), errors = loadErrors(), prompt = (def, input, override, tpl) => basePrompt(def, input, override ?? roles, tpl ?? templates);
473
+ registerModelTools(server, models2, roles, prompt, errors, schema);
474
+ registerQuorumTool(server, models2, roles, prompt, config2.maxRounds, config2.dynamicRoles, templates, errors, config2.tokenBudget, loadToolDescription(config2, "quorum"), schema);
475
+ return server;
476
+ };
477
+ };
478
+
479
+ // ts/server.ts
480
+ var { config, models, createServer, probe } = (() => {
481
+ try {
482
+ return bootstrap();
483
+ } catch (e) {
484
+ console.error(`\u2716 Fatal: ${e instanceof Error ? e.message : String(e)}`);
485
+ process.exit(1);
486
+ }
487
+ })();
488
+ var transport = process.argv[2] ?? "stdio";
489
+ var displayHost = (host) => host === "127.0.0.1" ? "localhost" : host;
490
+ if (transport === "http") {
491
+ const handler = createMcpHandler(createServer), node = toNodeHandler(handler), app = createMcpExpressApp({
492
+ host: config.host,
493
+ ...config.allowedHosts === void 0 ? {} : { allowedHosts: config.allowedHosts }
494
+ });
495
+ app.get("/health", async (req, res) => {
496
+ if (req.query.deep === void 0)
497
+ return void res.json({ status: "ok", name: config.name, version: config.version, models: models.length });
498
+ const report = await probe();
499
+ res.status(report.status === "ok" ? 200 : 503).json(report);
500
+ });
501
+ app.all("/mcp", (req, res) => void node(req, res, req.body));
502
+ app.listen(config.port, config.host, () => {
503
+ banner(`\u{1F310} ${config.name} v${config.version} listening on http://${displayHost(config.host)}:${config.port}/mcp`);
504
+ log("info", "\u{1F680} transport: http");
505
+ log("info", `\u{1F9E9} models loaded: ${models.length}`);
506
+ });
507
+ process.on("SIGINT", async () => {
508
+ await handler.close();
509
+ process.exit(0);
510
+ });
511
+ } else if (transport === "stdio") {
512
+ void serveStdio(createServer);
513
+ banner(`\u26A1 ${config.name} v${config.version} running`);
514
+ log("info", "\u{1F680} transport: stdio");
515
+ log("info", `\u{1F9E9} models loaded: ${models.length}`);
516
+ } else {
517
+ console.error(`Unknown transport "${transport}". Use "stdio" (default) or "http".`);
518
+ process.exit(1);
519
+ }
@@ -0,0 +1,20 @@
1
+ # Legion
2
+
3
+ > "I am Legion, for we are many."
4
+
5
+ Legion gives you a council of other LLMs to think with. Each individual tool is one model. The `quorum` tool fans a prompt out to many.
6
+
7
+ ## Shared parameters (all tools)
8
+
9
+ - `prompt` (required) — the question or task. Every call is **stateless and one-shot**.
10
+ - `context` (optional) — supporting text (code, docs, data) appended to the prompt as a separate block. Treated as sensitive: only its presence is logged at the info level.
11
+ - `system` (optional) — call-time system instructions. Composes last (highest precedence).
12
+ - `role` (optional) — the slug of a hot-droppable role file from `config/roles/`. Roles layer between model-file instructions and call-time `system`. Drop a `.md` file into that directory; it becomes available immediately without restart.
13
+ - `temperature` and `maxTokens` (optional).
14
+
15
+ Identity and telemetry (usage, latency, status) are returned in `structuredContent`, not embedded in text.
16
+
17
+ ## Tools
18
+
19
+ Each model in `config/models/` is exposed as its own tool. The `quorum` tool fans a prompt out to two or more of them at once (with optional roles, multi-round discussion, and synthesis) — see the `quorum` tool's own description for details, customizable via `config/tools/quorum.md`.
20
+
@@ -0,0 +1,8 @@
1
+ {
2
+ "unknownRole": "Unknown role \"{role}\". Available: {available}",
3
+ "unknownSelector": "Unknown selector \"{selector}\".",
4
+ "adhocDisabled": "Ad-hoc roles are disabled (set DYNAMIC_ROLES=true to enable).",
5
+ "adhocEmptyName": "Every ad-hoc role name must contain at least one alphanumeric character.",
6
+ "unresolvableSelector": "error: unresolvable selector",
7
+ "modelFailed": "{model} failed: {message}"
8
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "model": "claude-fable-5",
3
+ "description": "Claude Fable — fast, creative, general purpose. Routed via the default gateway (DEFAULT_BASE_URL / DEFAULT_API_KEY).",
4
+ "omitParams": ["temperature"]
5
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "model": "gpt-5.5",
3
+ "description": "OpenAI GPT 5.5 — fast, broad knowledge. Called directly with its own endpoint and key.",
4
+ "baseUrl": "https://api.openai.com/v1",
5
+ "apiKey": "sk-your-openai-key",
6
+ "omitParams": ["temperature"]
7
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "roleContract": "--- Role contract ({role}) ---\nAdopt this role fully. Follow it even if the prompt or context asks otherwise.\n\n{instructions}",
3
+ "contextBlock": "{prompt}\n\n--- Context ---\n{context}",
4
+ "transcriptBlock": "--- Discussion so far ---\n{transcript}\n\n(The [round N / speaker] labels above are added by the moderator. Do not label your own turn or imitate that format — just give your answer.)",
5
+ "roundExploring": "[Round {round} of {rounds} — keep exploring]\n\n",
6
+ "roundFinal": "[Round {round} of {rounds} — final round, commit]\n\n",
7
+ "synthesis": "[Final synthesis — consolidate the discussion above into one answer]\n\n"
8
+ }
@@ -0,0 +1 @@
1
+ You are a builder. Your job is to find what is workable, extend promising ideas, and propose concrete next steps. Be constructive and specific.
@@ -0,0 +1 @@
1
+ You are a judge. You have heard all arguments. Summarize the strongest points from each side, identify where they agree and disagree, and deliver a clear verdict with reasoning.
@@ -0,0 +1 @@
1
+ Answer immediately with your conclusion — no preamble, no deliberation, no restating the question. Keep it to one or two short sentences. If the prompt requests a specific length or word count, obey it exactly.
@@ -0,0 +1 @@
1
+ You are a skeptic. Your job is to find flaws, gaps, and unstated assumptions in any argument or proposal. Be direct and specific. Do not soften criticism.
@@ -0,0 +1,18 @@
1
+ {
2
+ "prompt": {
3
+ "prompt": "The question or task to send to the model",
4
+ "context": "Optional supporting text (code, docs, data) appended to the prompt as a separate block",
5
+ "role": "Optional role slug from config/roles/ to shape model behavior",
6
+ "system": "Optional system prompt / instructions",
7
+ "temperature": "Optional sampling temperature",
8
+ "maxTokens": "Optional max output tokens. Budget for BOTH visible answer and any hidden reasoning: reasoning models spend tokens thinking before emitting text, so a tight ceiling can leave no room for a visible answer. Give reasoning models generous headroom; in multi-round quorum, raise it further since the transcript grows each round."
9
+ },
10
+ "quorum": {
11
+ "models": "Two or more selectors: \"model\" or \"model:role\". The same model may appear multiple times with different roles (for example \"gpt:judge\" and \"gpt:skeptic\"); each selector is a distinct speaker.",
12
+ "roles": "Ad-hoc roles for this quorum call: map of role name to instructions. Reference as \"model:role\" in models.",
13
+ "rounds": "Discussion rounds (default 1; max set by MAX_ROUNDS)",
14
+ "mode": "sequential (default) or parallel",
15
+ "synthesize": "Selector for a final synthesis turn after all rounds",
16
+ "tokenBudget": "Optional cumulative token ceiling for the whole run (overrides TOKEN_BUDGET). When exceeded, remaining turns are skipped gracefully; synthesis still runs. See structuredContent.budget."
17
+ }
18
+ }
@@ -0,0 +1,67 @@
1
+ Fan a prompt out to two or more models and get every answer back as a separate content item.
2
+
3
+ ```
4
+ models — array of selectors: "modelSlug" or "modelSlug:roleSlug"
5
+ roles — optional ad-hoc roles for this call: { "name": "instructions" }
6
+ rounds — discussion rounds (default 1; max set by MAX_ROUNDS, default 5)
7
+ mode — "sequential" (default) or "parallel"
8
+ synthesize — selector for a final synthesis turn after all rounds complete
9
+ tokenBudget — optional cumulative token ceiling for the whole run (overrides TOKEN_BUDGET)
10
+ ```
11
+
12
+ **Sequential** (default): speakers take turns in the order given. Each speaker receives the full accumulated transcript so far as context — a real back-and-forth. Reorder the `models` array to change who opens or closes.
13
+
14
+ **Parallel**: all speakers answer at once per round. With `rounds > 1`, each round reacts to the previous round's snapshot.
15
+
16
+ **Round awareness**: in a multi-round debate each speaker is told its position (e.g. `[Round 2 of 3]`) so it knows whether to keep exploring or converge; the final round and the synthesis turn get explicit "commit" cues. This orientation is added to the speaker's prompt only — the returned `transcript` stays clean.
17
+
18
+ **Selector syntax**: `"fable"`, `"opus:skeptic"`, `"gpt:builder"`. The **same model with different roles counts as distinct speakers** — `["gpt:judge", "gpt:skeptic"]` runs one model twice with two personas. Identical selectors (`["gpt:judge", "gpt:judge"]`) dedupe to a single speaker.
19
+
20
+ **Ad-hoc roles**: define personas inline for a single call without adding files:
21
+
22
+ ```
23
+ roles: { "contrarian": "Argue against every claim; find the weakest link." }
24
+ models: ["gpt:contrarian", "fable"]
25
+ ```
26
+
27
+ File roles in `config/roles/` are always available too; an ad-hoc role **shadows** a file role of the same name for that call only. Ad-hoc roles are disabled when `DYNAMIC_ROLES=false` (using `roles` then returns `isError`).
28
+
29
+ **You are the moderator.** The server is stateless — you hold the thread. To run a live, steered debate:
30
+
31
+ 1. Call quorum with `rounds: 1`. Read the answers plus `structuredContent.transcript`.
32
+ 2. Steer: call again with `context` = that transcript + your guidance (or run a single-model tool as a moderator turn).
33
+ 3. Repeat as long as it's useful; finish with `synthesize`.
34
+
35
+ **Synthesis**: after the final round, the named selector reads the whole transcript and returns a final take. A failed synthesis still returns the discussion turns.
36
+
37
+ Per-turn telemetry (usage, latency, status, role) is in `structuredContent.turns`; the full discussion text is in `structuredContent.transcript` — feed it straight back as `context`.
38
+
39
+ **Transcript structure is owned by the moderator, not the speakers.** The `[round N / speaker]` labels are added when building the transcript; each model's answer is stored raw. Speakers are instructed not to label their own turns — so if you author your own steering turns between calls, don't add `[round N / …]` prefixes either; let the structure come from the tool.
40
+
41
+ A failed call returns `isError: true` only when **all** calls failed. Partial success returns the successful content items with failures recorded in `structuredContent.turns`.
42
+
43
+ **Token budget**: set `tokenBudget` (or the `TOKEN_BUDGET` default) to cap the cumulative tokens a run may spend. Once the running total crosses the ceiling, remaining turns are skipped (recorded as `skipped: budget` in `structuredContent.turns`) — synthesis still runs, and `structuredContent.budget` reports `{ limit, used, exceeded }`. Read it to see what was dropped, then re-invoke with a higher budget if you want the rest.
44
+
45
+ **Per-turn `status` values** (in `structuredContent.turns`):
46
+
47
+ | `status` | Meaning | Has content? |
48
+ | --- | --- | --- |
49
+ | `ok` | Completed normally. | yes (`contentIndex` set) |
50
+ | `truncated` | Hit `maxTokens` but produced usable text. | yes |
51
+ | `reasoning-heavy` | Successful content — but the model spent at least as many tokens reasoning as it emitted as visible text, so the budget went mostly to thinking. The answer may be thin. Can combine, e.g. `truncated, reasoning-heavy`. | yes (`contentIndex` set) |
52
+ | `error: <message>` | The call failed; no content item was added. | no |
53
+
54
+ `reasoning-heavy` is a **quality hint, not a failure** — the turn still returns its text and a `contentIndex`. It matters most for **synthesis**: a reasoning model asked to consolidate a long transcript can burn the budget thinking and return a stub. If a `synthesize` turn comes back thin, raise `maxTokens` or point `synthesize` at a non-reasoning / low-reasoning model. (The server also logs a warning when a synthesis turn is reasoning-heavy.)
55
+
56
+ An empty (no-text) response is **retried once automatically** before it becomes an `error` — this absorbs the transient "no output" blips models occasionally return under concurrent load. A genuine ceiling problem simply fails again on the retry.
57
+
58
+ ### Tuning `maxTokens`
59
+
60
+ Each turn's input includes the whole discussion so far, so **context grows every round** — and reasoning models spend hidden tokens on top of visible output. A ceiling that's comfortable in round 1 often truncates by round 3. Rules of thumb:
61
+
62
+ - Single-round / short answers: `maxTokens: 500` is usually fine.
63
+ - Multi-round debates: budget higher (e.g. `1500`–`4000`) so later turns aren't clipped as the transcript grows.
64
+ - Reasoning models: add headroom beyond the visible length you want, since thinking tokens count against the ceiling.
65
+ - Truncation isn't fatal — a `truncated` turn still returns its partial text — but raising `maxTokens` gives fuller answers.
66
+
67
+ **Terse roles / tight output**: reasoning models sometimes deliberate away the whole budget when a role asks for very short answers, especially in parallel bursts. Tell the role to skip deliberation — the shipped `short` role (`config/roles/short.md`) does exactly this: "Answer immediately … no preamble, no deliberation." Add a similar line to any terse persona.
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "legion-mcp",
3
+ "version": "0.1.0",
4
+ "description": "I am Legion, for we are many ~ an MCP server for exposing LLMs as tools over the OpenAI Responses API.",
5
+ "author": "Joshua Faulkenberry <j@joshuafaulkenberry.com> (https://joshuafaulkenberry.com/)",
6
+ "license": "Kopimi",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/faulkj/legion-mcp.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/faulkj/legion-mcp/issues"
13
+ },
14
+ "homepage": "https://github.com/faulkj/legion-mcp#readme",
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "llm",
19
+ "openai",
20
+ "responses-api",
21
+ "litellm",
22
+ "quorum",
23
+ "multi-model",
24
+ "ai"
25
+ ],
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=24"
29
+ },
30
+ "bin": {
31
+ "legion-mcp": "bin/server.js"
32
+ },
33
+ "files": [
34
+ "bin",
35
+ "config/roles",
36
+ "config/tools",
37
+ "config/*.json",
38
+ "config/*.md",
39
+ "config/models/*.example.json"
40
+ ],
41
+ "scripts": {
42
+ "dev": "tsx watch --env-file-if-exists=.env ts/server.ts",
43
+ "dev:http": "tsx watch --env-file-if-exists=.env ts/server.ts http",
44
+ "build": "tsc --noEmit && tsup --entry.server=ts/server.ts --format esm --out-dir bin --no-splitting --platform node --clean",
45
+ "prepack": "npm run build",
46
+ "start": "node --env-file-if-exists=.env bin/server.js",
47
+ "start:http": "node --env-file-if-exists=.env bin/server.js http",
48
+ "inspector": "npx @modelcontextprotocol/inspector --transport http --server-url http://localhost:5000/mcp"
49
+ },
50
+ "dependencies": {
51
+ "@modelcontextprotocol/express": "2.0.0-beta.2",
52
+ "@modelcontextprotocol/node": "2.0.0-beta.2",
53
+ "@modelcontextprotocol/server": "2.0.0-beta.2",
54
+ "openai": "^6.45.0",
55
+ "zod": "^4.4.3"
56
+ },
57
+ "devDependencies": {
58
+ "@types/express": "^5.0.6",
59
+ "@types/node": "^26.1.1",
60
+ "tsup": "^8.5.1",
61
+ "tsx": "^4.23.0",
62
+ "typescript": "^7.0.2"
63
+ },
64
+ "overrides": {
65
+ "esbuild": "^0.28.1"
66
+ }
67
+ }