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.
- package/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +21 -0
- package/README.md +1 -1
- package/dist/relic-mcp.js +377 -3
- package/mcp-servers.json +11 -0
- package/package.json +8 -5
- package/skills/relic/SKILL.md +78 -0
- package/src/index.ts +35 -5
- package/src/install.ts +260 -0
- package/src/installer.ts +266 -0
- package/src/origin.ts +54 -0
|
@@ -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
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
|
|
1048
|
-
relicOrigin: process.env["RELIC_ORIGIN"]
|
|
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
|
|
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);
|
package/mcp-servers.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relic-mcp",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
23
|
+
"url": "git+https://github.com/TheBushidoCollective/relic.git",
|
|
24
24
|
"directory": "packages/relic-mcp"
|
|
25
25
|
},
|
|
26
|
-
"homepage": "https://github.com/TheBushidoCollective/
|
|
26
|
+
"homepage": "https://github.com/TheBushidoCollective/relic#readme",
|
|
27
27
|
"bugs": {
|
|
28
|
-
"url": "https://github.com/TheBushidoCollective/
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/installer.ts
ADDED
|
@@ -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
|
+
}
|