remote-access-mcp 3.0.0 → 3.0.1
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/AGENTS.md +78 -0
- package/docs/ai/architecture.md +106 -0
- package/docs/ai/decisions.md +141 -0
- package/docs/ai/security-model.md +114 -0
- package/docs/ai/tools-and-cli.md +152 -0
- package/docs/ai/transport-compatibility.md +85 -0
- package/package.json +4 -2
package/AGENTS.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# AGENTS.md — Guide for AI Coding Agents
|
|
2
|
+
|
|
3
|
+
This file is the entry point for any AI agent (Claude Code, Cursor, opencode,
|
|
4
|
+
Windsurf, etc.) working in this repository. Read it fully before touching code.
|
|
5
|
+
|
|
6
|
+
**Deep documentation lives in [`docs/ai/`](docs/ai/)** — go there next:
|
|
7
|
+
|
|
8
|
+
| File | What it covers |
|
|
9
|
+
|---|---|
|
|
10
|
+
| [`docs/ai/architecture.md`](docs/ai/architecture.md) | Module map, request lifecycle, data flow |
|
|
11
|
+
| [`docs/ai/tools-and-cli.md`](docs/ai/tools-and-cli.md) | Every tool (44) and CLI command, with schemas and policy gates |
|
|
12
|
+
| [`docs/ai/security-model.md`](docs/ai/security-model.md) | Auth, policy engine, guards, audit chain — read before security-adjacent changes |
|
|
13
|
+
| [`docs/ai/transport-compatibility.md`](docs/ai/transport-compatibility.md) | The three MCP dialects we serve and why (hard-won lessons) |
|
|
14
|
+
| [`docs/ai/decisions.md`](docs/ai/decisions.md) | Architecture Decision Records, including the fleet removal |
|
|
15
|
+
|
|
16
|
+
## What this project is
|
|
17
|
+
|
|
18
|
+
`remote-access-mcp` turns **one machine** (a server, a laptop, a desktop) into
|
|
19
|
+
an endpoint an AI chatbot can operate over the [Model Context Protocol](https://modelcontextprotocol.io).
|
|
20
|
+
ChatGPT, Grok and Claude connectors drive the machine's filesystem, shell,
|
|
21
|
+
services, logs and packages through a per-token security policy.
|
|
22
|
+
|
|
23
|
+
**One gateway = one machine. By design.** Multi-server orchestration ("fleet
|
|
24
|
+
mode") was implemented and then removed (v3.0.0) — see
|
|
25
|
+
[decisions.md ADR-007](docs/ai/decisions.md). If a task asks you to add it
|
|
26
|
+
back, stop and confirm with the owner first.
|
|
27
|
+
|
|
28
|
+
## The 30-second mental model
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Chatbot (ChatGPT/Grok/Claude)
|
|
32
|
+
│ HTTPS via Cloudflare (TLS) / or quick tunnel on laptops
|
|
33
|
+
▼
|
|
34
|
+
nginx (port 80, SSE-tuned) ── only on servers with a domain
|
|
35
|
+
▼
|
|
36
|
+
express gateway (127.0.0.1:8765) ── node dist/server/run.js
|
|
37
|
+
├─ authenticate token (URL-path or Bearer header)
|
|
38
|
+
├─ per-token policy: scopes + paths + shell flag + read-only + rpm + expiry
|
|
39
|
+
├─ per-request McpServer build → tools re-registered fresh (hot-reload)
|
|
40
|
+
├─ audit wrapper → JSONL hash chain + webhook notifications
|
|
41
|
+
▼
|
|
42
|
+
the machine: fs, shell, systemd, journalctl, apt/npm, git, sqlite …
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Server speaks **both** Streamable HTTP (stateless + stateful) **and** the
|
|
46
|
+
legacy 2024-11-05 SSE transport, on **both** `/mcp` and `/sse` paths.
|
|
47
|
+
- Config: `~/.config/remote-access-mcp/` (Linux), `AppData/Roaming` (Win),
|
|
48
|
+
`Library/Application Support` (mac). Live tunnel URL goes to `runtime.json`,
|
|
49
|
+
never config.
|
|
50
|
+
|
|
51
|
+
## Ground rules for changes
|
|
52
|
+
|
|
53
|
+
1. **TypeScript strict, ESM-only, zero `require()`** in `src/` — a `require()`
|
|
54
|
+
slipped past vitest once and crash-looped production for a day (ADR-004).
|
|
55
|
+
2. **No native dependencies** in the runtime path. better-sqlite3 caused
|
|
56
|
+
SIGABRT in the stateless loop; the audit log is plain JSONL now (ADR-005).
|
|
57
|
+
3. **Tests must be hermetic** — build gateway state explicitly; never read the
|
|
58
|
+
host's real config. A test suite that leaks production config will fail
|
|
59
|
+
whenever the service is running (ADR-006).
|
|
60
|
+
4. **Run `npm test` (107 tests) before any publish.** CI runs Node 18/20/22.
|
|
61
|
+
5. **Schema parameters must never be conditionally omitted** — the SDK
|
|
62
|
+
silently drops client args that aren't in the schema, which once turned a
|
|
63
|
+
"remote" command into a local execution (ADR-008).
|
|
64
|
+
6. Commits: conventional, small, one concern each. This repo's history is
|
|
65
|
+
read as documentation (see `docs/ai/decisions.md`).
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm run build # tsc → dist/
|
|
71
|
+
npm test # vitest, 107 tests
|
|
72
|
+
npm run dev # tsx watch (local only)
|
|
73
|
+
ramcp doctor # after deploying: full-chain health check
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Deploy flow: build → test → `npm publish` → `npm i -g remote-access-mcp@latest`
|
|
77
|
+
on the server → `systemctl restart remote-access-mcp` → verify via
|
|
78
|
+
`ramcp doctor` + a ChatGPT-dialect initialize POST.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
How the gateway is put together, for AI agents (and humans) who need to
|
|
4
|
+
reason about it before changing it.
|
|
5
|
+
|
|
6
|
+
## Module map
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
src/
|
|
10
|
+
├── core/ # framework-free building blocks
|
|
11
|
+
│ ├── config.ts # RamcpConfig + tokens; loads/migrates config.json
|
|
12
|
+
│ ├── policy.ts # path sandbox, scope groups, tool permission gate
|
|
13
|
+
│ ├── audit.ts # JSONL hash-chain audit log + redaction
|
|
14
|
+
│ ├── crypto.ts # timing-safe token compare
|
|
15
|
+
│ ├── rate-limit.ts # token-bucket per token
|
|
16
|
+
│ ├── platform.ts # OS detection, shells, dataDir, runtime state
|
|
17
|
+
│ ├── tunnel.ts # cloudflared quick-tunnel lifecycle
|
|
18
|
+
│ ├── webhooks.ts # fire-and-forget event notifications
|
|
19
|
+
│ └── context.ts # ConfigWatcher (hot-reload) + ToolContext
|
|
20
|
+
├── server/
|
|
21
|
+
│ ├── app.ts # express app: auth, routes, sessions, MCP wiring
|
|
22
|
+
│ ├── run.ts # boot: listen, tunnel, runtime state, shutdown
|
|
23
|
+
│ ├── sessions.ts # stateful MCP session store (Claude dialect)
|
|
24
|
+
│ └── legacy-sse.ts # 2024-11-05 SSE transport + keepalive
|
|
25
|
+
├── tools/ # one file per suite, registered per request
|
|
26
|
+
│ ├── filesystem.ts shell.ts system.ts http.ts git.ts sqlite.ts
|
|
27
|
+
│ ├── logs.ts services.ts packages.ts schedule.ts security.ts
|
|
28
|
+
│ ├── project.ts web.ts planning.ts ops.ts policy.ts index.ts
|
|
29
|
+
└── cli/main.ts # ramcp — all commands
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Request lifecycle (the part that matters most)
|
|
33
|
+
|
|
34
|
+
One MCP POST travels this path:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
nginx ─→ express
|
|
38
|
+
│ app.all('/<token>/sse') or ('/sse' + Bearer header)
|
|
39
|
+
├─ authenticate(presented) [core/crypto + config]
|
|
40
|
+
│ reloads config if mtime changed (hot-reload)
|
|
41
|
+
│ timing-safe compare against all tokens
|
|
42
|
+
├─ rateLimited(token)? → 429
|
|
43
|
+
├─ normalizeAccept(req) [compat: never 406]
|
|
44
|
+
├─ session routing:
|
|
45
|
+
│ Mcp-Session-Id present?
|
|
46
|
+
│ ├─ known session → its StreamableHTTP transport
|
|
47
|
+
│ └─ unknown → 404 (client restarts cleanly)
|
|
48
|
+
│ no session id:
|
|
49
|
+
│ ├─ initialize → new stateful session (id in response header)
|
|
50
|
+
│ └─ anything else → stateless throwaway transport
|
|
51
|
+
├─ buildServerFor(token) [per request!]
|
|
52
|
+
│ McpServer + registerAllTools(server, ctx)
|
|
53
|
+
│ every tool handler wrapped: try/catch → audit → webhooks
|
|
54
|
+
└─ transport.handleRequest → tool executes → response
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Tools are re-registered for every request.** That is what makes policy
|
|
58
|
+
edits (CLI or in-chat `allow_path`) apply on the next request with zero
|
|
59
|
+
restarts. Do not "optimize" this by caching servers across requests — it
|
|
60
|
+
would break hot-reload and per-token isolation.
|
|
61
|
+
|
|
62
|
+
`ctx: ToolContext` carries `{ cfg, token, readOnly, persist, audit }`.
|
|
63
|
+
The token record is looked up fresh per request, so a revoked token dies
|
|
64
|
+
mid-conversation.
|
|
65
|
+
|
|
66
|
+
## The three dialects (why server code looks redundant)
|
|
67
|
+
|
|
68
|
+
| Client | Dialect | First request | Follow-ups |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| ChatGPT, Grok | Streamable, stateless | POST initialize (200 JSON/SSE) | fresh POST per call, ignores session id |
|
|
71
|
+
| Claude (new SDK) | Streamable, stateful | POST initialize → **Mcp-Session-Id header back** | same id on every request; GET opens notification stream |
|
|
72
|
+
| Claude (auto for `/sse` URLs) | Legacy SSE 2024-11-05 | **GET** stream → `event: endpoint` frame | POST to `/<token>/sse/messages?sessionId=…`, replies ride the stream |
|
|
73
|
+
|
|
74
|
+
GET disambiguation: a GET **with** `Mcp-Session-Id` is a stateful client
|
|
75
|
+
opening its notification stream; **without** it, it's a legacy client
|
|
76
|
+
starting the handshake. Mixing these up sends `event: endpoint` to a
|
|
77
|
+
stateful client, which aborts with `Unknown SSE event: endpoint` — that
|
|
78
|
+
exact bug killed Claude connectors for a full day. Tests pin it
|
|
79
|
+
(`tests/get-dispatch.test.ts`).
|
|
80
|
+
|
|
81
|
+
## Data & state
|
|
82
|
+
|
|
83
|
+
| File | Lives in | Written by | Lifetime |
|
|
84
|
+
|---|---|---|---|
|
|
85
|
+
| `config.json` | dataDir per OS | CLI, policy tools | persistent; 0600 |
|
|
86
|
+
| `audit.jsonl` | dataDir | audit wrapper | append-only, hash-chained |
|
|
87
|
+
| `runtime.json` | dataDir | gateway boot | ephemeral — pid-checked, cleared on exit |
|
|
88
|
+
| `schedule.json`, `plans.json`, `snapshots.json`, `snapshots/` | dataDir | tools | persistent |
|
|
89
|
+
|
|
90
|
+
The tunnel URL **only** ever goes to `runtime.json`. Writing it to config
|
|
91
|
+
would clobber a server's real `public_host` the moment someone ran
|
|
92
|
+
`ramcp tunnel` on it (that happened; ADR-003).
|
|
93
|
+
|
|
94
|
+
## Cross-platform layer
|
|
95
|
+
|
|
96
|
+
`core/platform.ts` normalizes: which shell (`bash -lc` / `pwsh` / `cmd.exe`),
|
|
97
|
+
where data lives, how paths compare (case-insensitive on Win/mac),
|
|
98
|
+
`which()`, `hasSystemd()`. Tools call `shellCommand()` instead of hardcoding
|
|
99
|
+
bash — Windows compat lives or dies here.
|
|
100
|
+
|
|
101
|
+
## Service installers
|
|
102
|
+
|
|
103
|
+
`ramcp service install` writes a systemd unit (Linux), a launchd plist
|
|
104
|
+
(macOS, per-user, no sudo), or a schtasks entry (Windows, logon trigger).
|
|
105
|
+
The unit points at `dist/server/run.js` under the *global npm* install path —
|
|
106
|
+
`ramcp upgrade` rewrites nothing but must restart the right manager per OS.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Decision Records (ADR)
|
|
2
|
+
|
|
3
|
+
Short, dated records of *why* the code is the way it is. Each one encodes a
|
|
4
|
+
lesson that cost real debugging time. When one of these decisions blocks
|
|
5
|
+
something you're trying to do, don't quietly route around it — write a new
|
|
6
|
+
record superseding the old one.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## ADR-001 — Express + per-request McpServer builds
|
|
11
|
+
**Status:** accepted · **Date:** 2026-08-30
|
|
12
|
+
|
|
13
|
+
The gateway builds a **fresh `McpServer` with all tools re-registered for
|
|
14
|
+
every request** (stateless dialect) or per session (stateful).
|
|
15
|
+
|
|
16
|
+
Why: (a) policy mutations — CLI, config hot-reload, or in-chat
|
|
17
|
+
`allow_path` — must apply on the *next* request, not after a restart;
|
|
18
|
+
(b) per-request builds bind the calling token's record into the handlers,
|
|
19
|
+
so revocation/rotation takes effect mid-conversation; (c) the MCP SDK
|
|
20
|
+
guidance for our compat matrix is one transport per request anyway.
|
|
21
|
+
|
|
22
|
+
Cost: registration overhead per request (~1ms measured). Accepted.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## ADR-002 — Dual auth: Bearer header *and* token-in-URL
|
|
27
|
+
**Status:** accepted · **Date:** 2026-08-30
|
|
28
|
+
|
|
29
|
+
ChatGPT custom connectors cannot set custom headers, so the token rides in
|
|
30
|
+
the URL path (`/<token>/mcp`). Everything else uses `Authorization: Bearer`.
|
|
31
|
+
Both resolve through the same timing-safe comparison.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## ADR-003 — Tunnel URL lives in runtime.json, never config
|
|
36
|
+
**Status:** accepted · **Date:** 2026-08-31
|
|
37
|
+
|
|
38
|
+
Quick-tunnel URLs are valid only while the gateway process runs. The first
|
|
39
|
+
implementation persisted the URL into `config.json`'s `public_host` — and
|
|
40
|
+
one test run on the production server **clobbered the real domain**, after
|
|
41
|
+
which `ramcp url` printed a dead trycloudflare URL to the user.
|
|
42
|
+
|
|
43
|
+
Fix: `runtime.json` = {pid, tunnel_url, host, port, started}, written on
|
|
44
|
+
boot, pid-checked on read (stale → ignored), cleared on exit including
|
|
45
|
+
`process.on('exit')` for Windows service stops. `ramcp url` prefers the
|
|
46
|
+
live tunnel. Pinned by `tests/runtime-state.test.ts`.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## ADR-004 — Pure ESM, no require() in src/
|
|
51
|
+
**Status:** accepted · **Date:** 2026-08-31
|
|
52
|
+
|
|
53
|
+
A `require()` in `src/cli/main.ts` passed the entire test suite (vitest's
|
|
54
|
+
CJS interop tolerates it) and **crash-looped production** within a second
|
|
55
|
+
of every systemd start. Diagnosis needed a boot test that runs the built
|
|
56
|
+
output under plain node — which now exists (`tests/boot.test.ts`) and
|
|
57
|
+
should be the pattern for anything touching startup paths.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## ADR-005 — Audit log is JSONL, not better-sqlite3
|
|
62
|
+
**Status:** accepted · **Date:** 2026-08-31
|
|
63
|
+
|
|
64
|
+
The original audit used better-sqlite3. In the stateless request loop its
|
|
65
|
+
native `Statement` destructor aborted Node with SIGABRT (~8 requests in,
|
|
66
|
+
native stack trace pointing at `Statement::~Statement`). Root cause:
|
|
67
|
+
better-sqlite3's teardown hooks racing the SDK's per-request transports.
|
|
68
|
+
|
|
69
|
+
The rewrite is a plain append + fsync JSONL file with the same hash chain,
|
|
70
|
+
zero native code in the dependency tree, and a regression test that runs
|
|
71
|
+
30 full stateless cycles with audit writes (`tests/crash-regression.test.ts`).
|
|
72
|
+
sqlite_query/sqlite_schema keep better-sqlite3 for *user* databases (open
|
|
73
|
+
per call, closed in-finally) — that usage never crashed.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## ADR-006 — Hermetic tests: build gateway state explicitly
|
|
78
|
+
**Status:** accepted · **Date:** 2026-08-31
|
|
79
|
+
|
|
80
|
+
Tests that called `buildApp()` without seeding state read the **host's real
|
|
81
|
+
config**. It worked until the production server deployed with
|
|
82
|
+
`mcp_path: /sse` — then 10 tests failed only-when-the-service-was-running.
|
|
83
|
+
All suites now construct explicit `GatewayState` objects (see any
|
|
84
|
+
`tests/*.test.ts` `beforeAll`).
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## ADR-007 — Fleet mode removed (single machine by design)
|
|
89
|
+
**Status:** accepted · **Date:** 2026-09-01 · **Supersedes:** the v2.4.0 fleet feature
|
|
90
|
+
|
|
91
|
+
Fleet (SSH to N machines, per-host tool allowlists, 14 remote-capable
|
|
92
|
+
tools, `ramcp fleet` CLI) was fully implemented, tested (including live-SSH
|
|
93
|
+
and fake-ssh suites), published as 2.4.x — and then **removed entirely** in
|
|
94
|
+
3.0.0 by owner decision: the product is one gateway per machine; multi-
|
|
95
|
+
server orchestration is not the problem this project solves.
|
|
96
|
+
|
|
97
|
+
What the removal kept: webhooks and `config export/import` (independent
|
|
98
|
+
value). What it took with it: `src/core/fleet.ts`, `src/tools/fleet.ts`,
|
|
99
|
+
the `host` parameter on 14 tools, fleet CLI/scopes/config, three test
|
|
100
|
+
files. `ramcp fleet` is now an unknown command (pinned by a test).
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## ADR-008 — Never omit schema parameters conditionally
|
|
105
|
+
**Status:** accepted · **Date:** 2026-09-01 · **Learned from:** the fleet era
|
|
106
|
+
|
|
107
|
+
When a tool's schema includes a parameter only under some conditions
|
|
108
|
+
(e.g., `host` only when fleet hosts exist), the MCP SDK **silently strips**
|
|
109
|
+
client arguments that aren't in the current schema. Consequence observed on
|
|
110
|
+
production: a client sending `run_command {command, host: "ghost"}$` to a
|
|
111
|
+
fleet-less gateway had `host` dropped and the **"remote" command executed
|
|
112
|
+
locally on the gateway** — a silent security downgrade, not an error.
|
|
113
|
+
|
|
114
|
+
Rule: parameters that gate security-relevant behavior stay in the schema
|
|
115
|
+
always; the *handler* refuses with a clear error when the capability is
|
|
116
|
+
absent. (Superseded by ADR-007 removing the host param outright, but the
|
|
117
|
+
rule stands for anything like it.)
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## ADR-009 — SSE-framed responses by default
|
|
122
|
+
**Status:** accepted · **Date:** 2026-08-31
|
|
123
|
+
|
|
124
|
+
`enableJsonResponse: true` (plain-JSON replies) works for ChatGPT/Grok but
|
|
125
|
+
Claude's connector read a valid 200 initialize reply and silently
|
|
126
|
+
abandoned the connection. SSE framing (`event: message\ndata: …`,
|
|
127
|
+
`content-type: text/event-stream`) is the reference behavior and the only
|
|
128
|
+
framing observed to satisfy every dialect we've tested. The wire-log proxy
|
|
129
|
+
session that proved this is described in transport-compatibility.md.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## ADR-010 — execFile does not support the `input` option
|
|
134
|
+
**Status:** accepted · **Date:** 2026-09-01
|
|
135
|
+
|
|
136
|
+
`promisify(execFile)({... input})` never closes the child's stdin — the
|
|
137
|
+
call hangs until timeout (fleet's remote file writes hung 5s→timeout every
|
|
138
|
+
time before this was understood; a minimal `bash -c cat` repro confirmed
|
|
139
|
+
it's the API, not our code). Pattern: when stdin must be piped, use
|
|
140
|
+
`spawn()` and end the stream manually. The helper shape lives in git
|
|
141
|
+
history (core/fleet.ts) if ever needed again.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Security Model
|
|
2
|
+
|
|
3
|
+
The gateway hands an AI the keys to a machine. Every design choice below
|
|
4
|
+
exists to make that survivable. Read this before touching anything in the
|
|
5
|
+
auth, policy, or audit paths.
|
|
6
|
+
|
|
7
|
+
## Threat model, plainly
|
|
8
|
+
|
|
9
|
+
The token holder is **trusted** (that's you). The threats are:
|
|
10
|
+
1. **Token leakage** → mitigate: no secrets in logs/audit (fingerprints only),
|
|
11
|
+
tight file perms, rotate in one command.
|
|
12
|
+
2. **The AI itself** going somewhere you didn't intend → mitigate: path
|
|
13
|
+
sandbox, scope groups, read-only mode, shell opt-in, protected
|
|
14
|
+
services/packages/processes, SSRF walls.
|
|
15
|
+
3. **A hostile chat platform or MITM** → mitigate: TLS at the edge
|
|
16
|
+
(Cloudflare/Let's Encrypt), loopback-only bind, timing-safe compares.
|
|
17
|
+
4. **Tampering with history** after an incident → the hash chain.
|
|
18
|
+
|
|
19
|
+
## Layer 1 — Network
|
|
20
|
+
|
|
21
|
+
- Binds `127.0.0.1` only. Nothing listens publicly but nginx/Cloudflare.
|
|
22
|
+
- `ramcp tunnel` (laptops) publishes a temporary https URL via cloudflared;
|
|
23
|
+
the URL dies with the process, is tracked in pid-checked `runtime.json`,
|
|
24
|
+
and self-verifies reachability before promising anything.
|
|
25
|
+
|
|
26
|
+
## Layer 2 — Authentication
|
|
27
|
+
|
|
28
|
+
- Two equivalent forms: `Authorization: Bearer <token>` or the token in the
|
|
29
|
+
URL path (`/<token>/mcp`) — the latter because ChatGPT connectors cannot
|
|
30
|
+
set custom headers.
|
|
31
|
+
- Compare is **timing-safe** with a length pre-filter (`core/crypto.ts`).
|
|
32
|
+
- Token records: expiry (ISO date), per-token rate limit (token bucket,
|
|
33
|
+
burst = rpm/4), revocation is config write + hot reload — a rotated token
|
|
34
|
+
dies mid-conversation.
|
|
35
|
+
- Sessions (stateful dialect) are **bound to the token fingerprint** that
|
|
36
|
+
opened them: presenting another token with a stolen session id → 403.
|
|
37
|
+
|
|
38
|
+
## Layer 3 — Authorization (per token)
|
|
39
|
+
|
|
40
|
+
`assertToolPermitted()` enforces, in order:
|
|
41
|
+
|
|
42
|
+
1. **scopes** — empty = every group; else the tool must belong to a listed
|
|
43
|
+
group. Group map is static in `core/policy.ts` (TOOL_SCOPES).
|
|
44
|
+
2. **read-only** — per-token or global `ramcp policy readonly on` refuses
|
|
45
|
+
every entry of `MUTATING_TOOLS`.
|
|
46
|
+
3. **path sandbox** — only if the tool passes a `target`:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
resolveReal(path) # realpathSync; for missing paths, the deepest
|
|
50
|
+
# existing ancestor is resolved and the
|
|
51
|
+
# remainder re-attached → symlink escapes collapse
|
|
52
|
+
→ in denied_paths? → refuse # deny always wins, even inside an allowed root
|
|
53
|
+
→ in allowed_paths? → allow
|
|
54
|
+
→ else → refuse # empty allow-list denies everything
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Path comparison is case-insensitive on Windows/macOS, separators normalized
|
|
58
|
+
(`core/platform.ts`).
|
|
59
|
+
|
|
60
|
+
**In-chat policy tools can only widen their own token's sandbox** — they
|
|
61
|
+
mutate the calling token's record, never another token's.
|
|
62
|
+
|
|
63
|
+
## Layer 4 — Tool-level guards
|
|
64
|
+
|
|
65
|
+
| Guard | Tool(s) | Behavior |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| Shell opt-in | run_command, kill_process, schedule | refuse unless token `shell_enabled` |
|
|
68
|
+
| Protected pids | kill_process | gateway pid, ppid, PID 1 |
|
|
69
|
+
| SSRF wall | http_request, web_fetch, port_check | 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16 (cloud metadata), 0/8, ::1, fc00::/7, fe80::/10, IPv4-mapped v6, metadata hostnames |
|
|
70
|
+
| Git arg whitelist | git | ~45 known verbs; `--upload-pack`/`--exec=` refused; no `;&|`${}`<>` in args |
|
|
71
|
+
| SQL single-statement | sqlite_query | one statement; ATTACH/DETACH refused (escape the sandbox via another db file) |
|
|
72
|
+
| Unit-name regex | service_*, journal | `[A-Za-z0-9@._-]+` only |
|
|
73
|
+
| Protected units | service_action | ssh/sshd/systemd/networkd/dbus/gateway itself/major Windows services |
|
|
74
|
+
| Protected packages | package_remove | nodejs/npm/nginx/openssh/systemd/remote-access-mcp |
|
|
75
|
+
| Secret masking | secret_scan | matches reported as file:line + type only — never the secret |
|
|
76
|
+
| Secret redaction | environment_inspect | pass/secret/token/key/auth/credential vars → `[MASKED]` |
|
|
77
|
+
| Schedule floor | schedule_command | ≥60s recurrence; one-shot allowed |
|
|
78
|
+
|
|
79
|
+
## Layer 5 — Accounting
|
|
80
|
+
|
|
81
|
+
- **Audit log** (`audit.jsonl`): one line per tool invocation — ts, token
|
|
82
|
+
**fingerprint** (sha256-16, never the secret), tool, redacted args,
|
|
83
|
+
ok/is_error, duration. Each line's `hash = sha256(prev_hash + fields)` →
|
|
84
|
+
deleting or editing any line breaks every hash after it.
|
|
85
|
+
`ramcp audit chain` walks it; exit code 2 on tamper. Args are redacted
|
|
86
|
+
(`pass|secret|token|key|auth` keys → `[REDACTED]`, values capped at 300ch).
|
|
87
|
+
- **Webhooks** mirror tool.error/tool.success out to URLs you own —
|
|
88
|
+
fire-and-forget, 5s timeout, identical events deduped within 10s.
|
|
89
|
+
|
|
90
|
+
## Layer 6 — The audit wrapper itself
|
|
91
|
+
|
|
92
|
+
`buildServerFor()` monkey-patches `server.registerTool` so **every** handler
|
|
93
|
+
is wrapped: errors are caught and converted to `isError` results (a tool
|
|
94
|
+
crash must never 500 the transport), then audit + webhooks fire. This is
|
|
95
|
+
also the single choke point where a future change would accidentally bypass
|
|
96
|
+
accounting — keep it that way.
|
|
97
|
+
|
|
98
|
+
## Things we refuse to add (non-negotiable)
|
|
99
|
+
|
|
100
|
+
- No `require()` in `src/` (pure ESM; one slip crash-looped production).
|
|
101
|
+
- No native modules in the request path (better-sqlite3 SIGABRT'd the
|
|
102
|
+
stateless loop — audit is plain JSONL now).
|
|
103
|
+
- No dynamic schema omission (the SDK silently drops unknown client args;
|
|
104
|
+
that once executed a "remote" command locally — ADR-008).
|
|
105
|
+
- No second machine (fleet removed by owner decision — ADR-007).
|
|
106
|
+
|
|
107
|
+
## If you're adding a tool
|
|
108
|
+
|
|
109
|
+
Checklist: (1) pick/extend a scope group; (2) decide mutating or read-only
|
|
110
|
+
and add to MUTATING_TOOLS if mutating; (3) accept `path`-style targets →
|
|
111
|
+
pass them as `target:` to the gate; (4) think about injection (quotes,
|
|
112
|
+
metacharacters, second-order effects) and add a guard, not a regex hope;
|
|
113
|
+
(5) never log secrets; the audit wrapper redacts args but your *output* is
|
|
114
|
+
on you.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Tools & CLI Reference
|
|
2
|
+
|
|
3
|
+
Complete inventory of what the gateway exposes, as of v3.0.0.
|
|
4
|
+
Counts: **44 tools / 17 suites**, **17 CLI commands**.
|
|
5
|
+
|
|
6
|
+
Every tool handler runs inside the audit wrapper (app.ts): execution is
|
|
7
|
+
timed, arguments redacted, outcome appended to the hash chain, webhooks
|
|
8
|
+
notified. A thrown `PolicyError`/`ScopeError` becomes a normal `isError`
|
|
9
|
+
result — never a 500.
|
|
10
|
+
|
|
11
|
+
## Permission gate (read this first)
|
|
12
|
+
|
|
13
|
+
`assertToolPermitted({ tool, scopes, readOnly, policy?, target? })` in
|
|
14
|
+
`core/policy.ts` — three checks, in order:
|
|
15
|
+
|
|
16
|
+
1. **scopes** — token's `scopes[]` empty = all groups; otherwise tool must
|
|
17
|
+
be in a listed group (group map in `TOOL_SCOPES`).
|
|
18
|
+
2. **readOnly** — token or global read-only refuses `MUTATING_TOOLS`.
|
|
19
|
+
3. **policy paths** — if `target` given: resolveReal() (symlinks collapsed,
|
|
20
|
+
deepest existing ancestor for not-yet-existing files) → deny-list wins →
|
|
21
|
+
must be inside an allow-list entry.
|
|
22
|
+
|
|
23
|
+
Scope groups: `filesystem shell system http git sqlite policy logs services
|
|
24
|
+
packages schedule security project planning formatting documents ops web`.
|
|
25
|
+
(`ramcp scopes` prints them with member tools.)
|
|
26
|
+
|
|
27
|
+
## Suites
|
|
28
|
+
|
|
29
|
+
### filesystem (7) — policy-gated on every path
|
|
30
|
+
| Tool | Notable |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `list_directory` | 2000-entry cap |
|
|
33
|
+
| `read_file` | `offset`/`limit` line windows |
|
|
34
|
+
| `write_file` | mkdir option |
|
|
35
|
+
| `edit_file` | exact-text replace, `all` flag |
|
|
36
|
+
| `delete_path` | rm -rf — destructive, policy-gated |
|
|
37
|
+
| `search_code` | recursive regex, skips .git/node_modules |
|
|
38
|
+
| `file_info` | stat essentials |
|
|
39
|
+
|
|
40
|
+
### shell (3) — behind token `shell_enabled`
|
|
41
|
+
| Tool | Notable |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `run_command` | platform shell via `shellCommand()`; ≤600s; 60KB output cap; cwd policy-gated |
|
|
44
|
+
| `process_list` | ps (POSIX) / Get-Process (Win) |
|
|
45
|
+
| `kill_process` | refuses gateway pid, ppid, PID 1 |
|
|
46
|
+
|
|
47
|
+
### system (3)
|
|
48
|
+
`system_info` · `disk_usage` (df / Get-Volume) · `network_interfaces` — all
|
|
49
|
+
cross-platform, no gates (read-only host facts).
|
|
50
|
+
|
|
51
|
+
### http (3) — SSRF-guarded
|
|
52
|
+
`http_request` · `port_check` · `web_fetch` — loopback/private/link-local
|
|
53
|
+
(169.254.0.0/16 incl. cloud metadata) refused; IPv4-mapped IPv6 too.
|
|
54
|
+
|
|
55
|
+
### git (1)
|
|
56
|
+
`git` — args validated: verb must match a whitelist (~45 verbs, no
|
|
57
|
+
`upload-pack`/`exec=`), no shell metacharacters in any arg; `repo_path`
|
|
58
|
+
policy-gated.
|
|
59
|
+
|
|
60
|
+
### sqlite (2) — policy-gated on db_path
|
|
61
|
+
`sqlite_query` — single statement only, ATTACH/DETACH refused, SELECT/WITH/
|
|
62
|
+
PRAGMA/EXPLAIN return rows, others report changes. `sqlite_schema`.
|
|
63
|
+
|
|
64
|
+
### logs (3) — policy-gated paths
|
|
65
|
+
`tail_logs` (1MB tail read) · `search_logs` (regex + context) · `journal`
|
|
66
|
+
(unit-name regex validated; journalctl / mac `log show` / Get-WinEvent).
|
|
67
|
+
|
|
68
|
+
### services (2)
|
|
69
|
+
`service_status` · `service_action` — `PROTECTED_UNITS` regex refuses ssh,
|
|
70
|
+
the gateway itself, dbus/networkd/WinDefend etc. Unit names regex-validated.
|
|
71
|
+
|
|
72
|
+
### packages (3)
|
|
73
|
+
`package_list` / `package_install` / `package_remove` — manager auto-detected
|
|
74
|
+
(apt / brew / winget / choco / npm); `package_remove` refuses
|
|
75
|
+
nodejs/npm/nginx/openssh/systemd/remote-access-mcp.
|
|
76
|
+
|
|
77
|
+
### schedule (3)
|
|
78
|
+
`schedule_command` (one-shot ISO time or recurring ≥60s; requires shell
|
|
79
|
+
token) · `list_scheduled_tasks` · `cancel_scheduled_task`. File-backed
|
|
80
|
+
(`schedule.json`), a 30s in-process ticker executes them.
|
|
81
|
+
|
|
82
|
+
### security (2)
|
|
83
|
+
`secret_scan` — 10 credential patterns (keys, AWS, GitHub, Slack, npm,
|
|
84
|
+
bearer, connection-string passwords); **output is masked**, only
|
|
85
|
+
file:line + type. `port_scan_local` — ss / lsof / Get-NetTCPConnection.
|
|
86
|
+
|
|
87
|
+
### project (2)
|
|
88
|
+
`analyze_project` (files/LOC/manifests/entry points, depth-capped walk) ·
|
|
89
|
+
`project_health_check` (git dirty, README presence, TODO density).
|
|
90
|
+
|
|
91
|
+
### planning (4)
|
|
92
|
+
`create_task_plan` · `task_status` (mark steps done) · `workspace_snapshot`
|
|
93
|
+
(content-addressed file copies) · `rollback_changes` (atomic restore).
|
|
94
|
+
Snapshot/rollback are in `MUTATING_TOOLS`.
|
|
95
|
+
|
|
96
|
+
### web (1)
|
|
97
|
+
`web_fetch` — SSRF-guarded public fetch, UA-tagged, size-capped.
|
|
98
|
+
|
|
99
|
+
### ops (2)
|
|
100
|
+
`environment_inspect` — env vars, secrets masked, long values truncated.
|
|
101
|
+
`nginx_inspect` — read-only; site names regex-validated (no traversal);
|
|
102
|
+
`nginx -T` summary or per-site config.
|
|
103
|
+
|
|
104
|
+
### policy (4) — token manages only its own sandbox
|
|
105
|
+
`list_allowed_paths` · `allow_path` · `deny_path` · `shell_enabled`. Mutations
|
|
106
|
+
persist via `saveConfig` and hot-reload; they cannot touch other tokens.
|
|
107
|
+
|
|
108
|
+
## CLI (`ramcp`)
|
|
109
|
+
|
|
110
|
+
| Command | Subcommands / flags | Notes |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `init` | `--paths a,b` | creates config + default token; re-run safe |
|
|
113
|
+
| `start` | `--tunnel --read-only --host --port` | foreground |
|
|
114
|
+
| `tunnel` | — | gateway + public URL; runtime state; self-checks reachability |
|
|
115
|
+
| `url` | `[name]` | live tunnel URL wins, else public_host/local |
|
|
116
|
+
| `token` | `add --name --paths --deny --scopes --shell --read-only --rpm --expires` · `list [--json]` · `show [--full]` · `rotate` · `revoke` | last token can't be revoked |
|
|
117
|
+
| `policy` | `allow <p…>` · `deny <p…>` · `shell on/off` · `scopes <groups|all>` (all `--token`) · `readonly on/off` (global) | multi-path, space-separated |
|
|
118
|
+
| `scopes` | — | group → tools map |
|
|
119
|
+
| `audit` | `--tool --since --limit --json` · `chain` | chain verifies hash chain, exit 2 on tamper |
|
|
120
|
+
| `doctor` | — | platform, config, tokens, gateway, service, tunnel, public, audit |
|
|
121
|
+
| `service` | `install [--domain] [--tunnel] [--dry-run]` · `uninstall` · `logs [-f]` · `status` | systemd / launchd / schtasks by OS |
|
|
122
|
+
| `schedule` | `list [--json]` | |
|
|
123
|
+
| `upgrade` | `--dry-run` | npm self-update + right restart per OS |
|
|
124
|
+
| `status` | — | incl. live runtime state |
|
|
125
|
+
| `version` | — | |
|
|
126
|
+
| `config` | `show` · `export [--out]` · `import FILE [--merge]` | export 0600, contains live tokens; merge preserves local identity |
|
|
127
|
+
| `webhook` | `add --url --events` · `list` · `on/off URL` · `remove URL` | events: `tool.error`, `tool.success`, `*` |
|
|
128
|
+
| ~~`fleet`~~ | — | **removed in v3.0.0** — unknown command now |
|
|
129
|
+
|
|
130
|
+
### Config file shape (v3)
|
|
131
|
+
|
|
132
|
+
```jsonc
|
|
133
|
+
{
|
|
134
|
+
"host": "127.0.0.1", "port": 8765, "public_host": "mcp.example.com",
|
|
135
|
+
"mcp_path": "/mcp", "mcp_path_aliases": ["/sse"],
|
|
136
|
+
"log_level": "info",
|
|
137
|
+
"audit": { "enabled": true, "db_path": "…/audit.jsonl" },
|
|
138
|
+
"read_only": false,
|
|
139
|
+
"tunnel": { "provider": "cloudflare", "auto_start": false },
|
|
140
|
+
"webhooks": [{ "url": "…", "events": ["tool.error"], "enabled": true }],
|
|
141
|
+
"tokens": [{
|
|
142
|
+
"id": "…", "name": "default", "token": "…", "created": "ISO",
|
|
143
|
+
"expires": "ISO?", "scopes": [], "read_only": false,
|
|
144
|
+
"max_requests_per_minute": 60?, "shell_enabled": true,
|
|
145
|
+
"allowed_paths": ["…"], "denied_paths": []
|
|
146
|
+
}]
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Env overrides (tests, minimal deploys): `RAMCP_TOKEN RAMCP_HOST RAMCP_PORT
|
|
151
|
+
RAMCP_PUBLIC_HOST RAMCP_SHELL RAMCP_ALLOWED_PATHS RAMCP_DENIED_PATHS`
|
|
152
|
+
(legacy `DANA_*` still honored). `SSH_BIN` existed for fleet tests — gone.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Transport Compatibility
|
|
2
|
+
|
|
3
|
+
Why the server speaks three MCP dialects, and the exact bugs that forced
|
|
4
|
+
each one. This file exists so nobody "simplifies" it away.
|
|
5
|
+
|
|
6
|
+
## The three dialects
|
|
7
|
+
|
|
8
|
+
| # | Dialect | Who speaks it | Handshake | Replies |
|
|
9
|
+
|---|---|---|---|---|
|
|
10
|
+
| 1 | Streamable HTTP, **stateless** | ChatGPT, Grok | POST initialize | inside the POST response; session id ignored |
|
|
11
|
+
| 2 | Streamable HTTP, **stateful** | Claude (new SDK), most reference clients | POST initialize → `Mcp-Session-Id` response header | every request carries the id; GET opens a server→client notification stream |
|
|
12
|
+
| 3 | **Legacy SSE** (2024-11-05) | Claude's connector UI auto-selects it when the URL ends in `/sse` | **GET** → `event: endpoint` frame announces the POST URL + sessionId | replies ride the long-lived GET stream; POSTs are 202 |
|
|
13
|
+
|
|
14
|
+
All three are served on **both** endpoint paths (`/mcp` and `/sse`, plus any
|
|
15
|
+
configured aliases) — the URL does not determine the dialect; the request
|
|
16
|
+
shape does.
|
|
17
|
+
|
|
18
|
+
## Dispatch rules (server/app.ts)
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
POST with Mcp-Session-Id:
|
|
22
|
+
known session → that session's StreamableHTTP transport
|
|
23
|
+
unknown session → 404 "Session not found" (client re-initializes)
|
|
24
|
+
POST initialize (no id) → new stateful session, id in response header
|
|
25
|
+
POST anything else (no id) → stateless throwaway transport (ChatGPT/Grok)
|
|
26
|
+
GET with Mcp-Session-Id → streamable notification stream for that session
|
|
27
|
+
GET without id → legacy SSE handshake (event: endpoint …)
|
|
28
|
+
POST /<token>/<path>/messages?sessionId=… → legacy SSE post-back leg
|
|
29
|
+
DELETE with id → session teardown (204)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`normalizeAccept()` widens every Accept header to
|
|
33
|
+
`application/json, text/event-stream` (GET → `text/event-stream`) before
|
|
34
|
+
the SDK sees it. The SDK enforces the spec strictly and answers 406 to
|
|
35
|
+
`*/*` or single-type Accepts; real clients send all of those.
|
|
36
|
+
|
|
37
|
+
Responses are **SSE-framed** (`enableJsonResponse: false`) — the spec's
|
|
38
|
+
reference behavior, and the framing every dialect we've seen accepts.
|
|
39
|
+
Plain-JSON mode was tried; Claude's connector silently dropped it.
|
|
40
|
+
|
|
41
|
+
## The incident log (each of these shipped and hurt)
|
|
42
|
+
|
|
43
|
+
1. **No session id** → Claude retried initialize every ~60s and reported
|
|
44
|
+
"Couldn't reach <server>". Fix: stateful sessions (v2.2.0),
|
|
45
|
+
`sessions.ts` store with 30-min idle TTL, 200 cap, per-token binding.
|
|
46
|
+
2. **406 on honest Accepts** → clients sending `*/*` or
|
|
47
|
+
`text/event-stream` alone got rejected by the SDK's strict check.
|
|
48
|
+
Fix: normalizeAccept (v2.2.1).
|
|
49
|
+
3. **`Unknown SSE event: endpoint`** → the legacy transport was bolted on
|
|
50
|
+
by hijacking **every** GET; a stateful client opening its notification
|
|
51
|
+
stream received the legacy `event: endpoint` frame and aborted its whole
|
|
52
|
+
TaskGroup. Fix: dispatch GETs by the Mcp-Session-Id header (v2.2.2/3).
|
|
53
|
+
4. **Plain-JSON responses dropped** → initialize answered 200 +
|
|
54
|
+
`application/json`, Claude read it and silently gave up. Fix: SSE
|
|
55
|
+
framing everywhere (v2.2.4). Diagnosed with a byte-logging wire proxy:
|
|
56
|
+
the client's exact headers/body made it obvious Claude got a *valid*
|
|
57
|
+
reply and still walked away — only the framing differed.
|
|
58
|
+
|
|
59
|
+
## Verification (do not trust, run)
|
|
60
|
+
|
|
61
|
+
The byte-exact Claude simulation lives in git history
|
|
62
|
+
(`python-httpx/0.28.1` + `clientInfo.name: "Anthropic"`); the pinned suite
|
|
63
|
+
covers the matrix:
|
|
64
|
+
|
|
65
|
+
- `tests/sessions.test.ts` — stateful handshake, routing, hijack-refusal,
|
|
66
|
+
DELETE teardown, stateless fallback
|
|
67
|
+
- `tests/get-dispatch.test.ts` — GET with id never emits `endpoint`; GET
|
|
68
|
+
without id always does
|
|
69
|
+
- `tests/legacy-sse.test.ts` — full 2024-11-05 handshake over `/sse` and
|
|
70
|
+
`/mcp`, sessionId-less POST-back → 400, unknown → 404, cross-token → 403
|
|
71
|
+
- `tests/accept-compat.test.ts` — six Accept variants all initialize
|
|
72
|
+
|
|
73
|
+
## Edge / proxy notes (production config)
|
|
74
|
+
|
|
75
|
+
- nginx: `proxy_buffering off`, `proxy_request_buffering off`,
|
|
76
|
+
`proxy_set_header Connection ""`, `chunked_transfer_encoding on`,
|
|
77
|
+
`gzip off`, `add_header X-Accel-Buffering no`, read/send timeouts 3600s —
|
|
78
|
+
a legacy SSE stream idles between tool calls; the default 60s proxy
|
|
79
|
+
timeouts kill it. The gateway sends `: keepalive` comments every 15s so
|
|
80
|
+
intermediate hops don't reap the stream.
|
|
81
|
+
- Cloudflare proxies all three dialects fine (verified from the edge).
|
|
82
|
+
- Quick tunnels on filtered networks can register but not carry traffic —
|
|
83
|
+
`ramcp tunnel` self-verifies and warns instead of handing out a dead URL
|
|
84
|
+
(that's environment, not protocol; http2-over-TCP is forced for the same
|
|
85
|
+
reason — QUIC is blocked on some ISPs).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-access-mcp",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Turn any Linux server into an AI-agent-accessible machine via MCP. ChatGPT, Claude, and Grok connect over HTTPS and control files, shell, git, and more — securely and with zero Python dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"README.md",
|
|
13
13
|
"README.fa.md",
|
|
14
14
|
"LICENSE",
|
|
15
|
-
"install.sh"
|
|
15
|
+
"install.sh",
|
|
16
|
+
"AGENTS.md",
|
|
17
|
+
"docs/ai"
|
|
16
18
|
],
|
|
17
19
|
"engines": {
|
|
18
20
|
"node": ">=20.18.1"
|