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 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.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"