relic-mcp 0.1.1 → 0.2.0

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.
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "relic",
4
+ "description": "Relic: zero-knowledge publishing for agent output. Turns a local file into a shareable link without handing the file to anybody.",
5
+ "owner": {
6
+ "name": "The Bushido Collective",
7
+ "url": "https://thebushido.co"
8
+ },
9
+ "plugins": [
10
+ {
11
+ "name": "relic",
12
+ "description": "Publish a file from your machine as an encrypted, shareable link. The agent encrypts locally, uploads only ciphertext, and hands back a URL whose fragment holds the key, so the service stores something it cannot read.",
13
+ "version": "0.2.0",
14
+ "source": "./",
15
+ "author": {
16
+ "name": "The Bushido Collective",
17
+ "url": "https://thebushido.co"
18
+ },
19
+ "homepage": "https://github.com/TheBushidoCollective/relic",
20
+ "category": "productivity"
21
+ }
22
+ ],
23
+ "metadata": {
24
+ "version": "0.2.0"
25
+ }
26
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "relic",
3
+ "version": "0.2.0",
4
+ "description": "Publish a file from your machine as an encrypted, shareable link. The agent encrypts locally, uploads only ciphertext, and hands back a URL whose fragment holds the key, so the service stores something it cannot read.",
5
+ "mcpServers": "./mcp-servers.json",
6
+ "author": {
7
+ "name": "The Bushido Collective",
8
+ "url": "https://thebushido.co"
9
+ },
10
+ "homepage": "https://github.com/TheBushidoCollective/relic",
11
+ "repository": "https://github.com/TheBushidoCollective/relic",
12
+ "license": "UNLICENSED",
13
+ "keywords": [
14
+ "claude-code",
15
+ "claude-code-plugin",
16
+ "sharing",
17
+ "encryption",
18
+ "zero-knowledge",
19
+ "publishing"
20
+ ]
21
+ }
package/README.md CHANGED
@@ -87,4 +87,4 @@ revisions (`2025-11-25` and earlier) are still answered.
87
87
 
88
88
  Requires Node 18 or newer.
89
89
 
90
- MIT licensed. Source: https://github.com/TheBushidoCollective/artifacts
90
+ MIT licensed. Source: https://github.com/TheBushidoCollective/relic
package/dist/relic-mcp.js CHANGED
@@ -1042,13 +1042,387 @@ function createHttpHandler(deps, options = {}) {
1042
1042
  };
1043
1043
  }
1044
1044
 
