lazy-mcp 2.6.0 → 2.6.6
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/README.md +43 -618
- package/dist/cli.js +7 -8
- package/dist/cli.js.map +1 -1
- package/dist/multi-proxy.js +1 -1
- package/dist/multi-proxy.js.map +1 -1
- package/dist/oauth-manager.d.ts +44 -4
- package/dist/oauth-manager.d.ts.map +1 -1
- package/dist/oauth-manager.js +125 -39
- package/dist/oauth-manager.js.map +1 -1
- package/dist/paths.d.ts +26 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +76 -0
- package/dist/paths.js.map +1 -0
- package/dist/pid-lock.d.ts +3 -2
- package/dist/pid-lock.d.ts.map +1 -1
- package/dist/pid-lock.js +8 -7
- package/dist/pid-lock.js.map +1 -1
- package/dist/server-manager.d.ts +7 -0
- package/dist/server-manager.d.ts.map +1 -1
- package/dist/server-manager.js +17 -2
- package/dist/server-manager.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,37 +14,14 @@ A client-agnostic proxy that converts normal MCP servers to use a lazy-loading p
|
|
|
14
14
|
- [Known Issue](#known-issue)
|
|
15
15
|
- [Installation](#installation)
|
|
16
16
|
- [Usage](#usage)
|
|
17
|
+
- [Config directory location (XDG Base Directory)](#config-directory-location-xdg-base-directory)
|
|
17
18
|
- [Streamable HTTP Transport](#streamable-http-transport)
|
|
18
19
|
- [Integration (Claude Desktop, OpenCode, Cursor, VS Code, and more)](#integration-claude-desktop-opencode-cursor-vs-code-and-more)
|
|
19
20
|
- [Example](#example)
|
|
20
21
|
- [Development](#development)
|
|
21
22
|
- [Running from Local Source](#running-from-local-source)
|
|
22
23
|
- [Releases](#releases)
|
|
23
|
-
- [How It Works](#how-it-works)
|
|
24
|
-
- [Required CI/CD Variables](#required-cicd-variables)
|
|
25
|
-
- [`GITLAB_RELEASE_TOKEN`](#gitlabreleasetoken)
|
|
26
|
-
- [`NPM_TOKEN`](#npmtoken)
|
|
27
|
-
- [`PYPI_TOKEN`](#pypitoken)
|
|
28
|
-
- [`CARGO_TOKEN`](#cargotoken)
|
|
29
24
|
- [Configuration Reference](#configuration-reference)
|
|
30
|
-
- [Server Configuration Fields](#server-configuration-fields)
|
|
31
|
-
- [Permissions](#permissions)
|
|
32
|
-
- [Pattern syntax](#pattern-syntax)
|
|
33
|
-
- [Resolution algorithm](#resolution-algorithm)
|
|
34
|
-
- [Per-server permissions](#per-server-permissions)
|
|
35
|
-
- [Global (cross-server) permissions](#global-cross-server-permissions)
|
|
36
|
-
- [Read-only preset](#read-only-preset)
|
|
37
|
-
- [Hot reload](#hot-reload)
|
|
38
|
-
- [Soft-fail validation](#soft-fail-validation)
|
|
39
|
-
- [Error contract](#error-contract)
|
|
40
|
-
- [OAuth 2.0 Authentication](#oauth-20-authentication)
|
|
41
|
-
- [Command Format](#command-format)
|
|
42
|
-
- [Environment Variables](#environment-variables)
|
|
43
|
-
- [Embed Server Summaries](#embed-server-summaries)
|
|
44
|
-
- [Transport Configuration](#transport-configuration)
|
|
45
|
-
- [Logging Configuration](#logging-configuration)
|
|
46
|
-
- [Health Monitoring](#health-monitoring)
|
|
47
|
-
- [Config Reload (SIGHUP)](#config-reload-sighup)
|
|
48
25
|
- [Benefits](#benefits)
|
|
49
26
|
- [Documentation](#documentation)
|
|
50
27
|
<!-- TOC_END -->
|
|
@@ -209,104 +186,43 @@ LAZY_MCP_CONFIG=~/.config/lazy-mcp/servers.json npx lazy-mcp@latest
|
|
|
209
186
|
lazy-mcp --config ~/.config/lazy-mcp/servers.json
|
|
210
187
|
```
|
|
211
188
|
|
|
212
|
-
###
|
|
213
|
-
|
|
214
|
-
By default, lazy-mcp communicates over **stdio** (standard MCP transport). You can also run it as an **HTTP server** using the Streamable HTTP transport, allowing remote clients to connect over the network:
|
|
189
|
+
### Config directory location (XDG Base Directory)
|
|
215
190
|
|
|
216
|
-
|
|
191
|
+
The config directory defaults to `~/.config/lazy-mcp/`, but lazy-mcp honours the
|
|
192
|
+
[XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html):
|
|
193
|
+
if `$XDG_CONFIG_HOME` is set, lazy-mcp uses `$XDG_CONFIG_HOME/lazy-mcp/`
|
|
194
|
+
instead. This applies to:
|
|
217
195
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
{ "name": "my-server", "description": "My MCP server", "command": ["python", "server.py"] }
|
|
222
|
-
],
|
|
223
|
-
"transport": {
|
|
224
|
-
"type": "http",
|
|
225
|
-
"port": 3000,
|
|
226
|
-
"host": "localhost",
|
|
227
|
-
"path": "/mcp",
|
|
228
|
-
"authToken": "${MY_API_KEY}"
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
```
|
|
196
|
+
- the default `servers.json` location,
|
|
197
|
+
- the OAuth token store (`tokens.json`, `client-info.json`, …),
|
|
198
|
+
- the OAuth callback PID lock files.
|
|
232
199
|
|
|
233
|
-
|
|
200
|
+
This makes it easy to scope tokens per project (e.g. via `direnv` or `mise`)
|
|
201
|
+
without having to override `$HOME`:
|
|
234
202
|
|
|
235
203
|
```bash
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
# With custom host, path, and auth
|
|
240
|
-
lazy-mcp --config servers.json --transport http --port 3000 --host 127.0.0.1 --path /mcp --auth-token "my-secret"
|
|
204
|
+
# In a project's .envrc / mise.toml:
|
|
205
|
+
export XDG_CONFIG_HOME="$PWD/.config"
|
|
241
206
|
```
|
|
242
207
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
> lazy-mcp's own socket plus the incoming `Host` header. If you run lazy-mcp
|
|
247
|
-
> behind a TLS-terminating reverse proxy, the backend hop is usually plain
|
|
248
|
-
> HTTP, so the implicit same-origin shortcut may reject public HTTPS origins.
|
|
249
|
-
> In that setup, configure `transport.allowedOrigins` explicitly. If your
|
|
250
|
-
> proxy preserves the public `Host` header, configure `transport.allowedHosts`
|
|
251
|
-
> too.
|
|
252
|
-
>
|
|
253
|
-
> Example:
|
|
254
|
-
> ```json
|
|
255
|
-
> {
|
|
256
|
-
> "transport": {
|
|
257
|
-
> "type": "http",
|
|
258
|
-
> "host": "127.0.0.1",
|
|
259
|
-
> "port": 8080,
|
|
260
|
-
> "path": "/mcp",
|
|
261
|
-
> "allowedHosts": ["mcp.example.com"],
|
|
262
|
-
> "allowedOrigins": ["https://mcp.example.com"]
|
|
263
|
-
> }
|
|
264
|
-
> }
|
|
265
|
-
> ```
|
|
266
|
-
>
|
|
267
|
-
> lazy-mcp intentionally does not trust `Forwarded` / `X-Forwarded-*` headers
|
|
268
|
-
> by default. If proxy-aware origin reconstruction is ever added, it should be
|
|
269
|
-
> behind an explicit trusted-proxy setting.
|
|
270
|
-
|
|
271
|
-
**Via environment variables** (lowest precedence):
|
|
208
|
+
With that set, lazy-mcp will read `./.config/lazy-mcp/servers.json` and store
|
|
209
|
+
tokens under `./.config/lazy-mcp/` — completely isolated from your global
|
|
210
|
+
lazy-mcp state.
|
|
272
211
|
|
|
273
|
-
|
|
274
|
-
LAZY_MCP_TRANSPORT=http LAZY_MCP_PORT=3000 LAZY_MCP_AUTH_TOKEN=my-secret lazy-mcp
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
**Precedence**: CLI flags > config file > environment variables > defaults.
|
|
212
|
+
### Streamable HTTP Transport
|
|
278
213
|
|
|
279
|
-
|
|
214
|
+
By default, lazy-mcp communicates over **stdio**. You can also run it as an **HTTP server** so remote clients can connect over the network:
|
|
280
215
|
|
|
281
216
|
```bash
|
|
282
|
-
|
|
283
|
-
curl -X POST http://localhost:3000/mcp \
|
|
284
|
-
-H "Content-Type: application/json" \
|
|
285
|
-
-H "Accept: application/json, text/event-stream" \
|
|
286
|
-
-H "Authorization: Bearer ${MY_API_KEY}" \
|
|
287
|
-
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
217
|
+
lazy-mcp --config servers.json --transport http --port 3000 --auth-token "my-secret"
|
|
288
218
|
```
|
|
289
219
|
|
|
290
|
-
|
|
220
|
+
See [doc/HTTP_TRANSPORT.md](./doc/HTTP_TRANSPORT.md) for full configuration, security guidance (DNS rebinding protection, payload limits, bearer auth), and reverse-proxy setup.
|
|
291
221
|
|
|
292
222
|
## Integration (Claude Desktop, OpenCode, Cursor, VS Code, and more)
|
|
293
223
|
|
|
294
|
-
Replace multiple MCP server entries with one aggregated proxy:
|
|
295
|
-
|
|
296
|
-
**Before** (5 separate MCP servers):
|
|
297
|
-
```json
|
|
298
|
-
{
|
|
299
|
-
"mcp": {
|
|
300
|
-
"chrome-devtools": { "command": ["npx", "lazy-mcp@latest", "npx", "-y", "chrome-devtools-mcp@latest"] },
|
|
301
|
-
"gitlab": { "command": ["npx", "lazy-mcp@latest", "npx", "mcp-remote@latest", "https://..."] },
|
|
302
|
-
"grepai": { "command": ["npx", "lazy-mcp@latest", "grepai", "mcp-serve"] },
|
|
303
|
-
"context7": { "command": ["npx", "-y", "@upstash/context7-mcp"] },
|
|
304
|
-
"perplexity": { "command": ["npx", "-y", "@perplexity-ai/mcp-server"] }
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
```
|
|
224
|
+
Replace multiple MCP server entries in your client with one aggregated lazy-mcp proxy:
|
|
308
225
|
|
|
309
|
-
**After** (Consolidated into 1 multi-server proxy):
|
|
310
226
|
```json
|
|
311
227
|
{
|
|
312
228
|
"mcp": {
|
|
@@ -319,38 +235,9 @@ Replace multiple MCP server entries with one aggregated proxy:
|
|
|
319
235
|
}
|
|
320
236
|
```
|
|
321
237
|
|
|
322
|
-
|
|
238
|
+
All downstream MCP servers live in `~/.config/lazy-mcp/servers.json`. **Result**: ~90% context reduction (from ~16K to ~1.5K tokens initially).
|
|
323
239
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
```json
|
|
327
|
-
{
|
|
328
|
-
"mcp": {
|
|
329
|
-
"lazy-mcp": {
|
|
330
|
-
"type": "remote",
|
|
331
|
-
"url": "http://localhost:3000/mcp",
|
|
332
|
-
"headers": {
|
|
333
|
-
"Authorization": "Bearer ${LAZY_MCP_AUTH_TOKEN}"
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
Where `~/.config/lazy-mcp/servers.json` contains all 5 servers:
|
|
341
|
-
```json
|
|
342
|
-
{
|
|
343
|
-
"servers": [
|
|
344
|
-
{ "name": "chrome-devtools", "description": "Chrome DevTools automation", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] },
|
|
345
|
-
{ "name": "gitlab", "description": "GitLab API integration", "command": ["npx", "mcp-remote@latest", "https://..."] },
|
|
346
|
-
{ "name": "grepai", "description": "Search codebase", "command": ["grepai", "mcp-serve"] },
|
|
347
|
-
{ "name": "context7", "description": "Library documentation", "command": ["npx", "-y", "@upstash/context7-mcp"] },
|
|
348
|
-
{ "name": "perplexity", "description": "Web search", "command": ["npx", "-y", "@perplexity-ai/mcp-server"] }
|
|
349
|
-
]
|
|
350
|
-
}
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
**Result**: ~90% context reduction (from ~16K to ~1.5K tokens initially)
|
|
240
|
+
See [doc/INTEGRATION.md](./doc/INTEGRATION.md) for before/after examples and HTTP-mode client configuration.
|
|
354
241
|
|
|
355
242
|
## Example
|
|
356
243
|
|
|
@@ -417,494 +304,29 @@ Or with `ts-node` (no build needed, always reflects latest source):
|
|
|
417
304
|
|
|
418
305
|
## Releases
|
|
419
306
|
|
|
420
|
-
Releases are fully automated via [semantic-release](https://semantic-release.gitbook.io/) on every push to `main`.
|
|
421
|
-
|
|
422
|
-
### How It Works
|
|
423
|
-
|
|
424
|
-
1. CI analyzes all commits since the last tag using [Conventional Commits](https://www.conventionalcommits.org/)
|
|
425
|
-
2. Determines the next version (`feat` → minor bump, `fix`/`perf`/`chore`/`refactor`/`test` → patch bump)
|
|
426
|
-
3. Updates `package.json`, `CHANGELOG.md`, `VERSION`, and `RELEASE_NOTES.md`
|
|
427
|
-
4. Commits those files as `chore(release): X.Y.Z` and pushes a `vX.Y.Z` tag
|
|
428
|
-
5. Creates a GitLab Release with the generated release notes
|
|
429
|
-
6. The `vX.Y.Z` tag triggers a separate pipeline that publishes the package to npm automatically
|
|
430
|
-
|
|
431
|
-
No manual version bumping or tagging needed — just merge to `main` with conventional commit messages.
|
|
432
|
-
|
|
433
|
-
### Required CI/CD Variables
|
|
434
|
-
|
|
435
|
-
Four CI/CD variables must be configured (GitLab → Project → Settings → CI/CD → Variables):
|
|
436
|
-
|
|
437
|
-
| Variable | Description |
|
|
438
|
-
|----------|-------------|
|
|
439
|
-
| `GITLAB_RELEASE_TOKEN` | Project access token — pushes the release commit + tag to `main` and creates the GitLab Release |
|
|
440
|
-
| `NPM_TOKEN` | npm automation token — publishes the package to the npm registry on tag pipelines |
|
|
441
|
-
| `PYPI_TOKEN` | PyPI API token — publishes the package to PyPI on tag pipelines |
|
|
442
|
-
| `CARGO_TOKEN` | crates.io API token — publishes the crate to crates.io on tag pipelines |
|
|
443
|
-
|
|
444
|
-
#### `GITLAB_RELEASE_TOKEN`
|
|
445
|
-
|
|
446
|
-
**Creating the token** (GitLab → Project → Settings → Access Tokens):
|
|
447
|
-
|
|
448
|
-
| Setting | Value |
|
|
449
|
-
|---------|-------|
|
|
450
|
-
| Token name | `semantic-release-bot` (or any name) |
|
|
451
|
-
| Role | **Developer** (push to a protected branch should be configured separately) |
|
|
452
|
-
| Scopes | `api`, `write_repository` |
|
|
453
|
-
|
|
454
|
-
**Adding the variable:**
|
|
455
|
-
|
|
456
|
-
| Setting | Value |
|
|
457
|
-
|---------|-------|
|
|
458
|
-
| Key | `GITLAB_RELEASE_TOKEN` |
|
|
459
|
-
| Masked | ✅ Yes |
|
|
460
|
-
| Protected | ❌ No (must be available on the unprotected `main` pipeline) |
|
|
461
|
-
|
|
462
|
-
> **Note**: If `main` is a protected branch with push restrictions, the token's bot user must be added to the "Allowed to push" list under GitLab → Project → Settings → Repository → Protected branches.
|
|
463
|
-
|
|
464
|
-
#### `NPM_TOKEN`
|
|
465
|
-
|
|
466
|
-
**Creating the token** (npmjs.com → Account → Access Tokens → Generate New Token):
|
|
467
|
-
|
|
468
|
-
| Setting | Value |
|
|
469
|
-
|---------|-------|
|
|
470
|
-
| Token type | **Automation** (bypasses 2FA, suitable for CI) |
|
|
471
|
-
|
|
472
|
-
**Adding the variable:**
|
|
307
|
+
Releases are fully automated via [semantic-release](https://semantic-release.gitbook.io/) on every push to `main`. CI analyzes [Conventional Commits](https://www.conventionalcommits.org/), bumps the version, tags, and publishes to npm, PyPI, and crates.io.
|
|
473
308
|
|
|
474
|
-
|
|
475
|
-
|---------|-------|
|
|
476
|
-
| Key | `NPM_TOKEN` |
|
|
477
|
-
| Masked | ✅ Yes |
|
|
478
|
-
| Protected | ✅ Yes (only needed on tag pipelines, which are protected) |
|
|
479
|
-
|
|
480
|
-
#### `PYPI_TOKEN`
|
|
481
|
-
|
|
482
|
-
**Creating the token** (pypi.org → Account Settings → API tokens → Add API token):
|
|
483
|
-
|
|
484
|
-
| Setting | Value |
|
|
485
|
-
|---------|-------|
|
|
486
|
-
| Token name | `lazy-mcp-ci` (or any name) |
|
|
487
|
-
| Scope | **Project: lazy-mcp** (restrict to this project after first publish; use "Entire account" for the very first publish) |
|
|
488
|
-
|
|
489
|
-
**Adding the variable:**
|
|
490
|
-
|
|
491
|
-
| Setting | Value |
|
|
492
|
-
|---------|-------|
|
|
493
|
-
| Key | `PYPI_TOKEN` |
|
|
494
|
-
| Masked | ✅ Yes |
|
|
495
|
-
| Protected | ✅ Yes (only needed on tag pipelines, which are protected) |
|
|
496
|
-
|
|
497
|
-
#### `CARGO_TOKEN`
|
|
498
|
-
|
|
499
|
-
**Creating the token** (crates.io → Account Settings → API Tokens → New Token):
|
|
500
|
-
|
|
501
|
-
| Setting | Value |
|
|
502
|
-
|---------|-------|
|
|
503
|
-
| Token name | `lazy-mcp-ci` (or any name) |
|
|
504
|
-
| Scopes | `publish-new`, `publish-update` |
|
|
505
|
-
|
|
506
|
-
**Adding the variable:**
|
|
507
|
-
|
|
508
|
-
| Setting | Value |
|
|
509
|
-
|---------|-------|
|
|
510
|
-
| Key | `CARGO_TOKEN` |
|
|
511
|
-
| Masked | ✅ Yes |
|
|
512
|
-
| Protected | ✅ Yes (only needed on tag pipelines, which are protected) |
|
|
309
|
+
See [doc/RELEASES.md](./doc/RELEASES.md) for the full release pipeline and the required CI/CD variables (`GITLAB_RELEASE_TOKEN`, `NPM_TOKEN`, `PYPI_TOKEN`, `CARGO_TOKEN`).
|
|
513
310
|
|
|
514
311
|
## Configuration Reference
|
|
515
312
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
| Field | Type | Required | Description |
|
|
519
|
-
|-------|------|----------|-------------|
|
|
520
|
-
| `name` | string | ✅ | Unique server identifier |
|
|
521
|
-
| `description` | string | ✅ | Human-readable description |
|
|
522
|
-
| `type` | "local" \| "remote" | Optional | Inferred from `url` (remote) or `command` (local) if omitted |
|
|
523
|
-
| `command` | string[] \| string | For local | Command to execute (array format recommended) |
|
|
524
|
-
| `args` | string[] | Optional | Arguments (only if command is string) |
|
|
525
|
-
| `url` | string | For remote | HTTP/HTTPS URL |
|
|
526
|
-
| `headers` | object | Optional | Static HTTP headers for remote servers |
|
|
527
|
-
| `oauth` | object | Optional | OAuth 2.0 config for remote servers (see below) |
|
|
528
|
-
| `env` | object | Optional | Environment variables (supports `${VAR}` and `{file:...}` expansion) |
|
|
529
|
-
| `enabled` | boolean | Optional | Enable/disable server (default: true) |
|
|
530
|
-
| `examples` | object[] | Optional | Usage examples shown in `list_servers` output |
|
|
531
|
-
| `tags` | string[] | Optional | Capability tags for filtering (e.g. `"api"`, `"browser"`) |
|
|
532
|
-
| `permissions` | object | Optional | Per-server `invoke_command` allow/deny rules (see [Permissions](#permissions)) |
|
|
533
|
-
|
|
534
|
-
### Permissions
|
|
535
|
-
|
|
536
|
-
> **Experimental.** The permissions feature is experimental and may change in future releases. Only the `allow` and `deny` actions are supported today. An `ask` action (for interactive confirmation) is planned but not yet implemented — it requires host-side companion hooks that are still in design. Configs that use `"action": "ask"` will fail validation for now.
|
|
537
|
-
|
|
538
|
-
The `permissions` block gives you per-server and global allow/deny rules over `invoke_command`. Rules are glob patterns matched (case-insensitively) against the command name. Calls that match a `deny` rule are blocked before they reach the downstream server and the agent receives a structured error (with `_meta.lazyMcpPolicy: "blocked"` so host plugins can hook into it).
|
|
313
|
+
lazy-mcp reads its configuration from `~/.config/lazy-mcp/servers.json` (or `--config <path>`). At minimum each server needs `name`, `description`, and either `command` (local) or `url` (remote).
|
|
539
314
|
|
|
540
|
-
|
|
315
|
+
Common top-level blocks:
|
|
541
316
|
|
|
542
|
-
|
|
317
|
+
- **`servers[]`** — list of MCP servers to aggregate (required)
|
|
318
|
+
- **`permissions`** — global and per-server allow/deny rules for `invoke_command` (experimental)
|
|
319
|
+
- **`transport`** — switch from stdio to HTTP, set port, bind host, bearer auth, etc.
|
|
320
|
+
- **`logging`** — structured stderr logging (level, format, body dumps, redaction)
|
|
321
|
+
- **`healthMonitor`** — background health probes (activity-driven by default)
|
|
322
|
+
- **`embedServerSummaries`** — opt-in: embed configured server names/descriptions in `list_servers` description
|
|
323
|
+
- **`requestTimeout`** — per-server request timeout in ms
|
|
543
324
|
|
|
544
|
-
|
|
545
|
-
|-------|---------|
|
|
546
|
-
| `*` | Zero or more characters |
|
|
547
|
-
| `?` | Exactly one character |
|
|
548
|
-
| Any other char | Literal (case-insensitive) |
|
|
549
|
-
|
|
550
|
-
#### Resolution algorithm
|
|
551
|
-
|
|
552
|
-
For each `invoke_command` call, lazy-mcp evaluates rules in this order. The first level that produces a match wins; within a level, the last matching rule wins.
|
|
553
|
-
|
|
554
|
-
1. Per-server rules (`servers[i].permissions.rules`)
|
|
555
|
-
2. Global rules (top-level `permissions.rules`), gated by the optional `server` glob (defaults to `*`)
|
|
556
|
-
3. Per-server `default` (`servers[i].permissions.default`)
|
|
557
|
-
4. Global `default` (top-level `permissions.default`)
|
|
558
|
-
5. Implicit `"allow"`
|
|
559
|
-
|
|
560
|
-
#### Per-server permissions
|
|
561
|
-
|
|
562
|
-
```jsonc
|
|
563
|
-
{
|
|
564
|
-
"servers": [
|
|
565
|
-
{
|
|
566
|
-
"name": "gitlab",
|
|
567
|
-
"url": "https://gitlab.com/api/v4/mcp",
|
|
568
|
-
"permissions": {
|
|
569
|
-
"default": "allow",
|
|
570
|
-
"rules": [
|
|
571
|
-
{ "pattern": "list_*", "action": "allow" },
|
|
572
|
-
{ "pattern": "get_*", "action": "allow" },
|
|
573
|
-
{ "pattern": "search_*", "action": "allow" },
|
|
574
|
-
{ "pattern": "delete_*", "action": "deny",
|
|
575
|
-
"message": "Destructive GitLab calls are blocked by policy." }
|
|
576
|
-
]
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
]
|
|
580
|
-
}
|
|
581
|
-
```
|
|
325
|
+
You can also expand secrets in any string value with `${VAR}` (env var) or `{file:/path/to/secret}` (file-based, owner-only `0600` recommended).
|
|
582
326
|
|
|
583
|
-
|
|
327
|
+
Send `SIGHUP` to reload the config without restarting (`kill -HUP <pid>` — the PID is in `list_servers`).
|
|
584
328
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
```jsonc
|
|
588
|
-
{
|
|
589
|
-
"permissions": {
|
|
590
|
-
"default": "allow",
|
|
591
|
-
"rules": [
|
|
592
|
-
{ "server": "*", "pattern": "*delete*", "action": "deny" },
|
|
593
|
-
{ "server": "*", "pattern": "*destroy*", "action": "deny" }
|
|
594
|
-
]
|
|
595
|
-
},
|
|
596
|
-
"servers": [/* … */]
|
|
597
|
-
}
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
#### Read-only preset
|
|
601
|
-
|
|
602
|
-
A handy "exploration" preset that denies everything by default but allows discovery-style commands:
|
|
603
|
-
|
|
604
|
-
```jsonc
|
|
605
|
-
{
|
|
606
|
-
"permissions": {
|
|
607
|
-
"default": "deny",
|
|
608
|
-
"rules": [
|
|
609
|
-
{ "server": "*", "pattern": "list_*", "action": "allow" },
|
|
610
|
-
{ "server": "*", "pattern": "get_*", "action": "allow" },
|
|
611
|
-
{ "server": "*", "pattern": "search_*", "action": "allow" }
|
|
612
|
-
]
|
|
613
|
-
},
|
|
614
|
-
"servers": [/* … */]
|
|
615
|
-
}
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
#### Hot reload
|
|
619
|
-
|
|
620
|
-
Permission changes pick up via the existing `SIGHUP` config-reload path — no restart required. Edit `~/.config/lazy-mcp/servers.json`, then `kill -HUP <pid>` (the PID is included in the `list_servers` response).
|
|
621
|
-
|
|
622
|
-
#### Soft-fail validation
|
|
623
|
-
|
|
624
|
-
Invalid `permissions` blocks are soft-failed: per-server errors surface as `_configError` on the offending server (it appears unhealthy in `list_servers`); an invalid top-level block is stripped at load time with a warning on stderr, leaving the rest of the config working.
|
|
625
|
-
|
|
626
|
-
A practical asymmetry to be aware of: a per-server `permissions` typo makes **that server** unreachable until fixed (it shows up unhealthy with a clear `_configError` in `list_servers`), while a global `permissions` typo is stripped and the rest of the config still loads — only the global rules are dropped, with a stderr warning. The reasoning: a per-server typo is most likely localised to the server you were editing, so failing fast there gives you the loudest signal; a global typo shouldn't take the entire proxy offline.
|
|
627
|
-
|
|
628
|
-
#### Error contract
|
|
629
|
-
|
|
630
|
-
Blocked calls return an `isError: true` tool result with a `_meta.lazyMcpPolicy: "blocked"` field that host plugins can intercept without parsing the message:
|
|
631
|
-
|
|
632
|
-
```jsonc
|
|
633
|
-
{
|
|
634
|
-
"content": [{
|
|
635
|
-
"type": "text",
|
|
636
|
-
"text": "🚫 Blocked by lazy-mcp policy: Tool 'gitlab:delete_project' is blocked by lazy-mcp policy\n\nDetails: Destructive GitLab calls are blocked by policy.\n\nTo allow this, remove the matching deny rule from your lazy-mcp config (~/.config/lazy-mcp/servers.json) and reload with: kill -HUP <pid>"
|
|
637
|
-
}],
|
|
638
|
-
"isError": true,
|
|
639
|
-
"_meta": { "lazyMcpPolicy": "blocked" }
|
|
640
|
-
}
|
|
641
|
-
```
|
|
642
|
-
|
|
643
|
-
### OAuth 2.0 Authentication
|
|
644
|
-
|
|
645
|
-
lazy-mcp has built-in OAuth 2.0 + PKCE support for remote servers that require user authorization. It works without opening a browser automatically, making it suitable for sandboxed agent environments: when authentication is needed, lazy-mcp returns the authorization URL in the error message so the agent can present it to the user.
|
|
646
|
-
|
|
647
|
-
OAuth server endpoints are **discovered automatically** via RFC 8414 (`/.well-known/oauth-authorization-server`). Dynamic client registration (RFC 7591) is used when no `clientId` is provided.
|
|
648
|
-
|
|
649
|
-
Tokens are persisted to `~/.config/lazy-mcp/tokens.json` (mode `0600`) and refreshed automatically via `refresh_token` when available.
|
|
650
|
-
|
|
651
|
-
**Minimal config** (fully automatic — discovery + dynamic registration):
|
|
652
|
-
```json
|
|
653
|
-
{
|
|
654
|
-
"name": "glean",
|
|
655
|
-
"description": "Glean enterprise search",
|
|
656
|
-
"url": "https://your-company.glean.com/mcp/default",
|
|
657
|
-
"oauth": {}
|
|
658
|
-
}
|
|
659
|
-
```
|
|
660
|
-
|
|
661
|
-
**With a pre-registered client ID**:
|
|
662
|
-
```json
|
|
663
|
-
{
|
|
664
|
-
"name": "glean",
|
|
665
|
-
"description": "Glean enterprise search",
|
|
666
|
-
"url": "https://your-company.glean.com/mcp/default",
|
|
667
|
-
"oauth": {
|
|
668
|
-
"clientId": "${GLEAN_CLIENT_ID}",
|
|
669
|
-
"extraHeaders": { "X-Glean-Auth-Type": "OAUTH" }
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
```
|
|
673
|
-
|
|
674
|
-
**`oauth` object fields:**
|
|
675
|
-
|
|
676
|
-
| Field | Type | Default | Description |
|
|
677
|
-
|-------|------|---------|-------------|
|
|
678
|
-
| `clientId` | string | — | OAuth client ID. If omitted, dynamic registration (RFC 7591) is attempted |
|
|
679
|
-
| `clientSecret` | string | — | Client secret (omit for public-client / PKCE-only flows) |
|
|
680
|
-
| `callbackPort` | number | `8947` | Local port for the OAuth redirect callback server |
|
|
681
|
-
| `extraHeaders` | object | — | Additional headers added to every authenticated request (e.g. `X-Glean-Auth-Type`) |
|
|
682
|
-
|
|
683
|
-
**How it works:**
|
|
684
|
-
|
|
685
|
-
1. Agent calls `invoke_command` (or `list_commands`) on an OAuth-protected server
|
|
686
|
-
2. lazy-mcp returns an `isError: true` response with the authorization URL
|
|
687
|
-
3. Agent presents the URL to the user: _"Open this URL to authorize Glean: https://..."_
|
|
688
|
-
4. User opens the URL in a browser and completes authorization
|
|
689
|
-
5. Browser redirects to `http://localhost:8947/callback` — lazy-mcp captures the token
|
|
690
|
-
6. Agent retries the original command — now succeeds transparently
|
|
691
|
-
|
|
692
|
-
### Command Format
|
|
693
|
-
|
|
694
|
-
**Recommended** (OpenCode-compatible):
|
|
695
|
-
```json
|
|
696
|
-
{
|
|
697
|
-
"command": ["npx", "-y", "my-mcp-server", "--port", "3000"]
|
|
698
|
-
}
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
**Legacy** (still supported):
|
|
702
|
-
```json
|
|
703
|
-
{
|
|
704
|
-
"command": "npx",
|
|
705
|
-
"args": ["-y", "my-mcp-server", "--port", "3000"]
|
|
706
|
-
}
|
|
707
|
-
```
|
|
708
|
-
|
|
709
|
-
### Environment Variables
|
|
710
|
-
|
|
711
|
-
Use `${VAR_NAME}` to reference environment variables:
|
|
712
|
-
|
|
713
|
-
```json
|
|
714
|
-
{
|
|
715
|
-
"env": {
|
|
716
|
-
"API_KEY": "${MY_API_KEY}",
|
|
717
|
-
"DEBUG": "true"
|
|
718
|
-
},
|
|
719
|
-
"headers": {
|
|
720
|
-
"Authorization": "Bearer ${AUTH_TOKEN}"
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
```
|
|
724
|
-
|
|
725
|
-
Use `{file:/path/to/secret}` to read a secret directly from a file (trailing newline is stripped automatically). `~/` is expanded to the home directory. On Windows, `%VAR_NAME%` references inside the path are expanded from environment variables (e.g. `%USERPROFILE%`):
|
|
726
|
-
|
|
727
|
-
```json
|
|
728
|
-
{
|
|
729
|
-
"env": {
|
|
730
|
-
"GITLAB_API_TOKEN": "{file:~/.secrets/gl-pat-token}",
|
|
731
|
-
"OTHER_SECRET": "{file:/run/secrets/other-token}"
|
|
732
|
-
},
|
|
733
|
-
"headers": {
|
|
734
|
-
"Authorization": "Bearer {file:~/.secrets/api-token}"
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
```
|
|
738
|
-
|
|
739
|
-
On Windows you can use `%USERPROFILE%` or any other environment variable in the path:
|
|
740
|
-
|
|
741
|
-
```json
|
|
742
|
-
{
|
|
743
|
-
"env": {
|
|
744
|
-
"API_TOKEN": "{file:%USERPROFILE%\\.secrets\\token}"
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
```
|
|
748
|
-
|
|
749
|
-
Only absolute paths and `~/` paths are accepted. Relative paths are left unchanged. Both notations can be combined in the same value and work in `env`, `headers`, and `transport.authToken` fields. `${VAR}` references inside a `{file:...}` path are not expanded; use an absolute path, `~/`, or Windows-style `%VAR_NAME%` path expansion instead. If the referenced file cannot be read (missing or permission error), the app exits at startup with an error message — e.g. `Cannot read secret file '/run/secrets/token' (from {file:/run/secrets/token}): ENOENT: no such file or directory`.
|
|
750
|
-
|
|
751
|
-
**Security best practices for secret files:**
|
|
752
|
-
|
|
753
|
-
- Set permissions to `0600` (owner read/write only): `chmod 0600 ~/.secrets/my-token`
|
|
754
|
-
- Store secret files in a user-specific directory (e.g. `~/.secrets/`)
|
|
755
|
-
- Never commit secret files to version control — add them to `.gitignore`
|
|
756
|
-
|
|
757
|
-
### Embed Server Summaries
|
|
758
|
-
|
|
759
|
-
By default, the AI must call `list_servers` to discover which MCP servers are available.
|
|
760
|
-
You can opt in to embedding server names and descriptions directly in the `list_servers` tool description so the
|
|
761
|
-
AI sees them upfront — useful for routing requests like "check my email" to the correct server without an extra tool call.
|
|
762
|
-
|
|
763
|
-
```json
|
|
764
|
-
{
|
|
765
|
-
"servers": [...],
|
|
766
|
-
"embedServerSummaries": {
|
|
767
|
-
"enabled": true,
|
|
768
|
-
"maxServers": 5
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
| Field | Type | Default | Description |
|
|
774
|
-
|-------|------|---------|-------------|
|
|
775
|
-
| `embedServerSummaries.enabled` | boolean | — | Enable/disable embedding |
|
|
776
|
-
| `embedServerSummaries.maxServers` | number | `10` | Maximum number of server summaries to embed |
|
|
777
|
-
|
|
778
|
-
> **Note:** Tool descriptions are included in every request context window.
|
|
779
|
-
> If your system prompt already instructs the LLM to call `list_servers`, this adds tokens with no benefit.
|
|
780
|
-
> Per-server descriptions are unbounded, so one verbose description can significantly increase tool description size.
|
|
781
|
-
> Making this opt-in lets you consciously accept that trade-off.
|
|
782
|
-
|
|
783
|
-
### Transport Configuration
|
|
784
|
-
|
|
785
|
-
Configure how lazy-mcp exposes its MCP endpoint. By default, it uses stdio (for subprocess-based clients). Set `type: "http"` to run as an HTTP server.
|
|
786
|
-
|
|
787
|
-
**Top-level configuration** (in `servers.json`):
|
|
788
|
-
|
|
789
|
-
| Field | Type | Default | Description |
|
|
790
|
-
|-------|------|---------|-------------|
|
|
791
|
-
| `transport.type` | `"stdio"` \| `"http"` | `"stdio"` | Transport mode |
|
|
792
|
-
| `transport.port` | number | `8080` | HTTP server port |
|
|
793
|
-
| `transport.host` | string | `"127.0.0.1"` | HTTP server bind address. For TLS-terminating reverse proxies, keep localhost binding and configure `allowedOrigins` explicitly. |
|
|
794
|
-
| `transport.path` | string | `"/mcp"` | MCP endpoint URL path |
|
|
795
|
-
| `transport.authToken` | string | — | Bearer token for HTTP auth (supports `${VAR}` and `{file:...}` expansion) |
|
|
796
|
-
| `transport.allowedOrigins` | string[] | — | List of allowed Origin header values for request-origin validation / CSRF protection (e.g. `["https://example.com"]`). Full origins including scheme and port. Recommended for TLS-terminating reverse-proxy deployments. Does not send CORS response headers. |
|
|
797
|
-
| `transport.allowedHosts` | string[] | — | List of explicitly allowed host domains for DNS rebinding protection. If a reverse proxy preserves the public `Host` header, add that host here. |
|
|
798
|
-
| `transport.maxPayloadSize` | number | `4194304` | Maximum request body size in bytes. Requests exceeding this limit return HTTP 413 |
|
|
799
|
-
|
|
800
|
-
**CLI flags** (override config values):
|
|
801
|
-
|
|
802
|
-
| Flag | Env Variable | Description |
|
|
803
|
-
|------|-------------|-------------|
|
|
804
|
-
| `--transport` | `LAZY_MCP_TRANSPORT` | Transport type (`stdio` or `http`) |
|
|
805
|
-
| `--port` | `LAZY_MCP_PORT` | HTTP server port |
|
|
806
|
-
| `--host` | `LAZY_MCP_HOST` | HTTP server bind address |
|
|
807
|
-
| `--path` | `LAZY_MCP_PATH` | MCP endpoint URL path |
|
|
808
|
-
| `--auth-token` | `LAZY_MCP_AUTH_TOKEN` | Bearer token for HTTP auth |
|
|
809
|
-
| `--request-timeout` | `LAZY_MCP_REQUEST_TIMEOUT` | Request timeout in ms for server calls, including MCP handshake requests and remote response parsing (default: 10000) |
|
|
810
|
-
| `--max-payload-size` | `LAZY_MCP_MAX_PAYLOAD_SIZE` | Maximum request body size in bytes (default: 4194304) |
|
|
811
|
-
|
|
812
|
-
When `authToken` is set, all HTTP requests must include `Authorization: Bearer <token>` — unauthenticated requests receive `401 Unauthorized`.
|
|
813
|
-
|
|
814
|
-
### Logging Configuration
|
|
815
|
-
|
|
816
|
-
lazy-mcp now emits structured logs to **stderr** (stdout remains reserved for MCP protocol traffic).
|
|
817
|
-
|
|
818
|
-
**Top-level configuration** (in `servers.json`):
|
|
819
|
-
|
|
820
|
-
| Field | Type | Default | Description |
|
|
821
|
-
|-------|------|---------|-------------|
|
|
822
|
-
| `logging.level` | `"error"` \| `"info"` \| `"debug"` | `"info"` | Minimum log level |
|
|
823
|
-
| `logging.format` | `"json"` \| `"plain"` | `"json"` | Log output format |
|
|
824
|
-
| `logging.dumpBodies` | boolean | `false` | Enable debug request/response body dumps |
|
|
825
|
-
| `logging.maxBodyLogBytes` | number | `8192` | Max body-dump size in bytes before truncation |
|
|
826
|
-
| `logging.redactKeys` | string[] | — | Additional case-insensitive keys to redact (merged with built-in defaults) |
|
|
827
|
-
|
|
828
|
-
Built-in redaction includes common secret keys like `authorization`, `token`, `access_token`, `refresh_token`, `client_secret`, and `headers.authorization`.
|
|
829
|
-
|
|
830
|
-
Example:
|
|
831
|
-
|
|
832
|
-
```json
|
|
833
|
-
{
|
|
834
|
-
"servers": [
|
|
835
|
-
{
|
|
836
|
-
"name": "my-server",
|
|
837
|
-
"description": "Example",
|
|
838
|
-
"command": ["npx", "-y", "my-mcp-server"]
|
|
839
|
-
}
|
|
840
|
-
],
|
|
841
|
-
"logging": {
|
|
842
|
-
"level": "debug",
|
|
843
|
-
"format": "json",
|
|
844
|
-
"dumpBodies": true,
|
|
845
|
-
"maxBodyLogBytes": 4096,
|
|
846
|
-
"redactKeys": ["my_custom_secret"]
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
Typical access log event fields include:
|
|
852
|
-
- client source (`clientIp`, optional `forwardedFor`)
|
|
853
|
-
- request shape (`httpMethod`, `path`, `mcpMethod`, `lazyTool`)
|
|
854
|
-
- downstream routing (`downstreamServer`, `downstreamCommand`)
|
|
855
|
-
- outcome (`status`, `reason`, `durationMs`)
|
|
856
|
-
|
|
857
|
-
### Health Monitoring
|
|
858
|
-
|
|
859
|
-
lazy-mcp includes a background health monitor that probes all servers periodically. The monitor is **activity-driven**: it sleeps on startup and only begins probing after the first user tool call (`list_servers`, `list_commands`, etc.). After a configurable idle timeout (default: 5 minutes) with no tool calls, the monitor goes back to sleep. This prevents OAuth-protected servers (e.g. GitLab via `mcp-remote`) from opening browser windows when no one is using the tools.
|
|
860
|
-
|
|
861
|
-
Successful probes populate the discovery cache, so subsequent `list_commands` calls return instantly from cache.
|
|
862
|
-
|
|
863
|
-
**Top-level configuration** (in `servers.json`):
|
|
864
|
-
|
|
865
|
-
| Field | Type | Default | Description |
|
|
866
|
-
|-------|------|---------|-------------|
|
|
867
|
-
| `healthMonitor.enabled` | boolean | `true` | Enable/disable background health monitoring |
|
|
868
|
-
| `healthMonitor.interval` | number | `30000` | Interval between health checks (ms) |
|
|
869
|
-
| `healthMonitor.timeout` | number | `10000` | Timeout per server probe (ms) |
|
|
870
|
-
| `healthMonitor.idleTimeout` | number | `300000` | Stop probing after this much inactivity (ms). `0` = never sleep (legacy) |
|
|
871
|
-
| `requestTimeout` | number | `10000` | Timeout for individual server requests. For remote HTTP servers, this includes waiting for headers, reading response bodies, and SSE response parsing. Override via `--request-timeout` or `LAZY_MCP_REQUEST_TIMEOUT` |
|
|
872
|
-
|
|
873
|
-
To disable health monitoring:
|
|
874
|
-
```json
|
|
875
|
-
{
|
|
876
|
-
"servers": [...],
|
|
877
|
-
"healthMonitor": { "enabled": false }
|
|
878
|
-
}
|
|
879
|
-
```
|
|
880
|
-
|
|
881
|
-
To increase the request timeout (e.g. for slow remote servers):
|
|
882
|
-
```json
|
|
883
|
-
{
|
|
884
|
-
"servers": [...],
|
|
885
|
-
"requestTimeout": 30000
|
|
886
|
-
}
|
|
887
|
-
```
|
|
888
|
-
|
|
889
|
-
### Config Reload (SIGHUP)
|
|
890
|
-
|
|
891
|
-
You can reload the configuration without restarting the process by sending a `SIGHUP` signal:
|
|
892
|
-
|
|
893
|
-
```bash
|
|
894
|
-
kill -HUP $(pgrep -f lazy-mcp)
|
|
895
|
-
```
|
|
896
|
-
|
|
897
|
-
This will:
|
|
898
|
-
- Re-read and validate the config file
|
|
899
|
-
- Add newly configured servers (lazy connection on first use)
|
|
900
|
-
- Remove servers no longer in config (closes connections)
|
|
901
|
-
- Reconnect servers whose config changed (updated URL, env, etc.)
|
|
902
|
-
- Preserve unchanged servers (keeps existing connections and caches)
|
|
903
|
-
- Restart the health monitor and probe all servers
|
|
904
|
-
|
|
905
|
-
If the new config is invalid, the reload is rejected and the current config continues running. All reload activity is logged to stderr.
|
|
906
|
-
|
|
907
|
-
> **Note**: SIGHUP is not available on Windows.
|
|
329
|
+
For the full reference — every field, OAuth flow, permission rule semantics, glob syntax, HTTP transport security, logging knobs, health-monitor tuning, and SIGHUP reload semantics — see **[doc/CONFIGURATION.md](./doc/CONFIGURATION.md)**.
|
|
908
330
|
|
|
909
331
|
## Benefits
|
|
910
332
|
|
|
@@ -921,9 +343,12 @@ If the new config is invalid, the reload is rejected and the current config cont
|
|
|
921
343
|
|
|
922
344
|
## Documentation
|
|
923
345
|
|
|
924
|
-
- **[
|
|
925
|
-
- **[
|
|
346
|
+
- **[doc/CONFIGURATION.md](./doc/CONFIGURATION.md)** - Full configuration reference (servers, permissions, OAuth, transport, logging, health monitoring, SIGHUP reload)
|
|
347
|
+
- **[doc/HTTP_TRANSPORT.md](./doc/HTTP_TRANSPORT.md)** - Streamable HTTP transport setup, security, and reverse-proxy guidance
|
|
348
|
+
- **[doc/INTEGRATION.md](./doc/INTEGRATION.md)** - Client integration examples (Claude Desktop, OpenCode, Cursor, VS Code) for stdio and HTTP modes
|
|
349
|
+
- **[doc/RELEASES.md](./doc/RELEASES.md)** - Release pipeline and required CI/CD variables
|
|
926
350
|
- **[doc/ARCHITECTURE.md](./doc/ARCHITECTURE.md)** - Architecture overview and design patterns
|
|
927
351
|
- **[doc/CONTRIBUTING.md](./doc/CONTRIBUTING.md)** - Contributing guide with common development tasks
|
|
928
352
|
- **[doc/requests/](./doc/requests/)** - [Bruno](https://www.usebruno.com/) API collection for testing the Streamable HTTP transport. Open the `doc/requests/` folder as a collection in Bruno, select the `local` or `local-with-auth` environment, and run requests against a locally running `lazy-mcp --transport http` instance.
|
|
929
|
-
- **[
|
|
353
|
+
- **[CHANGELOG.md](./CHANGELOG.md)** - Version history and release notes
|
|
354
|
+
- **[AGENTS.md](./AGENTS.md)** - Development guide for AI coding agents (build commands, code style, testing patterns)
|