@remnic/plugin-claude-code 9.49.0 → 9.49.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.
@@ -1,10 +1,37 @@
1
1
  {
2
2
  "name": "remnic",
3
3
  "description": "Universal memory for AI agents — automatic recall, observation, and cross-agent knowledge sharing",
4
- "version": "9.49.0",
4
+ "version": "9.49.1",
5
5
  "author": {
6
6
  "name": "Joshua Warren"
7
7
  },
8
8
  "homepage": "https://github.com/joshuaswarren/remnic",
9
- "repository": "https://github.com/joshuaswarren/remnic"
9
+ "repository": "https://github.com/joshuaswarren/remnic",
10
+ "userConfig": {
11
+ "remnic_daemon_token": {
12
+ "type": "string",
13
+ "title": "Remnic Daemon Bearer Token",
14
+ "description": "Bearer token the MCP server sends on every request. Mint via `remnic token generate claude-code` (or `remnic connectors install claude-code` on first install).",
15
+ "sensitive": true
16
+ },
17
+ "remnic_daemon_url": {
18
+ "type": "string",
19
+ "title": "Remnic Daemon MCP URL",
20
+ "description": "Full URL of the Remnic daemon's MCP endpoint. Default http://localhost:4318/mcp. Use http:// loopback or https:// — plaintext http to non-loopback is rejected because the bearer token travels with every request.",
21
+ "default": "http://localhost:4318/mcp"
22
+ }
23
+ },
24
+ "mcpServers": {
25
+ "remnic": {
26
+ "type": "stdio",
27
+ "command": "node",
28
+ "args": [
29
+ "${CLAUDE_PLUGIN_ROOT}/mcp-server-stdio/server.js"
30
+ ],
31
+ "env": {
32
+ "REMNIC_PLUGIN_DAEMON_URL": "${user_config.remnic_daemon_url}",
33
+ "REMNIC_PLUGIN_DAEMON_TOKEN": "${user_config.remnic_daemon_token}"
34
+ }
35
+ }
36
+ }
10
37
  }
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env node
2
+ // @remnic/plugin-claude-code — stdio↔HTTP MCP proxy
3
+ //
4
+ // Bridges the Remnic HTTP MCP endpoint (`<user_config.remnic_daemon_url>`)
5
+ // to a stdio MCP server Claude Code can speak without forking per request.
6
+ //
7
+ // Auth: reads `REMNIC_PLUGIN_DAEMON_TOKEN` (a daemon bearer token the user
8
+ // provided at plugin install time via Claude Code's `userConfig` flow) and
9
+ // sends it as the `Authorization: Bearer …` header on every HTTP request.
10
+ //
11
+ // URL: reads `REMNIC_PLUGIN_DAEMON_URL`, defaulting to `http://localhost:4318/mcp`
12
+ // (matches the package's documented local daemon default). http:// is gated to
13
+ // loopback hostnames (localhost, an IPv4 in 127.0.0.0/8, or ::1) so the bearer
14
+ // token never travels cleartext to a non-loopback target; https:// is
15
+ // unrestricted (the bearer is encrypted in transport).
16
+ //
17
+ // Transport: line-delimited JSON-RPC over stdin (with chunk-boundary safe
18
+ // accumulation), JSON-RPC over stdout. The Remnic daemon's `/mcp` endpoint
19
+ // streams SSE in some modes; for tool-call sizing this proxy reads a single
20
+ // buffered response per request and tolerates empty 202 bodies used by
21
+ // notifications/initialized.
22
+ //
23
+ // Exit codes:
24
+ // 0 graceful shutdown
25
+ // 2 fatal config error (missing required env, non-loopback http, etc.)
26
+
27
+ import http from "node:http";
28
+ import https from "node:https";
29
+ import { URL } from "node:url";
30
+
31
+ const URL_DEFAULT = "http://localhost:4318/mcp";
32
+ const REQUEST_TIMEOUT_MS = 30_000;
33
+ const EXIT_DRAIN_TIMEOUT_MS = 10_000;
34
+ const MAX_BODY_BYTES = 8 * 1024 * 1024; // 8 MiB; matches MCP tool-result upper bound
35
+
36
+ function readEnv(name, fallback) {
37
+ const v = process.env[name];
38
+ if (v === undefined || v === "") return fallback;
39
+ return v;
40
+ }
41
+
42
+ function fatal(msg) {
43
+ process.stderr.write(`remnic-mcp-proxy: ${msg}\n`);
44
+ process.exit(2);
45
+ }
46
+
47
+ function sanitizeMessage(msg) {
48
+ // Strip paths and control characters from upstream Node errors so they do
49
+ // not leak absolute filesystem paths or stack snippets into a JSON-RPC
50
+ // error envelope the client sees.
51
+ if (typeof msg !== "string") return "internal error";
52
+ let s = msg.replace(/[\p{Cc}]/gu, " ");
53
+ // Replace absolute paths with a generic token (defense-in-depth).
54
+ s = s.replace(/\s(?:file:|\/)[^\s'"]+/g, " <path> ");
55
+ if (s.length > 200) s = `${s.slice(0, 200)}…`;
56
+ return s;
57
+ }
58
+
59
+ function isLoopbackHostname(hostname) {
60
+ if (!hostname) return false;
61
+ const h = hostname.toLowerCase();
62
+ if (h === "localhost") return true;
63
+ // Reject bracketed literal IPv6 like "[::1]" — strip brackets first.
64
+ const bare = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
65
+ if (bare === "::1") return true;
66
+ // IPv4 dotted: check 127.0.0.0/8 range.
67
+ if (/^127(?:\.\d{1,3}){3}$/.test(bare)) return true;
68
+ // Defensive: anything that resolves through DNS that happens to be a
69
+ // loopback name (rare) is allowed by the URL constructor only if literally
70
+ // "localhost" so users must opt-in to LAN/external IPs explicitly.
71
+ return false;
72
+ }
73
+
74
+ const daemonUrl = readEnv("REMNIC_PLUGIN_DAEMON_URL", URL_DEFAULT);
75
+ const token = readEnv("REMNIC_PLUGIN_DAEMON_TOKEN", "");
76
+ if (!token) {
77
+ fatal(
78
+ "REMNIC_PLUGIN_DAEMON_TOKEN is not set; the plugin install flow must supply the bearer token via Claude Code userConfig."
79
+ );
80
+ }
81
+ let parsed;
82
+ try {
83
+ parsed = new URL(daemonUrl);
84
+ } catch {
85
+ fatal(`REMNIC_PLUGIN_DAEMON_URL is not a parseable URL: ${daemonUrl}`);
86
+ }
87
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
88
+ fatal(
89
+ `unsupported URL protocol in REMNIC_PLUGIN_DAEMON_URL: ${parsed.protocol} (expected http: or https:; refuse to send bearer token over plaintext to a non-loopback target)`
90
+ );
91
+ }
92
+ if (parsed.protocol === "http:" && !isLoopbackHostname(parsed.hostname)) {
93
+ fatal(
94
+ `REMNIC_PLUGIN_DAEMON_URL uses plain http:// to a non-loopback host (${parsed.hostname}); refuse to send bearer token cleartext. Use https:// or http://localhost / http://127.0.0.x.`
95
+ );
96
+ }
97
+ const transport = parsed.protocol === "https:" ? https : http;
98
+
99
+ // State: an in-flight count for graceful drain on stdin EOF.
100
+ let inFlight = 0;
101
+ let stdinEnded = false;
102
+ let exiting = false;
103
+
104
+ let stdinTail = ""; // partial line carried across stdin data events
105
+ const lineQueue = [];
106
+ process.stdin.setEncoding("utf8");
107
+ process.stdin.on("data", (chunk) => {
108
+ // Concatenate with any prior tail (a JSON-RPC record split across chunks),
109
+ // split on \n, and carry the final fragment forward.
110
+ const buf = `${stdinTail}${chunk}`;
111
+ const parts = buf.split("\n");
112
+ stdinTail = parts.pop();
113
+ for (const p of parts) {
114
+ if (p.length === 0) continue;
115
+ lineQueue.push(p);
116
+ }
117
+ drain();
118
+ });
119
+ process.stdin.on("end", () => {
120
+ stdinEnded = true;
121
+ // Flush any trailing partial line; the boundary contract is that one
122
+ // newline-terminated line per JSON-RPC record.
123
+ if (stdinTail.length > 0) {
124
+ lineQueue.push(stdinTail);
125
+ stdinTail = "";
126
+ drain();
127
+ }
128
+ // Drain in-flight forwards before exiting.
129
+ tryExit();
130
+ });
131
+
132
+ process.stdin.on("error", (err) => {
133
+ fatal(`stdin error: ${sanitizeMessage(err.message)}`);
134
+ });
135
+
136
+ process.stdout.on("error", (err) => {
137
+ process.stderr.write(`remnic-mcp-proxy: stdout error: ${sanitizeMessage(err.message)}\n`);
138
+ process.exit(1);
139
+ });
140
+
141
+ function writeRaw(payload) {
142
+ try {
143
+ process.stdout.write(`${payload}\n`);
144
+ } catch (err) {
145
+ process.stderr.write(`remnic-mcp-proxy: stdout write failed: ${sanitizeMessage(err.message)}\n`);
146
+ }
147
+ }
148
+
149
+ function writeError(id, code, message, requestId) {
150
+ const safeMsg = sanitizeMessage(message);
151
+ const payload = {
152
+ jsonrpc: "2.0",
153
+ id: id ?? null,
154
+ error: { code, message: safeMsg },
155
+ };
156
+ if (requestId !== undefined && requestId !== null) payload.id = requestId;
157
+ writeRaw(JSON.stringify(payload));
158
+ }
159
+
160
+ function writeResult(id, result) {
161
+ writeRaw(JSON.stringify({ jsonrpc: "2.0", id, result }));
162
+ }
163
+
164
+ function writeNotificationAck() {
165
+ // Per JSON-RPC 2.0, a notification has no `id` and the receiver MUST NOT reply.
166
+ // The proxy acknowledges indirectly by emitting nothing for notifications.
167
+ }
168
+
169
+ function drain() {
170
+ while (lineQueue.length > 0) {
171
+ const line = lineQueue.shift();
172
+ handleLine(line);
173
+ }
174
+ }
175
+
176
+ function handleLine(line) {
177
+ let msg;
178
+ try {
179
+ msg = JSON.parse(line);
180
+ } catch (err) {
181
+ writeError(null, -32700, `parse error: ${sanitizeMessage(err.message)}`);
182
+ return;
183
+ }
184
+ // Validate JSON-RPC shape: must be a non-null object with a string method.
185
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg)) {
186
+ writeError(null, -32600, "invalid request: not a JSON-RPC object");
187
+ return;
188
+ }
189
+ if (typeof msg.method !== "string" || msg.method.length === 0) {
190
+ writeError(msg.id ?? null, -32600, "invalid request: missing or empty method");
191
+ return;
192
+ }
193
+ // Distinguish notification (no `id`) from request (has `id`).
194
+ const isNotification = !Object.prototype.hasOwnProperty.call(msg, "id");
195
+ inFlight += 1;
196
+ forward(msg, isNotification).finally(() => {
197
+ inFlight -= 1;
198
+ tryExit();
199
+ });
200
+ }
201
+
202
+ function forward(msg, isNotification) {
203
+ // For the SDK's "initialized" notification, the daemon may return 202 with
204
+ // an empty body and no Content-Type. Treat that as a successful notification
205
+ // ack: emit no reply and resolve without surfacing a parse error. For
206
+ // requests, missing/empty body is also a parse error from the daemon.
207
+ const body = JSON.stringify({
208
+ jsonrpc: msg.jsonrpc ?? "2.0",
209
+ ...(isNotification ? {} : { id: msg.id ?? null }),
210
+ method: msg.method,
211
+ params: msg.params ?? {},
212
+ });
213
+ return new Promise((resolve) => {
214
+ const req = transport.request(
215
+ {
216
+ method: "POST",
217
+ hostname: parsed.hostname,
218
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
219
+ path: `${parsed.pathname || ""}${parsed.search || ""}` || "/mcp",
220
+ headers: {
221
+ "Content-Type": "application/json",
222
+ Accept: "application/json, text/event-stream",
223
+ "X-Engram-Client-Id": "claude-code",
224
+ Authorization: `Bearer ${token}`,
225
+ "Content-Length": Buffer.byteLength(body, "utf8"),
226
+ },
227
+ },
228
+ (res) => {
229
+ let chunks = "";
230
+ let received = 0;
231
+ res.setEncoding("utf8");
232
+ res.on("data", (c) => {
233
+ received += Buffer.byteLength(c, "utf8");
234
+ if (received > MAX_BODY_BYTES) {
235
+ req.destroy(new Error(`response exceeded MAX_BODY_BYTES (${MAX_BODY_BYTES})`));
236
+ return;
237
+ }
238
+ chunks += c;
239
+ });
240
+ res.on("end", () => {
241
+ try {
242
+ if (isNotification) {
243
+ // No reply expected. Either 2xx success or non-2xx-with-no-body
244
+ // both resolve silently; a 2xx body (some daemons return
245
+ // 202+JSON for non-streaming notifications) is also silently
246
+ // discarded because notifications never reply.
247
+ if (res.statusCode < 200 || res.statusCode >= 300) {
248
+ process.stderr.write(
249
+ `remnic-mcp-proxy: notification ${msg.method} got HTTP ${res.statusCode}: ${sanitizeMessage(chunks).slice(0, 200)}\n`
250
+ );
251
+ }
252
+ writeNotificationAck();
253
+ resolve();
254
+ return;
255
+ }
256
+ if (res.statusCode < 200 || res.statusCode >= 300) {
257
+ writeError(
258
+ msg.id ?? null,
259
+ -32001,
260
+ `daemon responded ${res.statusCode}: ${sanitizeMessage(chunks).slice(0, 200)}`,
261
+ msg.id ?? null
262
+ );
263
+ resolve();
264
+ return;
265
+ }
266
+ const trimmed = chunks.trim();
267
+ // Empty 202 / 204 bodies on a request are a contract violation
268
+ // (the daemon should always JSON-RPC). Surface as a parse error
269
+ // so MCP clients see the real failure mode.
270
+ if (trimmed.length === 0) {
271
+ writeError(
272
+ msg.id ?? null,
273
+ -32002,
274
+ `daemon returned empty body with HTTP ${res.statusCode}`,
275
+ msg.id ?? null
276
+ );
277
+ resolve();
278
+ return;
279
+ }
280
+ // The Remnic /mcp endpoint may emit SSE in streaming mode —
281
+ // strip `data: ` prefixes if present.
282
+ const cleaned = trimmed
283
+ .split("\n")
284
+ .filter((l) => l && !l.startsWith("event:") && !l.startsWith(":"))
285
+ .map((l) => (l.startsWith("data: ") ? l.slice(6) : l))
286
+ .join("\n")
287
+ .trim();
288
+ let parsedResp;
289
+ try {
290
+ parsedResp = JSON.parse(cleaned);
291
+ } catch (err) {
292
+ writeError(
293
+ msg.id ?? null,
294
+ -32002,
295
+ `daemon returned non-JSON: ${sanitizeMessage(err.message)} (preview: ${cleaned.slice(0, 200)})`,
296
+ msg.id ?? null
297
+ );
298
+ resolve();
299
+ return;
300
+ }
301
+ if (parsedResp && "error" in parsedResp) {
302
+ writeRaw(JSON.stringify(parsedResp));
303
+ resolve();
304
+ return;
305
+ }
306
+ writeResult(msg.id ?? null, parsedResp.result ?? parsedResp);
307
+ resolve();
308
+ } catch (err) {
309
+ // The outer guard catches anything that slipped through the inner
310
+ // parses. For requests, emit a JSON-RPC error envelope so the MCP
311
+ // client sees a real failure rather than a silent stall. For
312
+ // notifications (which carry no id and to which the receiver MUST
313
+ // NOT reply per JSON-RPC 2.0), log to stderr only.
314
+ if (isNotification) {
315
+ process.stderr.write(
316
+ `remnic-mcp-proxy: notification ${msg.method} handler error: ${sanitizeMessage(err.message)}\n`
317
+ );
318
+ } else {
319
+ writeError(
320
+ msg.id ?? null,
321
+ -32603,
322
+ `internal proxy error: ${sanitizeMessage(err.message)}`,
323
+ msg.id ?? null
324
+ );
325
+ }
326
+ resolve();
327
+ }
328
+ });
329
+ res.on("error", (err) => {
330
+ if (isNotification) {
331
+ process.stderr.write(
332
+ `remnic-mcp-proxy: notification ${msg.method} response stream error: ${sanitizeMessage(err.message)}\n`
333
+ );
334
+ } else {
335
+ writeError(
336
+ msg.id ?? null,
337
+ -32003,
338
+ `response stream error: ${sanitizeMessage(err.message)}`,
339
+ msg.id ?? null
340
+ );
341
+ }
342
+ resolve();
343
+ });
344
+ }
345
+ );
346
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => {
347
+ req.destroy(new Error(`request timed out after ${REQUEST_TIMEOUT_MS}ms`));
348
+ });
349
+ req.on("error", (err) => {
350
+ if (isNotification) {
351
+ process.stderr.write(
352
+ `remnic-mcp-proxy: notification ${msg.method} transport error: ${sanitizeMessage(err.message)}\n`
353
+ );
354
+ } else {
355
+ writeError(msg.id ?? null, -32003, `transport error: ${sanitizeMessage(err.message)}`, msg.id ?? null);
356
+ }
357
+ resolve();
358
+ });
359
+ req.write(body);
360
+ req.end();
361
+ });
362
+ }
363
+
364
+ function tryExit() {
365
+ if (exiting) return;
366
+ if (!stdinEnded) return;
367
+ if (inFlight > 0) {
368
+ // Wait for forwards to drain up to EXIT_DRAIN_TIMEOUT_MS, then exit anyway.
369
+ // Guard against an unbounded wait via a one-shot timer.
370
+ setTimeout(() => {
371
+ exiting = true;
372
+ process.exit(0);
373
+ }, EXIT_DRAIN_TIMEOUT_MS).unref();
374
+ return;
375
+ }
376
+ exiting = true;
377
+ process.exit(0);
378
+ }
379
+
380
+ drain();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/plugin-claude-code",
3
- "version": "9.49.0",
3
+ "version": "9.49.1",
4
4
  "description": "Remnic memory plugin for Claude Code — hooks, skills, MCP integration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "hooks",
26
26
  "skills",
27
27
  "agents",
28
+ "mcp-server-stdio",
28
29
  ".mcp.json",
29
30
  "settings.json"
30
31
  ]