1045
+ // src/installer.ts
1046
+ import { spawnSync } from "node:child_process";
1047
+ import { copyFile, mkdir, readFile as readFile2, stat, writeFile } from "node:fs/promises";
1048
+ import { homedir } from "node:os";
1049
+ import { dirname, join, resolve } from "node:path";
1050
+ import { fileURLToPath } from "node:url";
1051
+
1052
+ // src/install.ts
1053
+ var HARNESSES = [
1054
+ {
1055
+ id: "claude-code",
1056
+ label: "Claude Code",
1057
+ format: "plugin",
1058
+ configPath: ""
1059
+ },
1060
+ {
1061
+ id: "claude-desktop",
1062
+ label: "Claude Desktop",
1063
+ format: "json-mcp-servers",
1064
+ configPath: "Library/Application Support/Claude/claude_desktop_config.json"
1065
+ },
1066
+ {
1067
+ id: "cursor",
1068
+ label: "Cursor",
1069
+ format: "json-mcp-servers",
1070
+ configPath: ".cursor/mcp.json"
1071
+ },
1072
+ {
1073
+ id: "windsurf",
1074
+ label: "Windsurf",
1075
+ format: "json-mcp-servers",
1076
+ configPath: ".codeium/windsurf/mcp_config.json"
1077
+ },
1078
+ {
1079
+ id: "gemini",
1080
+ label: "Gemini CLI",
1081
+ format: "json-mcp-servers",
1082
+ configPath: ".gemini/settings.json"
1083
+ },
1084
+ {
1085
+ id: "vscode",
1086
+ label: "VS Code",
1087
+ format: "json-servers",
1088
+ configPath: "Library/Application Support/Code/User/mcp.json"
1089
+ },
1090
+ {
1091
+ id: "codex",
1092
+ label: "Codex",
1093
+ format: "toml",
1094
+ configPath: ".codex/config.toml"
1095
+ }
1096
+ ];
1097
+
1098
+ class UnknownHarnessError extends Error {
1099
+ }
1100
+
1101
+ class ExistingEntryError extends Error {
1102
+ }
1103
+ function planFor(harnessId, spec, existing, options = {}) {
1104
+ const harness = HARNESSES.find((h) => h.id === harnessId);
1105
+ if (harness === undefined) {
1106
+ throw new UnknownHarnessError(`Unknown harness ${harnessId}. Known: ${HARNESSES.map((h) => h.id).join(", ")}`);
1107
+ }
1108
+ if (harness.format === "toml") {
1109
+ return tomlPlan(harness, spec, existing, options);
1110
+ }
1111
+ return jsonPlan(harness, spec, existing, options);
1112
+ }
1113
+ function jsonPlan(harness, spec, existing, options) {
1114
+ const key = harness.format === "json-servers" ? "servers" : "mcpServers";
1115
+ let root = {};
1116
+ if (existing !== undefined && existing.trim().length > 0) {
1117
+ try {
1118
+ root = JSON.parse(existing);
1119
+ } catch (error) {
1120
+ throw new ExistingEntryError(`${harness.configPath} is not valid JSON, so merging would destroy it: ` + `${error.message}`);
1121
+ }
1122
+ }
1123
+ const servers = root[key] ?? {};
1124
+ const replaced = Object.hasOwn(servers, spec.name);
1125
+ if (replaced && options.force !== true) {
1126
+ throw new ExistingEntryError(`${harness.label} already has a server named "${spec.name}". ` + "Pass --force to replace it.");
1127
+ }
1128
+ const entry = harness.format === "json-servers" ? { type: "stdio", command: spec.command, args: spec.args, env: spec.env } : { command: spec.command, args: spec.args, env: spec.env };
1129
+ const merged = { ...root, [key]: { ...servers, [spec.name]: entry } };
1130
+ return {
1131
+ harness,
1132
+ path: harness.configPath,
1133
+ contents: `${JSON.stringify(merged, null, 2)}
1134
+ `,
1135
+ replaced
1136
+ };
1137
+ }
1138
+ function tomlPlan(harness, spec, existing, options) {
1139
+ const body = existing ?? "";
1140
+ const header = `[mcp_servers.${spec.name}]`;
1141
+ const replaced = body.includes(header);
1142
+ if (replaced && options.force !== true) {
1143
+ throw new ExistingEntryError(`${harness.label} already has [mcp_servers.${spec.name}]. ` + "Pass --force to replace it.");
1144
+ }
1145
+ const table = [
1146
+ header,
1147
+ `command = ${tomlString(spec.command)}`,
1148
+ `args = [${spec.args.map(tomlString).join(", ")}]`,
1149
+ ...Object.keys(spec.env).length > 0 ? [
1150
+ `[mcp_servers.${spec.name}.env]`,
1151
+ ...Object.entries(spec.env).map(([k, v]) => `${k} = ${tomlString(v)}`)
1152
+ ] : []
1153
+ ].join(`
1154
+ `);
1155
+ const withoutOld = replaced ? dropTomlTable(body, spec.name) : body;
1156
+ const separator = withoutOld.length === 0 || withoutOld.endsWith(`
1157
+
1158
+ `) ? "" : withoutOld.endsWith(`
1159
+ `) ? `
1160
+ ` : `
1161
+
1162
+ `;
1163
+ return {
1164
+ harness,
1165
+ path: harness.configPath,
1166
+ contents: `${withoutOld}${separator}${table}
1167
+ `,
1168
+ replaced
1169
+ };
1170
+ }
1171
+ function dropTomlTable(body, name) {
1172
+ const lines = body.split(`
1173
+ `);
1174
+ const kept = [];
1175
+ let skipping = false;
1176
+ for (const line of lines) {
1177
+ const isHeader = /^\s*\[/.test(line);
1178
+ if (isHeader) {
1179
+ skipping = line.trim() === `[mcp_servers.${name}]` || line.trim().startsWith(`[mcp_servers.${name}.`);
1180
+ }
1181
+ if (!skipping)
1182
+ kept.push(line);
1183
+ }
1184
+ return `${kept.join(`
1185
+ `).replace(/\n{3,}$/, `
1186
+
1187
+ `).trimEnd()}
1188
+ `;
1189
+ }
1190
+ function tomlString(value) {
1191
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
1192
+ }
1193
+ function snippetFor(spec) {
1194
+ return `${JSON.stringify({
1195
+ mcpServers: {
1196
+ [spec.name]: { command: spec.command, args: spec.args, env: spec.env }
1197
+ }
1198
+ }, null, 2)}
1199
+ `;
1200
+ }
1201
+
1202
+ // src/origin.ts
1203
+ function requiredOrigin(name, raw) {
1204
+ if (raw === undefined || raw.trim().length === 0) {
1205
+ throw new Error(`${name} is not set. It is the Relic service this client publishes to, ` + "for example https://relic.example.com. Installing the Relic plugin " + "sets it for you; set it yourself when running this server directly.");
1206
+ }
1207
+ let parsed;
1208
+ try {
1209
+ parsed = new URL(raw.trim());
1210
+ } catch {
1211
+ throw new Error(`${name} is not a URL: ${raw}`);
1212
+ }
1213
+ const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
1214
+ if (parsed.protocol !== "https:" && !loopback) {
1215
+ throw new Error(`${name} must be https, or a loopback host for development. Got ${raw}. ` + "Plaintext never leaves this machine, but the grant that authorizes " + "an upload does, and over http anyone on the path can take it.");
1216
+ }
1217
+ return parsed.origin;
1218
+ }
1219
+
1220
+ // src/installer.ts
1221
+ var USAGE = `relic-mcp - publish a file as an encrypted, shareable link
1222
+
1223
+ relic-mcp run the MCP server on stdio
1224
+ relic-mcp install [options] add this server to an agent harness
1225
+ relic-mcp --help this
1226
+
1227
+ Install options:
1228
+ --client <id> ${HARNESSES.map((h) => h.id).join(", ")}
1229
+ Omit to see which of these are installed here.
1230
+ --origin <url> The Relic service to publish to. Falls back to
1231
+ RELIC_SERVICE_ORIGIN.
1232
+ --name <name> Server name in the config. Default: relic.
1233
+ --print Write nothing; print the config to paste.
1234
+ --force Replace an existing entry of the same name.
1235
+ --dry-run Show the file and what would change, without writing.
1236
+ `;
1237
+ function parseArgs(argv) {
1238
+ const options = {
1239
+ client: undefined,
1240
+ origin: undefined,
1241
+ name: "relic",
1242
+ print: false,
1243
+ force: false,
1244
+ dryRun: false
1245
+ };
1246
+ for (let i = 0;i < argv.length; i++) {
1247
+ const arg = argv[i];
1248
+ const next = () => {
1249
+ const value = argv[++i];
1250
+ if (value === undefined)
1251
+ throw new Error(`${arg} needs a value`);
1252
+ return value;
1253
+ };
1254
+ if (arg === "--client")
1255
+ options.client = next();
1256
+ else if (arg === "--origin")
1257
+ options.origin = next();
1258
+ else if (arg === "--name")
1259
+ options.name = next();
1260
+ else if (arg === "--print")
1261
+ options.print = true;
1262
+ else if (arg === "--force")
1263
+ options.force = true;
1264
+ else if (arg === "--dry-run")
1265
+ options.dryRun = true;
1266
+ else
1267
+ throw new Error(`Unknown option ${arg}`);
1268
+ }
1269
+ return options;
1270
+ }
1271
+ function packageRoot() {
1272
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
1273
+ }
1274
+ async function exists(path) {
1275
+ return readFile2(path).then(() => true).catch(() => false);
1276
+ }
1277
+ async function runInstall(argv) {
1278
+ let options;
1279
+ try {
1280
+ options = parseArgs(argv);
1281
+ } catch (error) {
1282
+ process.stderr.write(`${error.message}
1283
+
1284
+ ${USAGE}`);
1285
+ process.exit(2);
1286
+ }
1287
+ const spec = {
1288
+ name: options.name,
1289
+ command: "npx",
1290
+ args: ["-y", "relic-mcp"],
1291
+ env: {}
1292
+ };
1293
+ if (options.print && options.client === undefined) {
1294
+ process.stdout.write(snippetFor(withOrigin(spec, options)));
1295
+ return;
1296
+ }
1297
+ if (options.client === undefined) {
1298
+ await reportDetected();
1299
+ return;
1300
+ }
1301
+ const resolved = withOrigin(spec, options);
1302
+ if (options.client === "claude-code") {
1303
+ await installClaudeCode(options);
1304
+ return;
1305
+ }
1306
+ const home = homedir();
1307
+ const harness = HARNESSES.find((h) => h.id === options.client);
1308
+ if (harness === undefined) {
1309
+ process.stderr.write(`Unknown client ${options.client}. Known: ` + `${HARNESSES.map((h) => h.id).join(", ")}
1310
+ `);
1311
+ process.exit(2);
1312
+ }
1313
+ const path = join(home, harness.configPath);
1314
+ const existing = await readFile2(path, "utf8").catch(() => {
1315
+ return;
1316
+ });
1317
+ let plan;
1318
+ try {
1319
+ plan = planFor(options.client, resolved, existing, {
1320
+ force: options.force
1321
+ });
1322
+ } catch (error) {
1323
+ if (error instanceof ExistingEntryError || error instanceof UnknownHarnessError) {
1324
+ process.stderr.write(`${error.message}
1325
+ `);
1326
+ process.exit(1);
1327
+ }
1328
+ throw error;
1329
+ }
1330
+ if (options.print) {
1331
+ process.stdout.write(plan.contents);
1332
+ return;
1333
+ }
1334
+ if (options.dryRun) {
1335
+ process.stdout.write(`Would ${plan.replaced ? "replace" : "add"} "${resolved.name}" in ${path}
1336
+ `);
1337
+ return;
1338
+ }
1339
+ if (existing !== undefined) {
1340
+ await copyFile(path, `${path}.relic-backup`);
1341
+ }
1342
+ await mkdir(dirname(path), { recursive: true });
1343
+ await writeFile(path, plan.contents);
1344
+ process.stdout.write(`${plan.replaced ? "Replaced" : "Added"} "${resolved.name}" in ${path}
1345
+ ` + (existing === undefined ? "" : `Previous config saved to ${path}.relic-backup
1346
+ `) + `Restart ${harness.label} to pick it up.
1347
+ `);
1348
+ }
1349
+ async function installClaudeCode(options) {
1350
+ const root = packageRoot();
1351
+ if (!await exists(join(root, ".claude-plugin", "marketplace.json"))) {
1352
+ process.stderr.write("This copy of relic-mcp does not carry the plugin manifests, so it " + "cannot be installed as a Claude Code plugin. Use --print and add " + `the server manually, or install a newer version.
1353
+ `);
1354
+ process.exit(1);
1355
+ }
1356
+ if (options.dryRun) {
1357
+ process.stdout.write(`Would add marketplace ${root} and install relic@relic
1358
+ `);
1359
+ return;
1360
+ }
1361
+ for (const args of [
1362
+ ["plugin", "marketplace", "add", root],
1363
+ ["plugin", "install", "relic@relic"]
1364
+ ]) {
1365
+ const result = spawnSync("claude", args, { stdio: "inherit" });
1366
+ if (result.error !== undefined || result.status !== 0) {
1367
+ process.stderr.write(`
1368
+ claude ${args.join(" ")} failed. Is the Claude Code CLI on PATH?
1369
+ `);
1370
+ process.exit(1);
1371
+ }
1372
+ }
1373
+ process.stdout.write(`
1374
+ Restart Claude Code to pick it up.
1375
+ `);
1376
+ }
1377
+ function withOrigin(spec, options) {
1378
+ const raw = options.origin ?? process.env["RELIC_SERVICE_ORIGIN"];
1379
+ let origin;
1380
+ try {
1381
+ origin = requiredOrigin("--origin", raw);
1382
+ } catch (error) {
1383
+ process.stderr.write(`${error.message}
1384
+
1385
+ Pass --origin https://your-relic-service
1386
+ `);
1387
+ process.exit(2);
1388
+ }
1389
+ return { ...spec, env: { RELIC_SERVICE_ORIGIN: origin } };
1390
+ }
1391
+ async function reportDetected() {
1392
+ const home = homedir();
1393
+ const lines = ["Harnesses detected here:", ""];
1394
+ for (const harness of HARNESSES) {
1395
+ if (harness.format === "plugin") {
1396
+ const found2 = spawnSync("claude", ["--version"], { stdio: "ignore" });
1397
+ lines.push(` ${found2.status === 0 ? "*" : " "} ${harness.id.padEnd(15)}${harness.label}`);
1398
+ continue;
1399
+ }
1400
+ const dir = dirname(join(home, harness.configPath));
1401
+ const found = await stat(dir).then((info) => info.isDirectory()).catch(() => false);
1402
+ lines.push(` ${found ? "*" : " "} ${harness.id.padEnd(15)}${harness.label}`);
1403
+ }
1404
+ lines.push("", "Install with:", " relic-mcp install --client <id>", "");
1405
+ process.stdout.write(lines.join(`
1406
+ `));
1407
+ }
1408
+
1045
1409
  // src/index.ts
1410
+ var argv = process.argv.slice(2);
1411
+ if (argv[0] === "install") {
1412
+ await runInstall(argv.slice(1));
1413
+ process.exit(0);
1414
+ }
1415
+ if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
1416
+ process.stdout.write(USAGE);
1417
+ process.exit(0);
1418
+ }
1419
+ var serviceOrigin = requiredOrigin("RELIC_SERVICE_ORIGIN", process.env["RELIC_SERVICE_ORIGIN"]);
1046
1420
  var deps = {
1047
- serviceOrigin: process.env["RELIC_SERVICE_ORIGIN"] ?? "https://relic.example",
1048
- relicOrigin: process.env["RELIC_ORIGIN"] ?? process.env["RELIC_SERVICE_ORIGIN"] ?? "https://relic.example",
1421
+ serviceOrigin,
1422
+ relicOrigin: process.env["RELIC_ORIGIN"] === undefined ? serviceOrigin : requiredOrigin("RELIC_ORIGIN", process.env["RELIC_ORIGIN"]),
1049
1423
  files: nodeFiles,
1050
1424
  fetch: globalThis.fetch,
1051
- clientName: process.env["RELIC_CLIENT_NAME"] ?? "relic-mcp/0.1.0"
1425
+ clientName: process.env["RELIC_CLIENT_NAME"] ?? "relic-mcp"
1052
1426
  };
