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 +44 -0
- package/README.md +291 -0
- package/bin/server.js +519 -0
- package/config/description.md +20 -0
- package/config/errors.json +8 -0
- package/config/models/claude.example.json +5 -0
- package/config/models/gpt.example.json +7 -0
- package/config/prompts.json +8 -0
- package/config/roles/builder.md +1 -0
- package/config/roles/judge.md +1 -0
- package/config/roles/short.md +1 -0
- package/config/roles/skeptic.md +1 -0
- package/config/schema.json +18 -0
- package/config/tools/quorum.md +67 -0
- package/package.json +67 -0
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,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
|
+
}
|