jamgate 0.1.0 → 0.4.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/README.md CHANGED
@@ -9,10 +9,15 @@
9
9
  > agent reads from and writes to, kept honest at write time. Local-first, no cloud calls,
10
10
  > one dependency.
11
11
 
12
+ One command wires Jamgate into every MCP client on your machine:
13
+
12
14
  ```bash
13
- claude mcp add jamgate -- npx jamgate
15
+ npx jamgate setup
14
16
  ```
15
17
 
18
+ [![Add to Cursor](https://img.shields.io/badge/Add%20to-Cursor-000?logo=cursor&logoColor=white)](cursor://anysphere.cursor-deeplink/mcp/install?name=jamgate&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJqYW1nYXRlIl19)
19
+  •  one-click **Claude Desktop** bundle → the `.mcpb` on the [latest release](https://github.com/amirj4m/jamgate/releases/latest)
20
+
16
21
  ## The problem: memory quality, not storage
17
22
 
18
23
  You are one person, but every AI you use is a separate island. You design with one,
@@ -76,15 +81,42 @@ remembered.
76
81
  Jamgate runs **locally** — your memory never leaves your machine. Requires Node.js 20+.
77
82
  No install step: `npx` fetches and runs it on demand.
78
83
 
79
- ### Claude Code
84
+ ### Option A — `npx jamgate setup` (recommended)
85
+
86
+ One command detects the MCP clients installed on your machine (Claude Code, Claude Desktop,
87
+ Cursor, Windsurf) and wires Jamgate into each:
80
88
 
81
89
  ```bash
82
- claude mcp add jamgate -- npx jamgate
90
+ npx jamgate setup
91
+ ```
92
+
93
+ It is **safe to run**: idempotent (running it twice changes nothing), it never touches any
94
+ server entry but its own, and it backs up each config file to `<file>.jamgate-backup` before
95
+ writing. Useful flags:
96
+
97
+ ```bash
98
+ npx jamgate setup --dry-run # show what would change, write nothing
99
+ npx jamgate setup --remote https://you/mcp --token … # wire HTTP transport (see Remote mode)
100
+ npx jamgate status # show which clients are wired + where the store lives
83
101
  ```
84
102
 
85
- ### Claude Desktop
103
+ Restart your client(s) afterwards. On Claude Code, when the `claude` CLI is present, setup
104
+ uses `claude mcp add` under the hood; otherwise it merges `~/.claude.json` directly.
86
105
 
87
- Add to `claude_desktop_config.json` (Settings Developer → Edit Config):
106
+ ### Option B per-client manual
107
+
108
+ Prefer to wire it yourself? Each client is a small config change.
109
+
110
+ **Claude Code:**
111
+
112
+ ```bash
113
+ claude mcp add jamgate -- npx jamgate
114
+ ```
115
+
116
+ **Claude Desktop** — one-click: download the `.mcpb` bundle from the
117
+ [latest release](https://github.com/amirj4m/jamgate/releases/latest) and open it (Claude
118
+ Desktop → Settings → Extensions; the bundle is unsigned, so you may see an "unverified"
119
+ prompt). Or add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
88
120
 
89
121
  ```json
90
122
  {
@@ -97,9 +129,8 @@ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
97
129
  }
98
130
  ```
99
131
 
100
- ### Cursor
101
-
102
- Add to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):
132
+ **Cursor** — click the **Add to Cursor** badge at the top, or add to `~/.cursor/mcp.json`
133
+ (or `.cursor/mcp.json` in a project):
103
134
 
104
135
  ```json
105
136
  {
@@ -112,6 +143,8 @@ Add to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):
112
143
  }
113
144
  ```
114
145
 
146
+ **Windsurf** — add the same `mcpServers` block to `~/.codeium/windsurf/mcp_config.json`.
147
+
115
148
  Restart the agent. It now has three tools:
116
149
 
117
150
  - **`save_memory`** — store a durable fact. The gate rejects junk, drops exact
@@ -122,7 +155,8 @@ Restart the agent. It now has three tools:
122
155
  - **`forget_memory`** — delete a memory by id.
123
156
 
124
157
  Your memory lives in `~/.jamgate/memory.json`. Same machine, every agent → one shared
125
- memory.
158
+ memory. To share one memory across **different** machines and your phone, see
159
+ [Remote mode](#remote-mode-self-hosted).
126
160
 
127
161
  ## Optional: local semantic search
128
162
 
@@ -153,6 +187,211 @@ All configuration is via environment variables; every one has a sensible default
153
187
  | `JAMGATE_DUP_THRESHOLD` | `0.88` | Semantic near-duplicate sensitivity (0–1); higher = stricter. |
154
188
  | `JAMGATE_GATE_LOG` | on | `off` disables the local decision log. |
155
189
  | `JAMGATE_TTL_<TYPE>_DAYS` | per type | Override the freshness window for a memory type, e.g. `JAMGATE_TTL_PROJECT_DAYS=180`. |
190
+ | `JAMGATE_HTTP` | off | `1`/`true` enables [remote mode](#remote-mode-self-hosted) (same as the `--http` flag). |
191
+ | `JAMGATE_PORT` | `8420` | Port for remote mode (same as `--port`). |
192
+ | `JAMGATE_HOST` | `127.0.0.1` | Interface to bind in remote mode. Keep it on localhost behind a reverse proxy. |
193
+ | `JAMGATE_TOKEN` | — | Bearer token required in remote mode. The server refuses to start without it. |
194
+
195
+ ## Deploy your own (no terminal needed)
196
+
197
+ Want one shared memory across your **phone, browser, and laptop** but don't want to run a
198
+ server? Click a button, log into a hosting platform, and you get **your own** Jamgate instance
199
+ with its own URL and token — no terminal, no server knowledge. Same gate, same store as the
200
+ local install; only the transport is over the network (this is [Remote mode](#remote-mode-self-hosted),
201
+ set up for you).
202
+
203
+ **What you should know first (honest version):**
204
+
205
+ - **You pay the platform directly — we host nothing.** A tiny always-on instance with a small
206
+ persistent disk runs roughly **$5–7/month** on Railway or Render. That bill is between you and
207
+ the platform; Jamgate takes no cut and runs no cloud.
208
+ - **Your instance, your data.** The memory store lives on a disk *in your account* on *your*
209
+ platform. Jamgate never sees it, never proxies it, has no telemetry. A deploy button is
210
+ convenience, not hosting — see [DECISIONS D-031](./DECISIONS.md).
211
+ - **Whoever holds the token holds the memory.** The deploy generates a strong bearer token for
212
+ you. Treat it like a password. There are no per-user accounts (one instance = one person; see
213
+ [Honest limits](#honest-limits-read-this)).
214
+
215
+ ### Deploy to Render (works today)
216
+
217
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/amirj4m/jamgate)
218
+
219
+ The button reads [`render.yaml`](./render.yaml) straight from this repo: it builds the image from
220
+ the [`Dockerfile`](./Dockerfile), **generates a random `JAMGATE_TOKEN` for you**, and attaches a
221
+ 1 GB persistent disk at `/data` for your memory. Render provisions a paid `starter` instance
222
+ (a disk needs one). After it goes live, read your token under **Environment**, and your URL is
223
+ the service URL with `/mcp` appended (e.g. `https://jamgate-xxxx.onrender.com/mcp`).
224
+
225
+ ### Deploy on Railway
226
+
227
+ Railway deploys need a *published template* to auto-generate the token and attach the volume —
228
+ that's a one-time step in the maintainer's Railway workspace, so the button below is **not live
229
+ yet**. [`railway.json`](./railway.json) already pins the Dockerfile build and the `/healthz`
230
+ check, so a manual "deploy from repo" works now; the one-click button lands once the template is
231
+ published.
232
+
233
+ <!-- After publishing the template, paste its code and enable this button:
234
+ [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/TEMPLATE_CODE)
235
+ -->
236
+
237
+ *Maintainer steps to make the button live (one time):* create a project from this repo on
238
+ Railway → add a **Volume** mounted at `/data` → add a variable `JAMGATE_TOKEN` with a generated
239
+ value (`${{ secret(32) }}`) → **Settings → Generate Template from Project** → publish, then paste
240
+ the resulting `railway.com/new/template/<code>` URL into the button markdown above.
241
+
242
+ ### Get your URL and token, then connect your devices
243
+
244
+ Once the deploy is live you have two things: a **URL** ending in `/mcp` and a **token** (from the
245
+ platform's environment/variables tab). Connect each device to the same instance so they share one
246
+ memory:
247
+
248
+ - **Desktops (Claude Code, Cursor, Windsurf) — one command:**
249
+
250
+ ```bash
251
+ npx jamgate setup --remote https://your-instance/mcp --token <your-token>
252
+ ```
253
+
254
+ This wires every detected client on that machine to your instance (Streamable HTTP clients
255
+ only; others are skipped with a reason).
256
+
257
+ - **Phone (Claude app) and claude.ai in a browser:** Settings → **Connectors** → *Add custom
258
+ connector* → URL `https://your-instance/mcp`, and provide the bearer token when asked. The same
259
+ three tools (`save_memory`, `recall_memory`, `forget_memory`) then work from your phone.
260
+
261
+ Save on your phone, recall on your laptop — one memory, everywhere. For the full server-owner
262
+ path (your own VPS, systemd + Caddy), keep reading.
263
+
264
+ ## Remote mode (self-hosted)
265
+
266
+ By default Jamgate runs **locally over stdio** — one process per agent, on your machine, no
267
+ network. That's the right model for a single computer. But you are one person with agents in
268
+ several places at once: the Claude app on your **phone**, claude.ai in a **browser**, Claude
269
+ Code on a **laptop**. stdio can't be their shared brain — each would get its own local process
270
+ and its own memory.
271
+
272
+ **Remote mode** is the answer: run **one** Jamgate instance on a server you control, put it
273
+ behind HTTPS, and point every agent at the same URL. Now they share **one** memory of you — save
274
+ on your phone, recall on your laptop. It's the same gate and the same store, just reachable over
275
+ the network. It stays **opt-in**; stdio remains the default and the local-first promise is
276
+ unchanged. Whether it's your own memory or a whole team's, the rule is one instance per person
277
+ (see [Honest limits](#honest-limits-read-this)).
278
+
279
+ ### Run it
280
+
281
+ ```bash
282
+ # A strong token is REQUIRED — the server refuses to start without one.
283
+ export JAMGATE_TOKEN=$(openssl rand -hex 32)
284
+ jamgate --http # listens on 127.0.0.1:8420/mcp
285
+ # or: jamgate --http --port 9000 (or JAMGATE_HTTP=1 JAMGATE_PORT=9000)
286
+ ```
287
+
288
+ The MCP endpoint is `/mcp`. Every request must carry `Authorization: Bearer <token>`; anything
289
+ else gets a `401`.
290
+
291
+ ### Security model
292
+
293
+ - **Bearer token.** One shared secret in `JAMGATE_TOKEN` guards every request, compared in
294
+ constant time so it can't be recovered from response timing. Generate it with
295
+ `openssl rand -hex 32`, keep it out of shell history, and rotate it by restarting with a new
296
+ value.
297
+ - **TLS is terminated by a reverse proxy, not by Jamgate.** Jamgate speaks plain HTTP and binds
298
+ to `127.0.0.1` by default, so it is never directly exposed. Put **caddy** or **nginx** in
299
+ front to terminate HTTPS and forward to it locally. A bearer token over plain HTTP on the open
300
+ internet is a leaked token — **always** run it behind TLS.
301
+ - **Your server, your data.** The store is still a flat file on a disk you own. No Jamgate cloud,
302
+ no third party, no telemetry. "Self-hosted" means exactly that.
303
+
304
+ ### Deploy: systemd + Caddy
305
+
306
+ A `systemd` unit to keep Jamgate running (fill in your user and a real token — ideally load the
307
+ token from an `EnvironmentFile` with `600` permissions rather than inlining it):
308
+
309
+ ```ini
310
+ # /etc/systemd/system/jamgate.service
311
+ [Unit]
312
+ Description=Jamgate MCP memory (remote mode)
313
+ After=network.target
314
+
315
+ [Service]
316
+ # Load JAMGATE_TOKEN=... (and any JAMGATE_* overrides) from a root-only file:
317
+ EnvironmentFile=/etc/jamgate.env
318
+ Environment=JAMGATE_HTTP=1
319
+ Environment=JAMGATE_PORT=8420
320
+ Environment=JAMGATE_STORE=/var/lib/jamgate/memory.json
321
+ ExecStart=/usr/bin/npx jamgate
322
+ User=jamgate
323
+ Restart=on-failure
324
+
325
+ [Install]
326
+ WantedBy=multi-user.target
327
+ ```
328
+
329
+ ```bash
330
+ echo "JAMGATE_TOKEN=$(openssl rand -hex 32)" | sudo tee /etc/jamgate.env >/dev/null
331
+ sudo chmod 600 /etc/jamgate.env
332
+ sudo systemctl enable --now jamgate
333
+ ```
334
+
335
+ **Caddy** — automatic HTTPS, two lines of real config:
336
+
337
+ ```caddyfile
338
+ memory.example.com {
339
+ reverse_proxy 127.0.0.1:8420
340
+ }
341
+ ```
342
+
343
+ **nginx** — equivalent, with TLS certs managed by certbot:
344
+
345
+ ```nginx
346
+ server {
347
+ listen 443 ssl;
348
+ server_name memory.example.com;
349
+
350
+ ssl_certificate /etc/letsencrypt/live/memory.example.com/fullchain.pem;
351
+ ssl_certificate_key /etc/letsencrypt/live/memory.example.com/privkey.pem;
352
+
353
+ location /mcp {
354
+ proxy_pass http://127.0.0.1:8420/mcp;
355
+ proxy_http_version 1.1;
356
+ proxy_set_header Connection ""; # keep-alive for SSE streaming
357
+ proxy_buffering off; # don't buffer the event stream
358
+ proxy_read_timeout 3600s;
359
+ }
360
+ }
361
+ ```
362
+
363
+ ### Connect your agents
364
+
365
+ Point every agent at `https://your-domain/mcp` with the token.
366
+
367
+ **Claude app (iOS / Android / desktop) and claude.ai** — Settings → Connectors → *Add custom
368
+ connector* → URL `https://your-domain/mcp`, and provide the bearer token when asked. Once
369
+ connected, the same three tools (`save_memory`, `recall_memory`, `forget_memory`) are available
370
+ from your phone and browser.
371
+
372
+ **Claude Code** — add it as an HTTP MCP server:
373
+
374
+ ```bash
375
+ claude mcp add --transport http jamgate https://your-domain/mcp \
376
+ --header "Authorization: Bearer <token>"
377
+ ```
378
+
379
+ **Any MCP client** that speaks Streamable HTTP works the same way: URL `https://your-domain/mcp`,
380
+ header `Authorization: Bearer <token>`.
381
+
382
+ ### Honest limits (read this)
383
+
384
+ - **Whoever holds the token holds the memory.** There are no per-user accounts — the token *is*
385
+ the authentication. Treat it like a password: strong, secret, rotated on suspicion.
386
+ - **One instance = one human.** Jamgate's memory is *of one person*, by design. There is no
387
+ multi-user tenancy, no per-identity isolation or access control. That's a deliberate scope
388
+ choice, not a missing feature — it keeps the security surface to a single secret and a single
389
+ store. If several people each want a memory, run one instance per person.
390
+ - **Concurrency is single-process.** Multiple agents hitting one instance at once is safe (writes
391
+ are serialized by a lock and re-read before write). This holds for one Jamgate process on one
392
+ host; it is not a distributed multi-node store.
393
+ - **No TLS in the box.** If you skip the reverse proxy, you're sending a bearer token in the
394
+ clear. Don't.
156
395
 
157
396
  ## How it compares
158
397
 
@@ -191,9 +430,9 @@ be local, free, and one command to install.
191
430
 
192
431
  ## Status
193
432
 
194
- Early but real, and now installable with one command. The MVP core, robustness, and
195
- intelligence layers all work today (see [`CHANGELOG.md`](./CHANGELOG.md) for the full
196
- 0.1.0 scope):
433
+ Early but real, and now installable with one command. The MVP core, robustness,
434
+ intelligence, and optional remote layers all work today (see [`CHANGELOG.md`](./CHANGELOG.md)
435
+ for the full scope):
197
436
 
198
437
  - **Gate core** — rule pre-filter, exact dedup, time-aware supersession, source-trust
199
438
  conflict guard, over a local flat-file store.
@@ -201,10 +440,19 @@ intelligence layers all work today (see [`CHANGELOG.md`](./CHANGELOG.md) for the
201
440
  automatic schema migration.
202
441
  - **Intelligence** — trusted client provenance, fuzzy recall, optional local embeddings
203
442
  with graceful fallback, auto-subject derivation, local decision log.
204
-
205
- Verified end-to-end over the MCP protocol and covered by an automated test suite (89
206
- tests) on Node 20.x and 22.x. Next: a thin classifier for ambiguous cases (trained on the
207
- local decision log) and multi-device sync (see [`DECISIONS.md`](./DECISIONS.md)).
443
+ - **Remote mode** *(optional)* — self-hosted Streamable HTTP transport with bearer-token
444
+ auth, so one instance can serve all of your agents (phone, browser, laptop) from one
445
+ shared memory. stdio stays the default.
446
+ - **One-click install** `npx jamgate setup` wires every detected client (Claude Code,
447
+ Claude Desktop, Cursor, Windsurf) in one idempotent, backup-first command, plus a Cursor
448
+ deeplink and a Claude Desktop `.mcpb` bundle.
449
+ - **Deploy your own** *(no terminal)* — a `Dockerfile`, a Render blueprint, and a Railway
450
+ config so a non-technical user can click a button, log into a platform, and get their own
451
+ hosted instance with a generated token and a persistent disk. We host nothing.
452
+
453
+ Verified end-to-end over the MCP protocol (both stdio and HTTP) and covered by an automated
454
+ test suite (138 tests) on Node 20.x and 22.x. Next: a thin classifier for ambiguous cases
455
+ (trained on the local decision log) and multi-device sync (see [`DECISIONS.md`](./DECISIONS.md)).
208
456
  **Goal: impact, not profit — open-source (MIT), built in the open.**
209
457
 
210
458
  ## Development
package/dist/http.js ADDED
@@ -0,0 +1,270 @@
1
+ import { createServer as createHttpServer, } from "node:http";
2
+ import { randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
+ import { VERSION } from "./version.js";
6
+ import { createServer } from "./index.js";
7
+ import { resolveGateLogConfig } from "./gate/log.js";
8
+ /**
9
+ * Optional REMOTE mode for Jamgate (Phase 5, D-029).
10
+ *
11
+ * stdio stays the default and the local-first story is unchanged; this module adds an
12
+ * opt-in Streamable HTTP transport so ONE self-hosted Jamgate instance can serve all of a
13
+ * single human's MCP clients at once — the phone app, claude.ai, Claude Code on a laptop,
14
+ * a ChatGPT connector — sharing one memory. It is a generic feature for every Jamgate user,
15
+ * not bespoke to any deployment.
16
+ *
17
+ * Security model (see README "Remote mode"):
18
+ * - A bearer token (JAMGATE_TOKEN) gates every request; the comparison is constant-time.
19
+ * - TLS is terminated by a reverse proxy (caddy/nginx), never in-process — we bind to
20
+ * localhost by default so the proxy is the only public entry point.
21
+ * - One instance = one human. Whoever holds the token holds the memory. There is no
22
+ * multi-user tenancy by design (D-029).
23
+ */
24
+ const DEFAULT_PORT = 8420;
25
+ const DEFAULT_HOST = "127.0.0.1";
26
+ const DEFAULT_MCP_PATH = "/mcp";
27
+ const HEALTH_PATH = "/healthz";
28
+ /**
29
+ * Decide the run mode from CLI args and environment. HTTP mode is opt-in via the `--http`
30
+ * flag or a truthy `JAMGATE_HTTP` env var; stdio is the default when neither is set. The
31
+ * port comes from `--port <n>`, else `JAMGATE_PORT`, else the platform's `PORT` (PaaS hosts
32
+ * such as Railway and Render inject it), else {@link DEFAULT_PORT}. An invalid port falls
33
+ * back to the default rather than crashing the server on a typo.
34
+ */
35
+ export function parseCliOptions(argv, env = process.env) {
36
+ const http = argv.includes("--http") || isTruthyEnv(env.JAMGATE_HTTP);
37
+ let port;
38
+ const flagIndex = argv.indexOf("--port");
39
+ if (flagIndex !== -1 && flagIndex + 1 < argv.length) {
40
+ port = Number(argv[flagIndex + 1]);
41
+ }
42
+ else if (env.JAMGATE_PORT) {
43
+ port = Number(env.JAMGATE_PORT);
44
+ }
45
+ else if (env.PORT) {
46
+ // Deploy platforms (Railway, Render, Heroku, …) assign the listen port via $PORT and
47
+ // expect the app to honor it. JAMGATE_PORT still wins so an explicit override is possible.
48
+ port = Number(env.PORT);
49
+ }
50
+ if (port === undefined || !Number.isInteger(port) || port < 0 || port > 65535) {
51
+ port = DEFAULT_PORT;
52
+ }
53
+ return { http, port };
54
+ }
55
+ function isTruthyEnv(value) {
56
+ if (!value)
57
+ return false;
58
+ const v = value.trim().toLowerCase();
59
+ return v === "1" || v === "true" || v === "yes" || v === "on";
60
+ }
61
+ /**
62
+ * Constant-time check of an `Authorization: Bearer <token>` header against the expected
63
+ * token. Returns false for a missing/malformed header or any mismatch. The comparison uses
64
+ * `timingSafeEqual` so an attacker can't learn the token byte-by-byte from response timing;
65
+ * when the lengths differ we still run one comparison (against the presented value itself)
66
+ * to avoid leaking the token length through an early return.
67
+ */
68
+ export function bearerTokenMatches(authorizationHeader, expectedToken) {
69
+ if (!authorizationHeader)
70
+ return false;
71
+ const prefix = "Bearer ";
72
+ if (!authorizationHeader.startsWith(prefix))
73
+ return false;
74
+ const presented = Buffer.from(authorizationHeader.slice(prefix.length).trim(), "utf8");
75
+ const expected = Buffer.from(expectedToken, "utf8");
76
+ if (presented.length !== expected.length) {
77
+ // Length already differs, so it can't match — but compare presented against itself so
78
+ // the work (and thus the timing) doesn't depend on the expected token's length.
79
+ timingSafeEqual(presented, presented);
80
+ return false;
81
+ }
82
+ return timingSafeEqual(presented, expected);
83
+ }
84
+ /**
85
+ * Start the HTTP transport and resolve once it is listening. Each MCP session gets its own
86
+ * `StreamableHTTPServerTransport` and its own `createServer(store)` instance (so the
87
+ * per-connection handshake provenance in D-024 still works over HTTP), all sharing the one
88
+ * injected store. Sessions are tracked by the `mcp-session-id` header per the SDK's stateful
89
+ * Streamable HTTP pattern, and torn down when the client disconnects or DELETEs the session.
90
+ */
91
+ export function startHttpServer(opts) {
92
+ const host = opts.host ?? process.env.JAMGATE_HOST ?? DEFAULT_HOST;
93
+ const path = opts.path ?? DEFAULT_MCP_PATH;
94
+ const port = opts.port ?? DEFAULT_PORT;
95
+ const gateLog = opts.gateLog ?? resolveGateLogConfig();
96
+ // Live sessions, keyed by the session id the transport assigns at initialize time.
97
+ const transports = new Map();
98
+ const httpServer = createHttpServer((req, res) => {
99
+ handleRequest(req, res, { store: opts.store, token: opts.token, path, transports, gateLog }).catch((err) => {
100
+ console.error("jamgate http: unhandled request error:", err);
101
+ if (!res.headersSent) {
102
+ sendJson(res, 500, {
103
+ jsonrpc: "2.0",
104
+ error: { code: -32603, message: "Internal server error" },
105
+ id: null,
106
+ });
107
+ }
108
+ else {
109
+ res.end();
110
+ }
111
+ });
112
+ });
113
+ return new Promise((resolve, reject) => {
114
+ httpServer.once("error", reject);
115
+ httpServer.listen(port, host, () => {
116
+ httpServer.removeListener("error", reject);
117
+ const addr = httpServer.address();
118
+ const boundPort = typeof addr === "object" && addr ? addr.port : port;
119
+ resolve({
120
+ server: httpServer,
121
+ port: boundPort,
122
+ host,
123
+ path,
124
+ close: () => new Promise((res2, rej2) => {
125
+ for (const t of transports.values())
126
+ void t.close();
127
+ transports.clear();
128
+ httpServer.close((err) => (err ? rej2(err) : res2()));
129
+ }),
130
+ });
131
+ });
132
+ });
133
+ }
134
+ async function handleRequest(req, res, ctx) {
135
+ // 0. Health check — deliberately BEFORE the auth gate and outside the MCP endpoint. Deploy
136
+ // platforms (Railway, Render, …) probe an unauthenticated URL to decide if the container
137
+ // is live, so this must answer 200 without a token. It exposes only liveness + version —
138
+ // never any memory, session, or config data (RULES §10: nothing leaks).
139
+ const reqPath = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).pathname;
140
+ if (reqPath === HEALTH_PATH) {
141
+ if (req.method === "GET" || req.method === "HEAD") {
142
+ sendJson(res, 200, { status: "ok", version: VERSION });
143
+ }
144
+ else {
145
+ res.setHeader("Allow", "GET, HEAD");
146
+ sendJson(res, 405, {
147
+ jsonrpc: "2.0",
148
+ error: { code: -32000, message: `Method ${req.method} not allowed` },
149
+ id: null,
150
+ });
151
+ }
152
+ return;
153
+ }
154
+ // 1. Auth gate — before anything else, on every method. A missing/wrong token is a flat
155
+ // 401; we never fall through to session handling for an unauthenticated request.
156
+ if (!bearerTokenMatches(req.headers.authorization, ctx.token)) {
157
+ res.setHeader("WWW-Authenticate", 'Bearer realm="jamgate"');
158
+ sendJson(res, 401, {
159
+ jsonrpc: "2.0",
160
+ error: { code: -32001, message: "Unauthorized: valid bearer token required" },
161
+ id: null,
162
+ });
163
+ return;
164
+ }
165
+ // 2. Past the auth gate, only the MCP endpoint exists (the unauthenticated `/healthz` was
166
+ // already handled in step 0). Everything else is 404.
167
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
168
+ if (url.pathname !== ctx.path) {
169
+ sendJson(res, 404, {
170
+ jsonrpc: "2.0",
171
+ error: { code: -32601, message: `Not found. MCP endpoint is ${ctx.path}` },
172
+ id: null,
173
+ });
174
+ return;
175
+ }
176
+ const sessionId = header(req, "mcp-session-id");
177
+ if (req.method === "POST") {
178
+ const body = await readJsonBody(req);
179
+ if (body === PARSE_ERROR) {
180
+ sendJson(res, 400, {
181
+ jsonrpc: "2.0",
182
+ error: { code: -32700, message: "Parse error: request body is not valid JSON" },
183
+ id: null,
184
+ });
185
+ return;
186
+ }
187
+ let transport = sessionId ? ctx.transports.get(sessionId) : undefined;
188
+ if (!transport) {
189
+ if (isInitializeRequest(body)) {
190
+ // New session: spin up a fresh transport + its own server, sharing the store.
191
+ transport = new StreamableHTTPServerTransport({
192
+ sessionIdGenerator: () => randomUUID(),
193
+ onsessioninitialized: (sid) => {
194
+ ctx.transports.set(sid, transport);
195
+ },
196
+ });
197
+ transport.onclose = () => {
198
+ const sid = transport.sessionId;
199
+ if (sid)
200
+ ctx.transports.delete(sid);
201
+ };
202
+ const server = createServer(ctx.store, ctx.gateLog);
203
+ await server.connect(transport);
204
+ }
205
+ else {
206
+ // Not an initialize and no known session → the client must initialize first.
207
+ sendJson(res, 400, {
208
+ jsonrpc: "2.0",
209
+ error: {
210
+ code: -32000,
211
+ message: "Bad Request: no valid session id; send an initialize request first",
212
+ },
213
+ id: null,
214
+ });
215
+ return;
216
+ }
217
+ }
218
+ await transport.handleRequest(req, res, body);
219
+ return;
220
+ }
221
+ if (req.method === "GET" || req.method === "DELETE") {
222
+ // GET opens the server→client SSE stream; DELETE ends the session. Both need a session.
223
+ const transport = sessionId ? ctx.transports.get(sessionId) : undefined;
224
+ if (!transport) {
225
+ sendJson(res, 400, {
226
+ jsonrpc: "2.0",
227
+ error: { code: -32000, message: "Bad Request: unknown or missing session id" },
228
+ id: null,
229
+ });
230
+ return;
231
+ }
232
+ await transport.handleRequest(req, res);
233
+ return;
234
+ }
235
+ res.setHeader("Allow", "GET, POST, DELETE");
236
+ sendJson(res, 405, {
237
+ jsonrpc: "2.0",
238
+ error: { code: -32000, message: `Method ${req.method} not allowed` },
239
+ id: null,
240
+ });
241
+ }
242
+ function header(req, name) {
243
+ const v = req.headers[name];
244
+ return Array.isArray(v) ? v[0] : v;
245
+ }
246
+ const PARSE_ERROR = Symbol("parse-error");
247
+ /** Read and JSON-parse the request body. Returns the parsed value, `undefined` for an empty
248
+ * body, or the {@link PARSE_ERROR} sentinel when the body isn't valid JSON. */
249
+ async function readJsonBody(req) {
250
+ const raw = await new Promise((resolve, reject) => {
251
+ let data = "";
252
+ req.setEncoding("utf8");
253
+ req.on("data", (chunk) => (data += chunk));
254
+ req.on("end", () => resolve(data));
255
+ req.on("error", reject);
256
+ });
257
+ if (raw.trim() === "")
258
+ return undefined;
259
+ try {
260
+ return JSON.parse(raw);
261
+ }
262
+ catch {
263
+ return PARSE_ERROR;
264
+ }
265
+ }
266
+ function sendJson(res, status, body) {
267
+ const payload = JSON.stringify(body);
268
+ res.writeHead(status, { "Content-Type": "application/json" });
269
+ res.end(payload);
270
+ }
package/dist/index.js CHANGED
@@ -2,9 +2,13 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { realpathSync } from "node:fs";
5
6
  import { pathToFileURL } from "node:url";
7
+ import { VERSION } from "./version.js";
6
8
  import { FileStore } from "./store/fileStore.js";
7
9
  import { loadTransformersEmbedder, resolveDupThreshold } from "./embeddings/embedder.js";
10
+ import { parseCliOptions, startHttpServer } from "./http.js";
11
+ import { setupCommand, statusCommand } from "./setup/cli.js";
8
12
  import { prefilter } from "./gate/prefilter.js";
9
13
  import { deriveSubject } from "./gate/subject.js";
10
14
  import { appendGateLog, resolveGateLogConfig, } from "./gate/log.js";
@@ -14,7 +18,7 @@ import { appendGateLog, resolveGateLogConfig, } from "./gate/log.js";
14
18
  * client provenance is captured from the handshake, D-024) without spawning a process.
15
19
  */
16
20
  export function createServer(store, gateLog = resolveGateLogConfig()) {
17
- const server = new Server({ name: "jamgate", version: "0.1.0" }, { capabilities: { tools: {} } });
21
+ const server = new Server({ name: "jamgate", version: VERSION }, { capabilities: { tools: {} } });
18
22
  /** The MCP client behind the current connection, taken from the `clientInfo` the client
19
23
  * sent in the `initialize` handshake (D-024). This is server-observed provenance — the
20
24
  * calling agent cannot spoof it through tool arguments. Undefined until the handshake
@@ -173,8 +177,9 @@ export function createServer(store, gateLog = resolveGateLogConfig()) {
173
177
  });
174
178
  return server;
175
179
  }
176
- async function main() {
177
- // Optional local embeddings (D-026): try to load the model; null → fuzzy recall only.
180
+ /** Build the default FileStore, wiring in optional local embeddings (D-026). Shared by the
181
+ * stdio and HTTP bootstraps so both run identical gate/store behaviour. */
182
+ async function buildStore() {
178
183
  // Loading is lazy and best-effort so the base install and offline/CI runs work unchanged.
179
184
  const embedder = await loadTransformersEmbedder();
180
185
  if (embedder)
@@ -182,18 +187,68 @@ async function main() {
182
187
  else
183
188
  console.error("jamgate: running on fuzzy recall (no embedding model loaded)");
184
189
  // Depend on the adapter contract, not a concrete backend (D-019).
185
- const store = new FileStore(undefined, {
190
+ return new FileStore(undefined, {
186
191
  embedder: embedder ?? undefined,
187
192
  dupThreshold: resolveDupThreshold(),
188
193
  });
194
+ }
195
+ async function main() {
196
+ const argv = process.argv.slice(2);
197
+ // Install-helper subcommands run before any store/server bootstrap — they only touch client
198
+ // config files, never the memory store or a transport (the One-Click Install phase, D-030).
199
+ if (argv[0] === "setup") {
200
+ process.exitCode = await setupCommand(argv.slice(1));
201
+ return;
202
+ }
203
+ if (argv[0] === "status") {
204
+ process.exitCode = await statusCommand();
205
+ return;
206
+ }
207
+ const opts = parseCliOptions(argv);
208
+ const store = await buildStore();
209
+ if (opts.http) {
210
+ // Opt-in remote mode (D-029). stdio stays the default; this only runs on --http /
211
+ // JAMGATE_HTTP. A bearer token is mandatory — refuse to start without one, rather than
212
+ // silently exposing a memory over the network.
213
+ const token = process.env.JAMGATE_TOKEN;
214
+ if (!token || token.trim() === "") {
215
+ console.error("jamgate: HTTP mode requires a bearer token, but JAMGATE_TOKEN is not set.\n" +
216
+ " Set a strong secret and restart, e.g.:\n" +
217
+ " JAMGATE_TOKEN=$(openssl rand -hex 32) jamgate --http\n" +
218
+ " TLS must be terminated by a reverse proxy (caddy/nginx); see the README " +
219
+ '"Remote mode" section. Refusing to start.');
220
+ process.exit(1);
221
+ }
222
+ const running = await startHttpServer({ store, token, port: opts.port });
223
+ console.error(`jamgate MCP server running on http://${running.host}:${running.port}${running.path} ` +
224
+ "(bearer auth required; terminate TLS at a reverse proxy)");
225
+ return;
226
+ }
189
227
  const server = createServer(store);
190
228
  const transport = new StdioServerTransport();
191
229
  await server.connect(transport);
192
230
  // stdout is the MCP channel; logs must go to stderr.
193
231
  console.error("jamgate MCP server running on stdio");
194
232
  }
195
- // Only bootstrap stdio when run as the CLI entrypoint, not when imported by a test.
196
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
233
+ /** True when this module is the process entrypoint (run as the CLI), not imported by a test.
234
+ * Must compare against the *realpath* of `process.argv[1]`: npm/npx install the bin as a
235
+ * symlink (`node_modules/.bin/jamgate` → the real `dist/index.js`), so `process.argv[1]` is
236
+ * the symlink path while `import.meta.url` is the resolved target. A naive equality check
237
+ * fails there and `main()` never runs — the wizard exits 0 with no output (the 0.4.0 bug).
238
+ * Resolving the symlink first makes both sides the same real file. */
239
+ function isMainEntrypoint() {
240
+ const entry = process.argv[1];
241
+ if (!entry)
242
+ return false;
243
+ try {
244
+ return import.meta.url === pathToFileURL(realpathSync(entry)).href;
245
+ }
246
+ catch {
247
+ // realpathSync throws if the path doesn't exist; fall back to the raw comparison.
248
+ return import.meta.url === pathToFileURL(entry).href;
249
+ }
250
+ }
251
+ if (isMainEntrypoint()) {
197
252
  main().catch((err) => {
198
253
  console.error("jamgate fatal:", err);
199
254
  process.exit(1);
@@ -0,0 +1,199 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { runSetup, runStatus, } from "./runner.js";
4
+ /**
5
+ * Terminal front-end for `jamgate setup` and `jamgate status`: parse argv, run the (pure-ish)
6
+ * runner, and print a clear per-client report. Kept separate from `runner.ts` so the wiring
7
+ * logic can be unit-tested without going through argv parsing or console output.
8
+ */
9
+ const execFileAsync = promisify(execFile);
10
+ const VALID_CLIENTS = ["claude-code", "claude-desktop", "cursor", "windsurf"];
11
+ /** Parse `setup` flags: `--dry-run`, `--remote <url>`, `--token <token>`, `--client <id>...`. */
12
+ export function parseSetupArgs(argv, env) {
13
+ let dryRun = false;
14
+ let url;
15
+ let token = env.JAMGATE_TOKEN;
16
+ const only = [];
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const arg = argv[i];
19
+ if (arg === "--dry-run") {
20
+ dryRun = true;
21
+ }
22
+ else if (arg === "--remote") {
23
+ url = argv[++i];
24
+ if (!url)
25
+ return { mode: "remote", dryRun, error: "--remote requires a URL argument" };
26
+ }
27
+ else if (arg === "--token") {
28
+ token = argv[++i];
29
+ if (!token)
30
+ return { mode: "stdio", dryRun, error: "--token requires a value" };
31
+ }
32
+ else if (arg === "--client") {
33
+ const id = argv[++i];
34
+ if (!VALID_CLIENTS.includes(id)) {
35
+ return {
36
+ mode: "stdio",
37
+ dryRun,
38
+ error: `unknown --client "${id}" (expected one of: ${VALID_CLIENTS.join(", ")})`,
39
+ };
40
+ }
41
+ only.push(id);
42
+ }
43
+ else {
44
+ return { mode: "stdio", dryRun, error: `unknown argument "${arg}"` };
45
+ }
46
+ }
47
+ const mode = url ? "remote" : "stdio";
48
+ if (mode === "remote" && !token) {
49
+ return {
50
+ mode,
51
+ url,
52
+ dryRun,
53
+ error: "remote mode requires a bearer token — pass --token <token> or set JAMGATE_TOKEN " +
54
+ "(it must match the token the remote jamgate server runs with)",
55
+ };
56
+ }
57
+ return { mode, url, token, dryRun, only: only.length ? only : undefined };
58
+ }
59
+ /** The real `claude` CLI adapter. `isAvailable` probes `claude --version`; `add` shells out to
60
+ * `claude mcp add --scope user`. Both swallow failures into a boolean so the runner can fall
61
+ * back to a direct JSON merge rather than leaving Claude Code unwired. */
62
+ export const realClaudeCli = {
63
+ async isAvailable() {
64
+ try {
65
+ await execFileAsync("claude", ["--version"], { timeout: 10_000 });
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ },
72
+ async add(spec) {
73
+ try {
74
+ await execFileAsync("claude", claudeAddArgs(spec), { timeout: 30_000 });
75
+ return { ok: true };
76
+ }
77
+ catch (err) {
78
+ return { ok: false, stderr: err instanceof Error ? err.message : String(err) };
79
+ }
80
+ },
81
+ previewCommand(spec) {
82
+ return `claude ${claudeAddArgs(spec).join(" ")}`;
83
+ },
84
+ };
85
+ /** Build the argv for `claude mcp add` for a given transport (shared by add + previewCommand). */
86
+ function claudeAddArgs(spec) {
87
+ if (spec.mode === "remote") {
88
+ const args = ["mcp", "add", "--scope", "user", "--transport", "http", "jamgate", spec.url ?? ""];
89
+ if (spec.token)
90
+ args.push("--header", `Authorization: Bearer ${spec.token}`);
91
+ return args;
92
+ }
93
+ return ["mcp", "add", "--scope", "user", "jamgate", "--", "npx", "jamgate"];
94
+ }
95
+ const SYMBOL = {
96
+ configured: "✓",
97
+ updated: "✓",
98
+ "already-configured": "•",
99
+ "not-found": "–",
100
+ skipped: "–",
101
+ error: "✗",
102
+ };
103
+ function describe(r) {
104
+ const verb = r.dryRun ? "would be " : "";
105
+ switch (r.outcome) {
106
+ case "configured":
107
+ return `${r.dryRun ? "would configure" : "configured"} (${r.transport})`;
108
+ case "updated":
109
+ return `${r.dryRun ? "would update" : "updated"} (${r.transport})`;
110
+ case "already-configured":
111
+ return "already configured";
112
+ case "not-found":
113
+ return "not found";
114
+ case "skipped":
115
+ return `${verb}skipped`;
116
+ case "error":
117
+ return "error";
118
+ }
119
+ }
120
+ /** Format the setup report as lines of terminal text. */
121
+ export function formatSetupReport(results, opts) {
122
+ const lines = [];
123
+ lines.push(opts.dryRun ? "jamgate setup — dry run (no files written)\n" : "jamgate setup\n");
124
+ for (const r of results) {
125
+ let line = ` ${SYMBOL[r.outcome]} ${r.label.padEnd(15)} ${describe(r)}`;
126
+ if (r.detail && (r.outcome === "not-found" || r.outcome === "skipped" || r.outcome === "error")) {
127
+ line += ` — ${r.detail}`;
128
+ }
129
+ lines.push(line);
130
+ if (r.path && (r.outcome === "configured" || r.outcome === "updated")) {
131
+ const via = r.method === "claude-cli" ? "claude mcp add" : r.path;
132
+ lines.push(` ${r.dryRun ? "target" : "via"}: ${via}${r.backedUp ? " (backup written)" : ""}`);
133
+ }
134
+ }
135
+ const configured = results.filter((r) => r.outcome === "configured" || r.outcome === "updated");
136
+ const already = results.filter((r) => r.outcome === "already-configured");
137
+ lines.push("");
138
+ if (opts.dryRun) {
139
+ lines.push(` ${configured.length} client(s) would change, ${already.length} already configured. ` +
140
+ "Re-run without --dry-run to apply.");
141
+ }
142
+ else {
143
+ lines.push(` Done: ${configured.length} configured, ${already.length} already configured. ` +
144
+ "Restart your client(s) to pick up jamgate.");
145
+ }
146
+ return lines.join("\n");
147
+ }
148
+ /** Format the status report as lines of terminal text. */
149
+ export function formatStatusReport(report) {
150
+ const lines = ["jamgate status\n"];
151
+ for (const c of report.clients) {
152
+ const mark = c.wired ? "✓" : c.detected ? "•" : "–";
153
+ const state = c.wired
154
+ ? `wired (${c.transport})`
155
+ : c.detected
156
+ ? "detected, not wired"
157
+ : "not found";
158
+ lines.push(` ${mark} ${c.label.padEnd(15)} ${state}`);
159
+ if (c.wired && c.path)
160
+ lines.push(` ${c.path}`);
161
+ }
162
+ lines.push("");
163
+ lines.push(` store: ${report.storePath}`);
164
+ return lines.join("\n");
165
+ }
166
+ /** Entry point for `jamgate setup`. Returns a process exit code. */
167
+ export async function setupCommand(argv, deps = {}) {
168
+ const env = deps.env ?? process.env;
169
+ const log = deps.log ?? ((m) => console.log(m));
170
+ const parsed = parseSetupArgs(argv, env);
171
+ if (parsed.error) {
172
+ log(`jamgate setup: ${parsed.error}`);
173
+ return 1;
174
+ }
175
+ const options = {
176
+ mode: parsed.mode,
177
+ url: parsed.url,
178
+ token: parsed.token,
179
+ dryRun: parsed.dryRun,
180
+ only: parsed.only,
181
+ platform: deps.platform,
182
+ env,
183
+ claudeCli: deps.claudeCli ?? realClaudeCli,
184
+ };
185
+ const results = await runSetup(options);
186
+ log(formatSetupReport(results, { dryRun: parsed.dryRun }));
187
+ return 0;
188
+ }
189
+ /** Entry point for `jamgate status`. Returns a process exit code. */
190
+ export async function statusCommand(deps = {}) {
191
+ const log = deps.log ?? ((m) => console.log(m));
192
+ const report = await runStatus({
193
+ env: deps.env,
194
+ platform: deps.platform,
195
+ claudeCli: deps.claudeCli ?? realClaudeCli,
196
+ });
197
+ log(formatStatusReport(report));
198
+ return 0;
199
+ }
@@ -0,0 +1,137 @@
1
+ import { join } from "node:path";
2
+ /** Resolve the user's home directory from the environment (test-injectable). */
3
+ function home(env) {
4
+ return env.HOME || env.USERPROFILE || null;
5
+ }
6
+ /** Resolve the base config directory used by Claude Desktop, per platform. */
7
+ function claudeDesktopDir(platform, env) {
8
+ const h = home(env);
9
+ if (platform === "darwin") {
10
+ return h ? join(h, "Library", "Application Support", "Claude") : null;
11
+ }
12
+ if (platform === "win32") {
13
+ return env.APPDATA ? join(env.APPDATA, "Claude") : null;
14
+ }
15
+ // Linux and everything else follow the XDG base-dir spec.
16
+ const base = env.XDG_CONFIG_HOME || (h ? join(h, ".config") : null);
17
+ return base ? join(base, "Claude") : null;
18
+ }
19
+ export const CLIENTS = [
20
+ {
21
+ id: "claude-code",
22
+ label: "Claude Code",
23
+ serverKey: "jamgate",
24
+ supportsRemote: true,
25
+ configPath(_platform, env) {
26
+ const h = home(env);
27
+ return h ? join(h, ".claude.json") : null;
28
+ },
29
+ detectPaths(_platform, env) {
30
+ const h = home(env);
31
+ // The config file itself is the strongest signal; the CLI is detected separately in the
32
+ // runner (it may be on PATH before the file exists on a very fresh install).
33
+ return h ? [join(h, ".claude.json")] : [];
34
+ },
35
+ },
36
+ {
37
+ id: "claude-desktop",
38
+ label: "Claude Desktop",
39
+ serverKey: "jamgate",
40
+ // Claude Desktop installs local (stdio) servers natively; remote servers go through the
41
+ // connectors UI / mcp-remote, not a plain HTTP entry, so we don't claim remote support.
42
+ supportsRemote: false,
43
+ configPath(platform, env) {
44
+ const dir = claudeDesktopDir(platform, env);
45
+ return dir ? join(dir, "claude_desktop_config.json") : null;
46
+ },
47
+ detectPaths(platform, env) {
48
+ const dir = claudeDesktopDir(platform, env);
49
+ return dir ? [dir] : [];
50
+ },
51
+ },
52
+ {
53
+ id: "cursor",
54
+ label: "Cursor",
55
+ serverKey: "jamgate",
56
+ supportsRemote: true,
57
+ configPath(_platform, env) {
58
+ const h = home(env);
59
+ return h ? join(h, ".cursor", "mcp.json") : null;
60
+ },
61
+ detectPaths(_platform, env) {
62
+ const h = home(env);
63
+ return h ? [join(h, ".cursor")] : [];
64
+ },
65
+ },
66
+ {
67
+ id: "windsurf",
68
+ label: "Windsurf",
69
+ serverKey: "jamgate",
70
+ // Windsurf's remote support is SSE-based (`serverUrl`) rather than Streamable HTTP; rather
71
+ // than write an entry we haven't verified round-trips, we wire stdio only.
72
+ supportsRemote: false,
73
+ configPath(_platform, env) {
74
+ const h = home(env);
75
+ return h ? join(h, ".codeium", "windsurf", "mcp_config.json") : null;
76
+ },
77
+ detectPaths(_platform, env) {
78
+ const h = home(env);
79
+ return h ? [join(h, ".codeium", "windsurf")] : [];
80
+ },
81
+ },
82
+ ];
83
+ export function clientById(id) {
84
+ const def = CLIENTS.find((c) => c.id === id);
85
+ if (!def)
86
+ throw new Error(`Unknown client id: ${id}`);
87
+ return def;
88
+ }
89
+ /**
90
+ * Build the server entry to write for a given client and mode. The stdio entry runs the
91
+ * published package with `npx jamgate` so it always tracks the installed version; the remote
92
+ * entry points at the user's self-hosted HTTP endpoint with a bearer header. Claude Code gets
93
+ * an explicit `type` to match how its own `claude mcp add` records entries.
94
+ */
95
+ export function buildEntry(client, params) {
96
+ if (params.mode === "remote") {
97
+ if (!params.url)
98
+ throw new Error("remote mode requires a url");
99
+ const headers = {};
100
+ if (params.token)
101
+ headers.Authorization = `Bearer ${params.token}`;
102
+ const entry = { url: params.url };
103
+ if (Object.keys(headers).length > 0)
104
+ entry.headers = headers;
105
+ if (client.id === "claude-code")
106
+ entry.type = "http";
107
+ return entry;
108
+ }
109
+ const entry = { command: "npx", args: ["jamgate"] };
110
+ // Claude Code records stdio servers as {type, command, args, env}. Match that shape exactly
111
+ // so an entry written by `claude mcp add` is recognised as already-configured on re-run
112
+ // (otherwise the empty `env` would read as a spurious diff and re-"update" every run).
113
+ if (client.id === "claude-code") {
114
+ entry.type = "stdio";
115
+ entry.env = {};
116
+ }
117
+ return entry;
118
+ }
119
+ /**
120
+ * The bare per-server config object Cursor's one-click deeplink base64-encodes. Cursor's
121
+ * deeplink takes the server config WITHOUT the `mcpServers` wrapper (verified against Cursor's
122
+ * install-links docs), so this mirrors {@link buildEntry} for Cursor but is exposed separately
123
+ * for the README badge generator and its test.
124
+ */
125
+ export function cursorDeeplinkConfig(params = { mode: "stdio" }) {
126
+ return buildEntry(clientById("cursor"), params);
127
+ }
128
+ /**
129
+ * Generate Cursor's official one-click install deeplink for Jamgate. Shape verified against
130
+ * Cursor docs: `cursor://anysphere.cursor-deeplink/mcp/install?name=<name>&config=<base64>`
131
+ * where `<base64>` is the base64 of the bare server config JSON.
132
+ */
133
+ export function cursorDeeplink(params = { mode: "stdio" }) {
134
+ const config = cursorDeeplinkConfig(params);
135
+ const base64 = Buffer.from(JSON.stringify(config), "utf8").toString("base64");
136
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=jamgate&config=${base64}`;
137
+ }
@@ -0,0 +1,52 @@
1
+ function isPlainObject(value) {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+ /** Structural deep-equality, sufficient for the JSON server entries we compare. */
5
+ export function deepEqual(a, b) {
6
+ if (a === b)
7
+ return true;
8
+ if (typeof a !== typeof b)
9
+ return false;
10
+ if (Array.isArray(a) || Array.isArray(b)) {
11
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
12
+ return false;
13
+ return a.every((v, i) => deepEqual(v, b[i]));
14
+ }
15
+ if (isPlainObject(a) && isPlainObject(b)) {
16
+ const ak = Object.keys(a);
17
+ const bk = Object.keys(b);
18
+ if (ak.length !== bk.length)
19
+ return false;
20
+ return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
21
+ }
22
+ return false;
23
+ }
24
+ /**
25
+ * Compute the merge of a jamgate server entry into an existing (parsed) config document.
26
+ *
27
+ * @param existing The parsed config, or undefined if the file did not exist. Anything that
28
+ * is not a plain object is treated as absent (we start fresh, preserving
29
+ * nothing we can't understand — the caller backs up first).
30
+ * @param key The server key to manage (always "jamgate").
31
+ * @param entry The desired server entry.
32
+ */
33
+ export function planMerge(existing, key, entry) {
34
+ const hadValidRoot = isPlainObject(existing);
35
+ const root = hadValidRoot ? { ...existing } : {};
36
+ const hadServers = isPlainObject(root.mcpServers);
37
+ const servers = hadServers
38
+ ? { ...root.mcpServers }
39
+ : {};
40
+ const current = servers[key];
41
+ if (current !== undefined && deepEqual(current, entry)) {
42
+ return { config: root, status: "already-configured", changed: false };
43
+ }
44
+ const status = current !== undefined
45
+ ? "updated"
46
+ : !hadValidRoot || !hadServers
47
+ ? "created"
48
+ : "configured";
49
+ servers[key] = entry;
50
+ root.mcpServers = servers;
51
+ return { config: root, status, changed: true };
52
+ }
@@ -0,0 +1,194 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { CLIENTS, buildEntry, } from "./clients.js";
5
+ import { planMerge } from "./merge.js";
6
+ /**
7
+ * The IO layer of `jamgate setup` / `jamgate status`. Given a platform + environment (both
8
+ * injectable so tests run entirely against a temp home), it detects installed MCP clients,
9
+ * wires Jamgate into each safely, and reports per-client outcomes.
10
+ *
11
+ * Safety guarantees (see DECISIONS D-030):
12
+ * - Idempotent: a second run changes nothing.
13
+ * - Never clobbers other servers or other fields — only the `jamgate` key is managed.
14
+ * - Backs up any config file to `<file>.jamgate-backup` before overwriting it.
15
+ * - `--dry-run` computes and reports every change without writing anything.
16
+ */
17
+ /** The suffix appended to a config file to hold its pre-write backup. */
18
+ export const BACKUP_SUFFIX = ".jamgate-backup";
19
+ async function pathExists(p) {
20
+ try {
21
+ await fs.access(p);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** Read + JSON-parse a config file. Returns undefined if the file is absent, and the special
29
+ * { __malformed: true } marker if it exists but isn't valid JSON (so we still back it up). */
30
+ async function readConfig(path) {
31
+ if (!(await pathExists(path)))
32
+ return { existing: undefined, malformed: false, fileExists: false };
33
+ const raw = await fs.readFile(path, "utf8");
34
+ if (raw.trim() === "")
35
+ return { existing: undefined, malformed: false, fileExists: true };
36
+ try {
37
+ return { existing: JSON.parse(raw), malformed: false, fileExists: true };
38
+ }
39
+ catch {
40
+ return { existing: undefined, malformed: true, fileExists: true };
41
+ }
42
+ }
43
+ /** Atomically-ish write: back up any existing file, then write pretty JSON with a trailing
44
+ * newline (matches how these editors format their own configs). */
45
+ async function writeConfig(path, config, fileExists) {
46
+ await fs.mkdir(dirname(path), { recursive: true });
47
+ let backedUp = false;
48
+ if (fileExists) {
49
+ await fs.copyFile(path, path + BACKUP_SUFFIX);
50
+ backedUp = true;
51
+ }
52
+ await fs.writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8");
53
+ return backedUp;
54
+ }
55
+ /** Is the given client installed on this machine? A config file/dir existing is the signal;
56
+ * Claude Code additionally counts as present when the `claude` CLI is available. */
57
+ async function isDetected(client, platform, env, claudeCli) {
58
+ for (const p of client.detectPaths(platform, env)) {
59
+ if (await pathExists(p))
60
+ return true;
61
+ }
62
+ if (client.id === "claude-code" && claudeCli && (await claudeCli.isAvailable()))
63
+ return true;
64
+ return false;
65
+ }
66
+ async function configureClient(client, opts, platform, env) {
67
+ const base = {
68
+ id: client.id,
69
+ label: client.label,
70
+ path: client.configPath(platform, env),
71
+ transport: opts.mode,
72
+ outcome: "not-found",
73
+ backedUp: false,
74
+ dryRun: opts.dryRun,
75
+ };
76
+ // Remote mode only applies to clients that actually speak Streamable HTTP.
77
+ if (opts.mode === "remote" && !client.supportsRemote) {
78
+ return {
79
+ ...base,
80
+ outcome: "skipped",
81
+ detail: "no verified HTTP transport — install stdio instead, or use a connector",
82
+ };
83
+ }
84
+ if (!(await isDetected(client, platform, env, opts.claudeCli))) {
85
+ return { ...base, outcome: "not-found", detail: "client not detected on this machine" };
86
+ }
87
+ const path = base.path;
88
+ if (!path) {
89
+ return { ...base, outcome: "skipped", detail: "no supported config path on this platform" };
90
+ }
91
+ const entry = buildEntry(client, {
92
+ mode: opts.mode,
93
+ url: opts.url,
94
+ token: opts.token,
95
+ });
96
+ // Decide the outcome from the current file state first (uniform across CLI and merge paths).
97
+ const { existing, malformed, fileExists } = await readConfig(path);
98
+ const plan = planMerge(existing, client.serverKey, entry);
99
+ if (plan.status === "already-configured") {
100
+ return { ...base, outcome: "already-configured" };
101
+ }
102
+ const outcome = plan.status === "updated" ? "updated" : "configured";
103
+ // Prefer `claude mcp add` for a FRESH Claude Code add when the CLI is present; updates and
104
+ // every other case go through the deterministic JSON merge (which cleanly replaces our key).
105
+ const useCli = client.id === "claude-code" &&
106
+ opts.claudeCli !== undefined &&
107
+ plan.status !== "updated" &&
108
+ (await opts.claudeCli.isAvailable());
109
+ if (opts.dryRun) {
110
+ const detail = useCli && opts.claudeCli
111
+ ? `would run: ${opts.claudeCli.previewCommand({ mode: opts.mode, url: opts.url, token: opts.token })}`
112
+ : malformed
113
+ ? "would rewrite malformed config (original backed up)"
114
+ : `would write ${path}`;
115
+ return {
116
+ ...base,
117
+ outcome,
118
+ method: useCli ? "claude-cli" : "json-merge",
119
+ detail,
120
+ };
121
+ }
122
+ if (useCli && opts.claudeCli) {
123
+ const res = await opts.claudeCli.add({ mode: opts.mode, url: opts.url, token: opts.token });
124
+ if (res.ok) {
125
+ return { ...base, outcome, method: "claude-cli" };
126
+ }
127
+ // CLI failed for some reason — fall through to the JSON merge so the client still gets wired.
128
+ }
129
+ const backedUp = await writeConfig(path, plan.config, fileExists);
130
+ return { ...base, outcome, method: "json-merge", backedUp };
131
+ }
132
+ /** Run `jamgate setup` across all (or a subset of) clients. */
133
+ export async function runSetup(opts) {
134
+ const platform = opts.platform ?? process.platform;
135
+ const env = opts.env ?? process.env;
136
+ const clients = CLIENTS.filter((c) => !opts.only || opts.only.includes(c.id));
137
+ const results = [];
138
+ for (const client of clients) {
139
+ try {
140
+ results.push(await configureClient(client, {
141
+ mode: opts.mode,
142
+ dryRun: opts.dryRun,
143
+ url: opts.url,
144
+ token: opts.token,
145
+ claudeCli: opts.claudeCli,
146
+ }, platform, env));
147
+ }
148
+ catch (err) {
149
+ results.push({
150
+ id: client.id,
151
+ label: client.label,
152
+ path: client.configPath(platform, env),
153
+ transport: opts.mode,
154
+ outcome: "error",
155
+ backedUp: false,
156
+ dryRun: opts.dryRun,
157
+ detail: err instanceof Error ? err.message : String(err),
158
+ });
159
+ }
160
+ }
161
+ return results;
162
+ }
163
+ /** Inspect where Jamgate is wired and where its store lives, without changing anything. */
164
+ export async function runStatus(opts = {}) {
165
+ const platform = opts.platform ?? process.platform;
166
+ const env = opts.env ?? process.env;
167
+ const clients = [];
168
+ for (const client of CLIENTS) {
169
+ const path = client.configPath(platform, env);
170
+ const detected = await isDetected(client, platform, env, opts.claudeCli);
171
+ let wired = false;
172
+ let transport;
173
+ if (path) {
174
+ const { existing } = await readConfig(path);
175
+ const servers = existing && typeof existing === "object" && existing !== null
176
+ ? existing.mcpServers
177
+ : undefined;
178
+ const entry = servers && typeof servers === "object" && servers !== null
179
+ ? servers[client.serverKey]
180
+ : undefined;
181
+ if (entry && typeof entry === "object") {
182
+ wired = true;
183
+ transport = "url" in entry ? "http" : "stdio";
184
+ }
185
+ }
186
+ clients.push({ id: client.id, label: client.label, path, detected, wired, transport });
187
+ }
188
+ // Mirror the store's own path resolution (FileStore: JAMGATE_STORE ?? ~/.jamgate/memory.json)
189
+ // so status reports where memories actually live.
190
+ const storePath = opts.storePath ??
191
+ env.JAMGATE_STORE ??
192
+ join(env.HOME || env.USERPROFILE || homedir(), ".jamgate", "memory.json");
193
+ return { clients, storePath };
194
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The single source of truth for the Jamgate version string in code.
3
+ *
4
+ * Kept in sync with `package.json` by hand on each release (the build has no codegen step).
5
+ * Used for the MCP server `serverInfo.version` and the `/healthz` payload, so both report
6
+ * the same number without importing `package.json` at runtime (which would break once the
7
+ * code is bundled or the file layout changes between `dist/` and the published package).
8
+ */
9
+ export const VERSION = "0.4.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamgate",
3
- "version": "0.1.0",
3
+ "version": "0.4.1",
4
4
  "mcpName": "io.github.amirj4m/jamgate",
5
5
  "description": "A neutral, cross-agent memory quality gate for AI agents, delivered as an MCP server — a gate, not a store.",
6
6
  "keywords": [