1053
1427
  if (process.env["RELIC_MCP_HTTP"] === "1") {
1054
1428
  const port = Number(process.env["RELIC_MCP_PORT"] ?? 7333);
@@ -0,0 +1,11 @@
1
+ {
2
+ "relic": {
3
+ "type": "stdio",
4
+ "command": "npx",
5
+ "args": ["-y", "relic-mcp@0.1.1"],
6
+ "env": {
7
+ "RELIC_SERVICE_ORIGIN": "https://relic-wh2jw5fg2q-uc.a.run.app",
8
+ "RELIC_CLIENT_NAME": "relic-plugin/0.1.0"
9
+ }
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Publish a file as an encrypted relic. The key is generated on your machine and never sent to the service.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,17 +20,20 @@
20
20
  ],
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "git+https://github.com/TheBushidoCollective/artifacts.git",
23
+ "url": "git+https://github.com/TheBushidoCollective/relic.git",
24
24
  "directory": "packages/relic-mcp"
25
25
  },
26
- "homepage": "https://github.com/TheBushidoCollective/artifacts#readme",
26
+ "homepage": "https://github.com/TheBushidoCollective/relic#readme",
27
27
  "bugs": {
28
- "url": "https://github.com/TheBushidoCollective/artifacts/issues"
28
+ "url": "https://github.com/TheBushidoCollective/relic/issues"
29
29
  },
30
30
  "files": [
31
31
  "dist",
32
32
  "src",
33
- "README.md"
33
+ "README.md",
34
+ ".claude-plugin",
35
+ "skills",
36
+ "mcp-servers.json"
34
37
  ],
35
38
  "publishConfig": {
36
39
  "access": "public",
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: relic
3
+ description: Publish a local file as an encrypted, shareable link when someone outside this session needs to see it. Use when the user says "share this", "send this to X", "publish this", "give me a link for this", "make this shareable", or has just been handed a generated report, HTML page, deck, image, or export and needs it somewhere a person can open. Also covers what the recipient sees, how long a link lives, and what the service can and cannot read.
4
+ ---
5
+
6
+ # Relic
7
+
8
+ Turn a file on this machine into a URL you can hand to a person.
9
+
10
+ The file is encrypted here, before anything is uploaded. Only ciphertext
11
+ reaches the service. The key lives in the URL fragment, which browsers never
12
+ send to a server, so the operator holds bytes they cannot open.
13
+
14
+ ## Publishing
15
+
16
+ Call `relic_publish` with a filesystem path:
17
+
18
+ ```
19
+ relic_publish(path: "/Users/me/Downloads/report.html")
20
+ ```
21
+
22
+ It takes a **path, not content**. That is deliberate: the plaintext never
23
+ enters the conversation, so it is never in the transcript, never in a model
24
+ context window, and never in whatever stores those. Do not read a file into
25
+ context and pass its text; pass where it lives.
26
+
27
+ Optional arguments worth knowing:
28
+
29
+ - `filename` overrides the display name shown to the recipient.
30
+ - `ttl_days` shortens the life of the link. Shorter is better for anything
31
+ sensitive; the default is the service maximum.
32
+
33
+ ## Say this when you hand over the link
34
+
35
+ **The key is in the URL, and the URL is now in the transcript.** Anyone with
36
+ this conversation can open the file. That is structural, not a bug being fixed
37
+ later: returning a usable link is the product, and a usable link contains the
38
+ key.
39
+
40
+ So the honest framing for the user is: zero-knowledge holds against whoever
41
+ runs Relic. It does not hold against their model provider, or anyone who can
42
+ read their session history. If the content should not be in a transcript at
43
+ all, it should not go through an agent.
44
+
45
+ Also worth one line, unprompted, the first time in a session:
46
+
47
+ - links expire (the tool returns the exact date)
48
+ - opens are capped, and the tool's mint response reports how many remain
49
+ - anyone with the link can read it; there are no per-recipient permissions
50
+
51
+ ## What the recipient gets
52
+
53
+ A page that fetches the ciphertext, decrypts it in their browser, and renders
54
+ by type. Markdown, code, images, and plain text render inline. HTML renders in
55
+ a sandboxed frame on a separate origin, so a published page cannot reach the
56
+ key or the service. Anything else offers a download.
57
+
58
+ They need the whole URL including the `#...` part. A link truncated at the `#`
59
+ is a page that cannot decrypt anything, and that is the most common way sharing
60
+ fails: chat clients and ticket systems sometimes cut fragments.
61
+
62
+ ## When not to use it
63
+
64
+ - **Something that belongs in the repo.** Commit it. A relic expires; a commit
65
+ does not.
66
+ - **A client deliverable.** Those have a durable home, and a link that dies in
67
+ a week is not it. Publish a relic in addition if someone needs to look at it
68
+ now, never instead.
69
+ - **Credentials, keys, or tokens.** Encrypted in transit and at rest still ends
70
+ with a secret sitting in a URL in a transcript.
71
+
72
+ ## Checking what the client does
73
+
74
+ `relic_describe_client` returns the client's own account of what it uploads and
75
+ what it withholds, plus the service it is pointed at. Use it when the user asks
76
+ what is actually being sent, rather than paraphrasing this file. The published
77
+ source is one file and is deliberately unminified, so "read it yourself" is a
78
+ real answer.
package/src/index.ts CHANGED
@@ -17,18 +17,48 @@ import { createServer } from 'node:http';
17
17
  import { Readable } from 'node:stream';
18
18
  import { nodeFiles } from './files.ts';
19
19
  import { createHttpHandler } from './http.ts';
20
+ import { runInstall, USAGE } from './installer.ts';
21
+ import { requiredOrigin } from './origin.ts';
20
22
  import type { PublishDeps } from './publish.ts';
21
23
  import { serveStdio } from './server.ts';
22
24
 
25
+ // Subcommands are handled before anything that needs configuration, so
26
+ // `--help` works on a machine that has never set an origin.
27
+ const argv = process.argv.slice(2);
28
+
29
+ if (argv[0] === 'install') {
30
+ await runInstall(argv.slice(1));
31
+ process.exit(0);
32
+ }
33
+
34
+ if (argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
35
+ process.stdout.write(USAGE);
36
+ process.exit(0);
37
+ }
38
+
39
+ // The value travels with whatever installs this: the plugin sets it, and one
40
+ // plugin version bump moves every install. See origin.ts for why there is no
41
+ // default.
42
+ const serviceOrigin = requiredOrigin(
43
+ 'RELIC_SERVICE_ORIGIN',
44
+ process.env['RELIC_SERVICE_ORIGIN']
45
+ );
46
+
23
47
  const deps: PublishDeps = {
24
- serviceOrigin: process.env['RELIC_SERVICE_ORIGIN'] ?? 'https://relic.example',
48
+ serviceOrigin,
49
+ // Where the shareable link points, when a reverse proxy or custom domain
50
+ // fronts the API. Defaults to the API's own origin, which is the common case.
25
51
  relicOrigin:
26
- process.env['RELIC_ORIGIN'] ??
27
- process.env['RELIC_SERVICE_ORIGIN'] ??
28
- 'https://relic.example',
52
+ process.env['RELIC_ORIGIN'] === undefined
53
+ ? serviceOrigin
54
+ : requiredOrigin('RELIC_ORIGIN', process.env['RELIC_ORIGIN']),
29
55
  files: nodeFiles,
30
56
  fetch: globalThis.fetch,
31
- clientName: process.env['RELIC_CLIENT_NAME'] ?? 'relic-mcp/0.1.0',
57
+ // Recorded against the grant, so the service can tell what published. No
58
+ // version baked in: a literal here goes stale the first release nobody
59
+ // remembers to edit, and a wrong version in a log is worse than none.
60
+ // Whatever installs this can set the variable to something more specific.
61
+ clientName: process.env['RELIC_CLIENT_NAME'] ?? 'relic-mcp',
32
62
  };
33
63
 
34
64
  if (process.env['RELIC_MCP_HTTP'] === '1') {
package/src/install.ts ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Installing this server into whichever agent harness somebody actually uses.
3
+ *
4
+ * The server itself needs nothing per harness. MCP over stdio is already the
5
+ * portable layer, and every target below launches the same command with the
6
+ * same environment. What differs is only where the config lives and what the
7
+ * wrapper key is called, which is a packaging problem wearing an integration
8
+ * problem's clothes.
9
+ *
10
+ * So this file holds no protocol code. It computes an edit and hands it back.
11
+ * The pure `planFor` is what the tests exercise; touching the filesystem is a
12
+ * separate, small step, because a bug here corrupts somebody's editor config
13
+ * rather than failing a request.
14
+ */
15
+
16
+ export type Format = 'json-mcp-servers' | 'json-servers' | 'toml' | 'plugin';
17
+
18
+ export interface Harness {
19
+ readonly id: string;
20
+ readonly label: string;
21
+ readonly format: Format;
22
+ /** Relative to the user's home directory. */
23
+ readonly configPath: string;
24
+ }
25
+
26
+ /**
27
+ * The targets, with the config location each one actually reads.
28
+ *
29
+ * Deliberately a short list of things that were checked rather than a long
30
+ * list of things that sound right. A wrong path here does not error: it
31
+ * writes a file nobody reads, and the user concludes the tool is broken.
32
+ */
33
+ export const HARNESSES: readonly Harness[] = [
34
+ {
35
+ id: 'claude-code',
36
+ label: 'Claude Code',
37
+ format: 'plugin',
38
+ configPath: '',
39
+ },
40
+ {
41
+ id: 'claude-desktop',
42
+ label: 'Claude Desktop',
43
+ format: 'json-mcp-servers',
44
+ configPath: 'Library/Application Support/Claude/claude_desktop_config.json',
45
+ },
46
+ {
47
+ id: 'cursor',
48
+ label: 'Cursor',
49
+ format: 'json-mcp-servers',
50
+ configPath: '.cursor/mcp.json',
51
+ },
52
+ {
53
+ id: 'windsurf',
54
+ label: 'Windsurf',
55
+ format: 'json-mcp-servers',
56
+ configPath: '.codeium/windsurf/mcp_config.json',
57
+ },
58
+ {
59
+ id: 'gemini',
60
+ label: 'Gemini CLI',
61
+ format: 'json-mcp-servers',
62
+ configPath: '.gemini/settings.json',
63
+ },
64
+ {
65
+ id: 'vscode',
66
+ label: 'VS Code',
67
+ format: 'json-servers',
68
+ configPath: 'Library/Application Support/Code/User/mcp.json',
69
+ },
70
+ {
71
+ id: 'codex',
72
+ label: 'Codex',
73
+ format: 'toml',
74
+ configPath: '.codex/config.toml',
75
+ },
76
+ ];
77
+
78
+ export interface ServerSpec {
79
+ readonly name: string;
80
+ readonly command: string;
81
+ readonly args: readonly string[];
82
+ readonly env: Readonly<Record<string, string>>;
83
+ }
84
+
85
+ export interface Plan {
86
+ readonly harness: Harness;
87
+ /** The file to write, absent for harnesses driven by their own CLI. */
88
+ readonly path: string | undefined;
89
+ readonly contents: string;
90
+ /** True when an entry of this name was already there and got replaced. */
91
+ readonly replaced: boolean;
92
+ }
93
+
94
+ export class UnknownHarnessError extends Error {}
95
+ export class ExistingEntryError extends Error {}
96
+
97
+ /**
98
+ * Merge the server into whatever the harness already has.
99
+ *
100
+ * Merging rather than writing, always. These files hold every other server the
101
+ * user has configured, and clobbering them to add one entry would be a far
102
+ * worse bug than failing to install.
103
+ */
104
+ export function planFor(
105
+ harnessId: string,
106
+ spec: ServerSpec,
107
+ existing: string | undefined,
108
+ options: { readonly force?: boolean } = {}
109
+ ): Plan {
110
+ const harness = HARNESSES.find((h) => h.id === harnessId);
111
+ if (harness === undefined) {
112
+ throw new UnknownHarnessError(
113
+ `Unknown harness ${harnessId}. Known: ${HARNESSES.map((h) => h.id).join(', ')}`
114
+ );
115
+ }
116
+
117
+ if (harness.format === 'toml') {
118
+ return tomlPlan(harness, spec, existing, options);
119
+ }
120
+ return jsonPlan(harness, spec, existing, options);
121
+ }
122
+
123
+ function jsonPlan(
124
+ harness: Harness,
125
+ spec: ServerSpec,
126
+ existing: string | undefined,
127
+ options: { readonly force?: boolean }
128
+ ): Plan {
129
+ const key = harness.format === 'json-servers' ? 'servers' : 'mcpServers';
130
+
131
+ let root: Record<string, unknown> = {};
132
+ if (existing !== undefined && existing.trim().length > 0) {
133
+ try {
134
+ root = JSON.parse(existing) as Record<string, unknown>;
135
+ } catch (error) {
136
+ // Refuse rather than replace. An unparseable config is somebody's
137
+ // settings with a typo in it, not an empty slot.
138
+ throw new ExistingEntryError(
139
+ `${harness.configPath} is not valid JSON, so merging would destroy it: ` +
140
+ `${(error as Error).message}`
141
+ );
142
+ }
143
+ }
144
+
145
+ const servers = (root[key] ?? {}) as Record<string, unknown>;
146
+ const replaced = Object.hasOwn(servers, spec.name);
147
+ if (replaced && options.force !== true) {
148
+ throw new ExistingEntryError(
149
+ `${harness.label} already has a server named "${spec.name}". ` +
150
+ 'Pass --force to replace it.'
151
+ );
152
+ }
153
+
154
+ // VS Code names the launch mode explicitly; the others infer stdio.
155
+ const entry =
156
+ harness.format === 'json-servers'
157
+ ? { type: 'stdio', command: spec.command, args: spec.args, env: spec.env }
158
+ : { command: spec.command, args: spec.args, env: spec.env };
159
+
160
+ const merged = { ...root, [key]: { ...servers, [spec.name]: entry } };
161
+ return {
162
+ harness,
163
+ path: harness.configPath,
164
+ contents: `${JSON.stringify(merged, null, 2)}\n`,
165
+ replaced,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Codex keeps servers in TOML.
171
+ *
172
+ * Appending a table rather than reformatting the file, because a real config
173
+ * carries comments and ordering that a parse-and-reserialize round trip would
174
+ * quietly throw away. The only edit made is adding or replacing one table.
175
+ */
176
+ function tomlPlan(
177
+ harness: Harness,
178
+ spec: ServerSpec,
179
+ existing: string | undefined,
180
+ options: { readonly force?: boolean }
181
+ ): Plan {
182
+ const body = existing ?? '';
183
+ const header = `[mcp_servers.${spec.name}]`;
184
+ const replaced = body.includes(header);
185
+
186
+ if (replaced && options.force !== true) {
187
+ throw new ExistingEntryError(
188
+ `${harness.label} already has [mcp_servers.${spec.name}]. ` +
189
+ 'Pass --force to replace it.'
190
+ );
191
+ }
192
+
193
+ const table = [
194
+ header,
195
+ `command = ${tomlString(spec.command)}`,
196
+ `args = [${spec.args.map(tomlString).join(', ')}]`,
197
+ ...(Object.keys(spec.env).length > 0
198
+ ? [
199
+ `[mcp_servers.${spec.name}.env]`,
200
+ ...Object.entries(spec.env).map(
201
+ ([k, v]) => `${k} = ${tomlString(v)}`
202
+ ),
203
+ ]
204
+ : []),
205
+ ].join('\n');
206
+
207
+ const withoutOld = replaced ? dropTomlTable(body, spec.name) : body;
208
+ const separator =
209
+ withoutOld.length === 0 || withoutOld.endsWith('\n\n')
210
+ ? ''
211
+ : withoutOld.endsWith('\n')
212
+ ? '\n'
213
+ : '\n\n';
214
+
215
+ return {
216
+ harness,
217
+ path: harness.configPath,
218
+ contents: `${withoutOld}${separator}${table}\n`,
219
+ replaced,
220
+ };
221
+ }
222
+
223
+ /** Remove `[mcp_servers.<name>]` and its sub-tables, leaving the rest intact. */
224
+ function dropTomlTable(body: string, name: string): string {
225
+ const lines = body.split('\n');
226
+ const kept: string[] = [];
227
+ let skipping = false;
228
+
229
+ for (const line of lines) {
230
+ const isHeader = /^\s*\[/.test(line);
231
+ if (isHeader) {
232
+ skipping =
233
+ line.trim() === `[mcp_servers.${name}]` ||
234
+ line.trim().startsWith(`[mcp_servers.${name}.`);
235
+ }
236
+ if (!skipping) kept.push(line);
237
+ }
238
+
239
+ return `${kept
240
+ .join('\n')
241
+ .replace(/\n{3,}$/, '\n\n')
242
+ .trimEnd()}\n`;
243
+ }
244
+
245
+ function tomlString(value: string): string {
246
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
247
+ }
248
+
249
+ /** The snippet to paste, for any harness not listed above. */
250
+ export function snippetFor(spec: ServerSpec): string {
251
+ return `${JSON.stringify(
252
+ {
253
+ mcpServers: {
254
+ [spec.name]: { command: spec.command, args: spec.args, env: spec.env },
255
+ },
256
+ },
257
+ null,
258
+ 2
259
+ )}\n`;
260
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The `relic-mcp install` command.
3
+ *
4
+ * Everything that decides *what* to write lives in `install.ts` and is pure.
5
+ * This file is the part that touches the disk and talks to the user, kept thin
6
+ * on purpose: it is the half that can damage somebody's editor configuration,
7
+ * and the less logic it holds the less there is to get wrong.
8
+ */
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import {
16
+ ExistingEntryError,
17
+ HARNESSES,
18
+ planFor,
19
+ type ServerSpec,
20
+ snippetFor,
21
+ UnknownHarnessError,
22
+ } from './install.ts';
23
+ import { requiredOrigin } from './origin.ts';
24
+
25
+ export const USAGE = `relic-mcp - publish a file as an encrypted, shareable link
26
+
27
+ relic-mcp run the MCP server on stdio
28
+ relic-mcp install [options] add this server to an agent harness
29
+ relic-mcp --help this
30
+
31
+ Install options:
32
+ --client <id> ${HARNESSES.map((h) => h.id).join(', ')}
33
+ Omit to see which of these are installed here.
34
+ --origin <url> The Relic service to publish to. Falls back to
35
+ RELIC_SERVICE_ORIGIN.
36
+ --name <name> Server name in the config. Default: relic.
37
+ --print Write nothing; print the config to paste.
38
+ --force Replace an existing entry of the same name.
39
+ --dry-run Show the file and what would change, without writing.
40
+ `;
41
+
42
+ interface Options {
43
+ client: string | undefined;
44
+ origin: string | undefined;
45
+ name: string;
46
+ print: boolean;
47
+ force: boolean;
48
+ dryRun: boolean;
49
+ }
50
+
51
+ function parseArgs(argv: readonly string[]): Options {
52
+ const options: Options = {
53
+ client: undefined,
54
+ origin: undefined,
55
+ name: 'relic',
56
+ print: false,
57
+ force: false,
58
+ dryRun: false,
59
+ };
60
+
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const arg = argv[i];
63
+ const next = (): string => {
64
+ const value = argv[++i];
65
+ if (value === undefined) throw new Error(`${arg} needs a value`);
66
+ return value;
67
+ };
68
+
69
+ if (arg === '--client') options.client = next();
70
+ else if (arg === '--origin') options.origin = next();
71
+ else if (arg === '--name') options.name = next();
72
+ else if (arg === '--print') options.print = true;
73
+ else if (arg === '--force') options.force = true;
74
+ else if (arg === '--dry-run') options.dryRun = true;
75
+ else throw new Error(`Unknown option ${arg}`);
76
+ }
77
+
78
+ return options;
79
+ }
80
+
81
+ /** The directory of the installed package, which is also the plugin root. */
82
+ function packageRoot(): string {
83
+ // dist/relic-mcp.js -> the package directory.
84
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..');
85
+ }
86
+
87
+ async function exists(path: string): Promise<boolean> {
88
+ return readFile(path)
89
+ .then(() => true)
90
+ .catch(() => false);
91
+ }
92
+
93
+ export async function runInstall(argv: readonly string[]): Promise<void> {
94
+ let options: Options;
95
+ try {
96
+ options = parseArgs(argv);
97
+ } catch (error) {
98
+ process.stderr.write(`${(error as Error).message}\n\n${USAGE}`);
99
+ process.exit(2);
100
+ }
101
+
102
+ const spec: ServerSpec = {
103
+ name: options.name,
104
+ command: 'npx',
105
+ args: ['-y', 'relic-mcp'],
106
+ env: {},
107
+ };
108
+
109
+ if (options.print && options.client === undefined) {
110
+ process.stdout.write(snippetFor(withOrigin(spec, options)));
111
+ return;
112
+ }
113
+
114
+ if (options.client === undefined) {
115
+ await reportDetected();
116
+ return;
117
+ }
118
+
119
+ const resolved = withOrigin(spec, options);
120
+
121
+ if (options.client === 'claude-code') {
122
+ await installClaudeCode(options);
123
+ return;
124
+ }
125
+
126
+ const home = homedir();
127
+ const harness = HARNESSES.find((h) => h.id === options.client);
128
+ if (harness === undefined) {
129
+ process.stderr.write(
130
+ `Unknown client ${options.client}. Known: ` +
131
+ `${HARNESSES.map((h) => h.id).join(', ')}\n`
132
+ );
133
+ process.exit(2);
134
+ }
135
+
136
+ const path = join(home, harness.configPath);
137
+ const existing = await readFile(path, 'utf8').catch(() => undefined);
138
+
139
+ let plan: ReturnType<typeof planFor>;
140
+ try {
141
+ plan = planFor(options.client, resolved, existing, {
142
+ force: options.force,
143
+ });
144
+ } catch (error) {
145
+ if (
146
+ error instanceof ExistingEntryError ||
147
+ error instanceof UnknownHarnessError
148
+ ) {
149
+ process.stderr.write(`${error.message}\n`);
150
+ process.exit(1);
151
+ }
152
+ throw error;
153
+ }
154
+
155
+ if (options.print) {
156
+ process.stdout.write(plan.contents);
157
+ return;
158
+ }
159
+
160
+ if (options.dryRun) {
161
+ process.stdout.write(
162
+ `Would ${plan.replaced ? 'replace' : 'add'} "${resolved.name}" in ${path}\n`
163
+ );
164
+ return;
165
+ }
166
+
167
+ // Back up before touching a file this tool did not create. Cheap, and the
168
+ // difference between an annoying mistake and a lost configuration.
169
+ if (existing !== undefined) {
170
+ await copyFile(path, `${path}.relic-backup`);
171
+ }
172
+
173
+ await mkdir(dirname(path), { recursive: true });
174
+ await writeFile(path, plan.contents);
175
+
176
+ process.stdout.write(
177
+ `${plan.replaced ? 'Replaced' : 'Added'} "${resolved.name}" in ${path}\n` +
178
+ (existing === undefined
179
+ ? ''
180
+ : `Previous config saved to ${path}.relic-backup\n`) +
181
+ `Restart ${harness.label} to pick it up.\n`
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Claude Code installs as a plugin rather than a bare server, because the
187
+ * plugin also carries the skill that tells the agent when publishing is the
188
+ * right move. The package ships the manifests, so the marketplace source is
189
+ * this directory on disk and no clone is involved.
190
+ */
191
+ async function installClaudeCode(options: Options): Promise<void> {
192
+ const root = packageRoot();
193
+
194
+ if (!(await exists(join(root, '.claude-plugin', 'marketplace.json')))) {
195
+ process.stderr.write(
196
+ 'This copy of relic-mcp does not carry the plugin manifests, so it ' +
197
+ 'cannot be installed as a Claude Code plugin. Use --print and add ' +
198
+ 'the server manually, or install a newer version.\n'
199
+ );
200
+ process.exit(1);
201
+ }
202
+
203
+ if (options.dryRun) {
204
+ process.stdout.write(
205
+ `Would add marketplace ${root} and install relic@relic\n`
206
+ );
207
+ return;
208
+ }
209
+
210
+ for (const args of [
211
+ ['plugin', 'marketplace', 'add', root],
212
+ ['plugin', 'install', 'relic@relic'],
213
+ ]) {
214
+ const result = spawnSync('claude', args, { stdio: 'inherit' });
215
+ if (result.error !== undefined || result.status !== 0) {
216
+ process.stderr.write(
217
+ `\nclaude ${args.join(' ')} failed. Is the Claude Code CLI on PATH?\n`
218
+ );
219
+ process.exit(1);
220
+ }
221
+ }
222
+
223
+ process.stdout.write('\nRestart Claude Code to pick it up.\n');
224
+ }
225
+
226
+ function withOrigin(spec: ServerSpec, options: Options): ServerSpec {
227
+ const raw = options.origin ?? process.env['RELIC_SERVICE_ORIGIN'];
228
+ let origin: string;
229
+ try {
230
+ origin = requiredOrigin('--origin', raw);
231
+ } catch (error) {
232
+ process.stderr.write(
233
+ `${(error as Error).message}\n\nPass --origin https://your-relic-service\n`
234
+ );
235
+ process.exit(2);
236
+ }
237
+ return { ...spec, env: { RELIC_SERVICE_ORIGIN: origin } };
238
+ }
239
+
240
+ /** What is actually on this machine, so the next command is obvious. */
241
+ async function reportDetected(): Promise<void> {
242
+ const home = homedir();
243
+ const lines: string[] = ['Harnesses detected here:', ''];
244
+
245
+ for (const harness of HARNESSES) {
246
+ if (harness.format === 'plugin') {
247
+ const found = spawnSync('claude', ['--version'], { stdio: 'ignore' });
248
+ lines.push(
249
+ ` ${found.status === 0 ? '*' : ' '} ${harness.id.padEnd(15)}${harness.label}`
250
+ );
251
+ continue;
252
+ }
253
+ // The directory rather than the file: a harness that has never had an MCP
254
+ // server configured has no config file yet, and is still installed.
255
+ const dir = dirname(join(home, harness.configPath));
256
+ const found = await stat(dir)
257
+ .then((info) => info.isDirectory())
258
+ .catch(() => false);
259
+ lines.push(
260
+ ` ${found ? '*' : ' '} ${harness.id.padEnd(15)}${harness.label}`
261
+ );
262
+ }
263
+
264
+ lines.push('', 'Install with:', ' relic-mcp install --client <id>', '');
265
+ process.stdout.write(lines.join('\n'));
266
+ }
package/src/origin.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reading the service origin out of the environment.
3
+ *
4
+ * Its own module because the entry point runs a server as a side effect of
5
+ * being imported, and a rule this easy to get wrong deserves tests that do not
6
+ * have to start one.
7
+ */
8
+
9
+ /**
10
+ * Require an origin, or explain what is missing.
11
+ *
12
+ * There is deliberately no default. A placeholder would turn "you did not
13
+ * configure me" into a DNS failure on the first publish, which is a worse
14
+ * message arriving later, and a real origin baked into a published tarball
15
+ * would outlive whatever address the service actually has.
16
+ *
17
+ * Returns the origin only. A path, query, or fragment in the variable is
18
+ * dropped rather than quietly concatenated into every request URL.
19
+ */
20
+ export function requiredOrigin(name: string, raw: string | undefined): string {
21
+ if (raw === undefined || raw.trim().length === 0) {
22
+ throw new Error(
23
+ `${name} is not set. It is the Relic service this client publishes to, ` +
24
+ 'for example https://relic.example.com. Installing the Relic plugin ' +
25
+ 'sets it for you; set it yourself when running this server directly.'
26
+ );
27
+ }
28
+
29
+ let parsed: URL;
30
+ try {
31
+ parsed = new URL(raw.trim());
32
+ } catch {
33
+ throw new Error(`${name} is not a URL: ${raw}`);
34
+ }
35
+
36
+ // http is allowed only against a loopback host, where there is no network
37
+ // path to sit on. Plaintext never leaves this machine either way, but the
38
+ // grant authorizing an upload does, and over http anyone between here and
39
+ // the service can take it and spend it.
40
+ const loopback =
41
+ parsed.hostname === 'localhost' ||
42
+ parsed.hostname === '127.0.0.1' ||
43
+ parsed.hostname === '[::1]';
44
+
45
+ if (parsed.protocol !== 'https:' && !loopback) {
46
+ throw new Error(
47
+ `${name} must be https, or a loopback host for development. Got ${raw}. ` +
48
+ 'Plaintext never leaves this machine, but the grant that authorizes ' +
49
+ 'an upload does, and over http anyone on the path can take it.'
50
+ );
51
+ }
52
+
53
+ return parsed.origin;
54
+ }