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 +264 -16
- package/dist/http.js +270 -0
- package/dist/index.js +61 -6
- package/dist/setup/cli.js +199 -0
- package/dist/setup/clients.js +137 -0
- package/dist/setup/merge.js +52 -0
- package/dist/setup/runner.js +194 -0
- package/dist/version.js +9 -0
- package/package.json +1 -1
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
|
-
|
|
15
|
+
npx jamgate setup
|
|
14
16
|
```
|
|
15
17
|
|
|
18
|
+
[](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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
[](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
|
+
[](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,
|
|
195
|
-
intelligence layers all work today (see [`CHANGELOG.md`](./CHANGELOG.md)
|
|
196
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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:
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
+
}
|
package/dist/version.js
ADDED
|
@@ -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