relic-mcp 0.3.0 → 0.3.2

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.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "relic",
12
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.3.0",
13
+ "version": "0.3.2",
14
14
  "source": "./",
15
15
  "author": {
16
16
  "name": "The Bushido Collective",
@@ -21,6 +21,6 @@
21
21
  }
22
22
  ],
23
23
  "metadata": {
24
- "version": "0.3.0"
24
+ "version": "0.3.2"
25
25
  }
26
26
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
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
5
  "mcpServers": "./mcp-servers.json",
6
6
  "author": {
package/dist/relic-mcp.js CHANGED
@@ -642,9 +642,19 @@ function deriveRendererClass(content, filename) {
642
642
  return "binary";
643
643
  }
644
644
  // src/state.ts
645
- import { mkdir, readFile as readFile2, rename, stat, writeFile } from "node:fs/promises";
645
+ import { execFile } from "node:child_process";
646
+ import {
647
+ mkdir,
648
+ readFile as readFile2,
649
+ realpath,
650
+ rename,
651
+ stat,
652
+ writeFile
653
+ } from "node:fs/promises";
646
654
  import { homedir } from "node:os";
647
- import { dirname, join } from "node:path";
655
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
656
+ import { fileURLToPath } from "node:url";
657
+ import { promisify } from "node:util";
648
658
  function publishStatePath() {
649
659
  const override = process.env["RELIC_PUBLISH_STATE"];
650
660
  if (override !== undefined && override.length > 0)
@@ -652,27 +662,137 @@ function publishStatePath() {
652
662
  const configRoot = process.env["XDG_CONFIG_HOME"] !== undefined && process.env["XDG_CONFIG_HOME"].length > 0 ? process.env["XDG_CONFIG_HOME"] : join(homedir(), ".config");
653
663
  return join(configRoot, "relic-mcp", "publish-state.json");
654
664
  }
655
- var writeChain = Promise.resolve();
656
- async function loadPublishState(relicId) {
657
- let parsed;
665
+ var runFile = promisify(execFile);
666
+ function normalizeGitRemote(remote) {
667
+ const value = remote.trim().replace(/^git\+/i, "");
658
668
  try {
659
- parsed = JSON.parse(await readFile2(publishStatePath(), "utf8"));
660
- } catch (error) {
661
- const code = error.code;
662
- if (code === "ENOENT")
663
- return;
664
- if (error instanceof SyntaxError) {
665
- throw new Error(`publish state at ${publishStatePath()} is not valid JSON. It cannot ` + "be read, so relics recorded in it cannot be republished from this " + "machine until it is fixed or removed.");
669
+ const url = new URL(value);
670
+ if (url.protocol === "file:") {
671
+ return fileURLToPath(url).replaceAll("\\", "/").replace(/\/+$/g, "").replace(/\.git$/i, "");
672
+ }
673
+ if (url.hostname.length > 0) {
674
+ const host = url.port.length > 0 ? `${url.hostname}:${url.port}` : url.hostname;
675
+ const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
676
+ return `${host}/${path}`.toLowerCase();
677
+ }
678
+ } catch {}
679
+ const scp = value.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/);
680
+ if (scp?.[1] !== undefined && scp[2] !== undefined) {
681
+ const path = scp[2].replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
682
+ return `${scp[1]}/${path}`.toLowerCase();
683
+ }
684
+ return value.replaceAll("\\", "/").replace(/\/+$/g, "").replace(/\.git$/i, "");
685
+ }
686
+ async function gitOutput(directory, args) {
687
+ try {
688
+ const { stdout } = await runFile("git", ["-C", directory, ...args], {
689
+ encoding: "utf8",
690
+ maxBuffer: 1024 * 1024
691
+ });
692
+ const output = String(stdout).trim();
693
+ return output.length === 0 ? undefined : output;
694
+ } catch {
695
+ return;
696
+ }
697
+ }
698
+ async function resolveSourceIdentity(path) {
699
+ const absolutePath = resolve(path);
700
+ const sourceDirectory = dirname(absolutePath);
701
+ const repositoryRoot = await gitOutput(sourceDirectory, [
702
+ "rev-parse",
703
+ "--show-toplevel"
704
+ ]);
705
+ if (repositoryRoot !== undefined) {
706
+ const repositoryPrefix = await gitOutput(sourceDirectory, [
707
+ "rev-parse",
708
+ "--show-prefix"
709
+ ]);
710
+ const repositoryPath = `${repositoryPrefix ?? ""}${basename(absolutePath)}`.replaceAll("\\", "/");
711
+ const remoteNames = (await gitOutput(repositoryRoot, ["remote"]) ?? "").split(/\r?\n/).filter((name) => name.length > 0).sort();
712
+ const remoteName = remoteNames.includes("origin") ? "origin" : remoteNames[0];
713
+ if (remoteName !== undefined) {
714
+ const remote = await gitOutput(repositoryRoot, [
715
+ "remote",
716
+ "get-url",
717
+ remoteName
718
+ ]);
719
+ if (remote !== undefined) {
720
+ const repository = normalizeGitRemote(remote);
721
+ return {
722
+ identity: `git-remote:${encodeURIComponent(repository)}:` + encodeURIComponent(repositoryPath),
723
+ kind: "git_remote",
724
+ path: repositoryPath,
725
+ repository,
726
+ description: `${repositoryPath} in ${repository}`
727
+ };
728
+ }
729
+ }
730
+ const commonDirectory = await gitOutput(repositoryRoot, [
731
+ "rev-parse",
732
+ "--git-common-dir"
733
+ ]);
734
+ if (commonDirectory !== undefined) {
735
+ const repository = await realpath(isAbsolute(commonDirectory) ? commonDirectory : resolve(repositoryRoot, commonDirectory));
736
+ return {
737
+ identity: `git-common-dir:${encodeURIComponent(repository)}:` + encodeURIComponent(repositoryPath),
738
+ kind: "git_common_dir",
739
+ path: repositoryPath,
740
+ repository,
741
+ description: `${repositoryPath} in Git repository ${repository}`
742
+ };
666
743
  }
667
- throw new Error(`could not read publish state at ${publishStatePath()}: ${code}`);
668
744
  }
669
- const entry = parsed?.relics?.[relicId];
745
+ const canonicalPath = await realpath(absolutePath);
746
+ return {
747
+ identity: `realpath:${encodeURIComponent(canonicalPath)}`,
748
+ kind: "realpath",
749
+ path: canonicalPath,
750
+ description: canonicalPath
751
+ };
752
+ }
753
+ var writeChain = Promise.resolve();
754
+ async function loadPublishState(relicId) {
755
+ const parsed = await readWholeFile(publishStatePath());
756
+ return validatePublishStateEntry(parsed?.relics?.[relicId], relicId);
757
+ }
758
+ function isSourceIdentity(value) {
759
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
760
+ return false;
761
+ }
762
+ const source = value;
763
+ return typeof source["identity"] === "string" && (source["kind"] === "git_remote" || source["kind"] === "git_common_dir" || source["kind"] === "realpath") && typeof source["path"] === "string" && (source["repository"] === undefined || typeof source["repository"] === "string") && typeof source["description"] === "string";
764
+ }
765
+ function validatePublishStateEntry(entry, relicId) {
670
766
  if (entry === undefined)
671
767
  return;
672
- if (typeof entry !== "object" || typeof entry.key !== "string" || typeof entry.publish_token !== "string" || typeof entry.version !== "number" || !Number.isSafeInteger(entry.version) || entry.version < 1) {
673
- throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` + "this relic id.");
768
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
769
+ throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` + `relic ${relicId}.`);
674
770
  }
675
- return entry;
771
+ const candidate = entry;
772
+ if (typeof candidate["key"] !== "string" || typeof candidate["publish_token"] !== "string" || typeof candidate["version"] !== "number" || !Number.isSafeInteger(candidate["version"]) || candidate["version"] < 1 || candidate["source"] !== undefined && !isSourceIdentity(candidate["source"])) {
773
+ throw new Error(`publish state at ${publishStatePath()} holds a malformed entry for ` + `relic ${relicId}.`);
774
+ }
775
+ return candidate;
776
+ }
777
+ async function loadPublishedSource(source) {
778
+ const parsed = await readWholeFile(publishStatePath());
779
+ const sources = parsed?.sources;
780
+ if (sources === undefined)
781
+ return;
782
+ if (sources === null || typeof sources !== "object" || Array.isArray(sources)) {
783
+ throw new Error(`publish state at ${publishStatePath()} holds a malformed source index.`);
784
+ }
785
+ const relicId = sources[source.identity];
786
+ if (relicId === undefined)
787
+ return;
788
+ if (typeof relicId !== "string" || relicId.length === 0) {
789
+ throw new Error(`publish state at ${publishStatePath()} holds a malformed source match.`);
790
+ }
791
+ const state = validatePublishStateEntry(parsed?.relics?.[relicId], relicId);
792
+ if (state?.source?.identity !== source.identity) {
793
+ throw new Error(`publish state at ${publishStatePath()} holds a source match without ` + "its relic entry.");
794
+ }
795
+ return { relic_id: relicId, state, source: state.source };
676
796
  }
677
797
  async function savePublishState(relicId, state) {
678
798
  const run = writeChain.then(() => writeEntry(relicId, state));
@@ -686,9 +806,33 @@ async function writeEntry(relicId, state) {
686
806
  const dir = dirname(path);
687
807
  await mkdir(dir, { mode: 448, recursive: true });
688
808
  const existing = await readWholeFile(path);
689
- const relics = { ...existing?.relics, [relicId]: state };
809
+ const storedRelics = existing?.["relics"];
810
+ if (storedRelics !== undefined && (storedRelics === null || typeof storedRelics !== "object" || Array.isArray(storedRelics))) {
811
+ throw new Error(`publish state at ${path} holds malformed relic entries.`);
812
+ }
813
+ const oldEntry = storedRelics?.[relicId];
814
+ const relics = {
815
+ ...storedRelics,
816
+ [relicId]: {
817
+ ...oldEntry !== null && typeof oldEntry === "object" && !Array.isArray(oldEntry) ? oldEntry : {},
818
+ ...state
819
+ }
820
+ };
821
+ const storedSources = existing?.["sources"];
822
+ if (storedSources !== undefined && (storedSources === null || typeof storedSources !== "object" || Array.isArray(storedSources))) {
823
+ throw new Error(`publish state at ${path} holds a malformed source index.`);
824
+ }
825
+ const sources = {
826
+ ...storedSources,
827
+ ...state.source === undefined ? {} : { [state.source.identity]: relicId }
828
+ };
829
+ const next = {
830
+ ...existing,
831
+ relics,
832
+ ...storedSources === undefined && state.source === undefined ? {} : { sources }
833
+ };
690
834
  const temp = `${path}.tmp`;
691
- await writeFile(temp, `${JSON.stringify({ relics }, null, 2)}
835
+ await writeFile(temp, `${JSON.stringify(next, null, 2)}
692
836
  `, {
693
837
  mode: 384
694
838
  });
@@ -706,7 +850,7 @@ async function readWholeFile(path) {
706
850
  }
707
851
  try {
708
852
  const parsed = JSON.parse(raw);
709
- if (parsed === null || typeof parsed !== "object") {
853
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
710
854
  throw new Error("not an object");
711
855
  }
712
856
  return parsed;
@@ -739,8 +883,56 @@ class ServerRefusal extends Error {
739
883
  this.problem = problem;
740
884
  }
741
885
  }
886
+ function republishToolCall(relicId, path) {
887
+ return {
888
+ name: "relic_republish",
889
+ arguments: { relic_id: relicId, path }
890
+ };
891
+ }
892
+ async function lookupResolvedSource(resolvedPath, deps) {
893
+ let source;
894
+ try {
895
+ source = await (deps.identifySource ?? resolveSourceIdentity)(resolvedPath);
896
+ } catch (error) {
897
+ const code = error.code;
898
+ throw new PublishError(code === "ENOENT" ? "source_not_found" : "source_unreadable", `could not resolve source identity for ${resolvedPath}: ` + error.message, { path: resolvedPath });
899
+ }
900
+ try {
901
+ const published = await loadPublishedSource(source);
902
+ return {
903
+ resolved_path: resolvedPath,
904
+ source,
905
+ ...published === undefined ? {} : {
906
+ match: {
907
+ relic_id: published.relic_id,
908
+ version: published.state.version,
909
+ source: published.source
910
+ }
911
+ }
912
+ };
913
+ } catch (error) {
914
+ throw new PublishError("local_state_unreadable", error.message);
915
+ }
916
+ }
917
+ async function lookupPublishedSource(path, deps) {
918
+ return lookupResolvedSource(deps.files.resolve(path), deps);
919
+ }
742
920
  async function publish(input, deps) {
743
921
  const source = await readSource(input.path, deps.files);
922
+ const sourceLookup = await lookupResolvedSource(source.resolvedPath, deps);
923
+ if (sourceLookup.match !== undefined && input.force_new !== true) {
924
+ const match = sourceLookup.match;
925
+ const republishCall = republishToolCall(match.relic_id, source.resolvedPath);
926
+ const cost = "a second URL that nobody holding the first one will ever see";
927
+ throw new PublishError("source_already_published", `${match.source.description} is already version ${match.version} of ` + `relic ${match.relic_id}. Publishing it as new would cost ${cost}. ` + `Call relic_republish(${JSON.stringify(republishCall.arguments)}) ` + "instead. Set force_new to true only when you intend a separate relic.", {
928
+ relic_id: match.relic_id,
929
+ version: match.version,
930
+ source_identity: match.source.identity,
931
+ source_description: match.source.description,
932
+ cost,
933
+ republish_call: republishCall
934
+ });
935
+ }
744
936
  const challenge = await postJson(deps, `${deps.serviceOrigin}/api/challenge`, {});
745
937
  const sizeLimit = Number(challenge["size_limit_bytes"]);
746
938
  const sizeBasis = String(challenge["size_basis"]);
@@ -793,7 +985,8 @@ async function publish(input, deps) {
793
985
  await savePublishState(relicId, {
794
986
  key: encodeKey(key),
795
987
  publish_token: publishToken,
796
- version: 1
988
+ version: 1,
989
+ source: sourceLookup.source
797
990
  });
798
991
  } catch (error) {
799
992
  throw new PublishError("local_state_write_failed", "the relic is published and the link works, but recording it for " + `later republishing failed, so it cannot be republished from this ` + `machine: ${error.message}`, { relic_id: relicId, url });
@@ -968,11 +1161,12 @@ async function republish(input, deps) {
968
1161
  var TOOL_NAME = "relic_publish";
969
1162
  var DESCRIBE_TOOL_NAME = "relic_describe_client";
970
1163
  var REPUBLISH_TOOL_NAME = "relic_republish";
1164
+ var LOOKUP_TOOL_NAME = "relic_lookup_source";
971
1165
  var MAX_TTL_DAYS = 3650;
972
1166
  var TOOL_DEFINITION = {
973
1167
  name: TOOL_NAME,
974
1168
  title: "Publish a relic",
975
- description: "Encrypt a file on this machine and publish it as a relic, returning a " + "shareable URL. The encryption key is generated locally and never sent " + "to the service. Takes a filesystem path, never inline content.",
1169
+ description: "Encrypt a file on this machine and publish it as a new relic, returning " + "a shareable URL. Publishing an update this way costs a second URL that " + "nobody holding the first one will ever see; use relic_republish instead " + "so the existing URL keeps working. The encryption key is generated " + "locally and never sent to the service. Takes a filesystem path, never " + "inline content.",
976
1170
  inputSchema: {
977
1171
  type: "object",
978
1172
  properties: {
@@ -989,6 +1183,11 @@ var TOOL_DEFINITION = {
989
1183
  minimum: 1,
990
1184
  maximum: MAX_TTL_DAYS,
991
1185
  description: "Optional. Gives the relic a lifetime in days. Omit it and the " + "relic is kept until it is deleted. Shorter is better for " + "sensitive content."
1186
+ },
1187
+ force_new: {
1188
+ type: "boolean",
1189
+ default: false,
1190
+ description: "Optional. Publish a deliberately separate relic even when this " + "machine already published the same source. Defaults to false. Use " + "only when you want two independent URLs for one file."
992
1191
  }
993
1192
  },
994
1193
  required: ["path"],
@@ -1083,6 +1282,60 @@ var REPUBLISH_TOOL_DEFINITION = {
1083
1282
  additionalProperties: false
1084
1283
  }
1085
1284
  };
1285
+ var LOOKUP_TOOL_DEFINITION = {
1286
+ name: LOOKUP_TOOL_NAME,
1287
+ title: "Look up a published source",
1288
+ description: "Look up whether this machine already published a file and return the " + "relic id needed by relic_republish. Reads local publish state only and " + "never calls the service.",
1289
+ inputSchema: {
1290
+ type: "object",
1291
+ properties: {
1292
+ path: {
1293
+ type: "string",
1294
+ description: "Filesystem path to the source to look up."
1295
+ }
1296
+ },
1297
+ required: ["path"],
1298
+ additionalProperties: false
1299
+ },
1300
+ outputSchema: {
1301
+ type: "object",
1302
+ properties: {
1303
+ found: { type: "boolean" },
1304
+ relic_id: { type: ["string", "null"] },
1305
+ version: { type: ["integer", "null"], minimum: 1 },
1306
+ resolved_path: { type: "string" },
1307
+ source_identity: { type: "string" },
1308
+ source_description: { type: "string" },
1309
+ republish_call: {
1310
+ type: ["object", "null"],
1311
+ properties: {
1312
+ name: { type: "string", const: REPUBLISH_TOOL_NAME },
1313
+ arguments: {
1314
+ type: "object",
1315
+ properties: {
1316
+ relic_id: { type: "string" },
1317
+ path: { type: "string" }
1318
+ },
1319
+ required: ["relic_id", "path"],
1320
+ additionalProperties: false
1321
+ }
1322
+ },
1323
+ required: ["name", "arguments"],
1324
+ additionalProperties: false
1325
+ }
1326
+ },
1327
+ required: [
1328
+ "found",
1329
+ "relic_id",
1330
+ "version",
1331
+ "resolved_path",
1332
+ "source_identity",
1333
+ "source_description",
1334
+ "republish_call"
1335
+ ],
1336
+ additionalProperties: false
1337
+ }
1338
+ };
1086
1339
  var DESCRIBE_TOOL_DEFINITION = {
1087
1340
  name: DESCRIBE_TOOL_NAME,
1088
1341
  title: "Describe the Relic client",
@@ -1093,13 +1346,22 @@ var DESCRIBE_TOOL_DEFINITION = {
1093
1346
  additionalProperties: false
1094
1347
  }
1095
1348
  };
1096
- var SERVER_VERSION = "0.3.0";
1349
+ var SERVER_VERSION = "0.3.2";
1097
1350
  var SERVER_INFO = {
1098
1351
  name: "relic",
1099
1352
  title: "Relic",
1100
1353
  version: SERVER_VERSION
1101
1354
  };
1102
1355
  var CAPABILITIES = { tools: {} };
1356
+ var INSTRUCTIONS = `Relic encrypts a file on this machine and uploads only ciphertext. The key lives in the URL fragment, which browsers never send to a server.
1357
+
1358
+ Five things that change how you should act:
1359
+
1360
+ 1. The link is the credential. Anyone holding it, fragment included, can read the file. Do not paste it into a tracker, a log, or a public channel.
1361
+ 2. Publishing puts the key in this transcript. That is structural rather than a defect, and worth saying plainly when you hand the link over.
1362
+ 3. If a source was published before, use relic_republish so its URL keeps working. Publishing it as new costs a second URL that nobody holding the first one will ever see. relic_publish refuses by default, relic_lookup_source recovers the id, and force_new is only for a deliberate second link.
1363
+ 4. A relic can be republished only from the machine that published it, which is where its key and publish token are stored. Anywhere else it refuses, and no retry changes that.
1364
+ 5. Rendered HTML and JSX run in an isolated frame with no network access. Inline the styles, scripts, fonts, and images a page needs, because a CDN reference renders as nothing. Decide that before you write the file.`;
1103
1365
  async function handleMessage(message, deps) {
1104
1366
  if (message.id === undefined)
1105
1367
  return;
@@ -1117,7 +1379,8 @@ async function handleMessage(message, deps) {
1117
1379
  result: {
1118
1380
  protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
1119
1381
  capabilities: CAPABILITIES,
1120
- serverInfo: SERVER_INFO
1382
+ serverInfo: SERVER_INFO,
1383
+ instructions: INSTRUCTIONS
1121
1384
  }
1122
1385
  };
1123
1386
  case "initialize": {
@@ -1128,7 +1391,8 @@ async function handleMessage(message, deps) {
1128
1391
  result: {
1129
1392
  protocolVersion: isSupportedVersion(asked) ? asked : PROTOCOL_VERSION,
1130
1393
  capabilities: CAPABILITIES,
1131
- serverInfo: SERVER_INFO
1394
+ serverInfo: SERVER_INFO,
1395
+ instructions: INSTRUCTIONS
1132
1396
  }
1133
1397
  };
1134
1398
  }
@@ -1141,6 +1405,7 @@ async function handleMessage(message, deps) {
1141
1405
  result: {
1142
1406
  tools: [
1143
1407
  TOOL_DEFINITION,
1408
+ LOOKUP_TOOL_DEFINITION,
1144
1409
  REPUBLISH_TOOL_DEFINITION,
1145
1410
  DESCRIBE_TOOL_DEFINITION
1146
1411
  ]
@@ -1171,13 +1436,16 @@ async function callTool(id2, params, deps) {
1171
1436
  key_transmitted_to_service: false,
1172
1437
  plaintext_transmitted_to_service: false,
1173
1438
  ciphertext_destination: "object storage, via a signed URL",
1174
- local_publish_state: "relic id, key, and publish token per relic, written 0600 under " + "the user config directory; never printed, never sent",
1439
+ local_publish_state: "relic id, source identity, key, and publish token per relic, " + "written 0600 under the user config directory; key and token " + "are never printed or sent",
1175
1440
  service_origin: deps.serviceOrigin
1176
1441
  },
1177
1442
  isError: false
1178
1443
  }
1179
1444
  };
1180
1445
  }
1446
+ if (params["name"] === LOOKUP_TOOL_NAME) {
1447
+ return callLookup(id2, params, deps);
1448
+ }
1181
1449
  if (params["name"] === REPUBLISH_TOOL_NAME) {
1182
1450
  return callRepublish(id2, params, deps);
1183
1451
  }
@@ -1194,8 +1462,17 @@ async function callTool(id2, params, deps) {
1194
1462
  if (!ttlDays.ok) {
1195
1463
  return errorResponse(id2, ERROR_CODES.invalidParams, `\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` + "omitted to keep the relic until it is deleted");
1196
1464
  }
1465
+ const forceNew = args["force_new"];
1466
+ if (forceNew !== undefined && typeof forceNew !== "boolean") {
1467
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`force_new` must be a boolean or omitted");
1468
+ }
1197
1469
  try {
1198
- const result = await publish({ path, filename, ttl_days: ttlDays.days }, deps);
1470
+ const result = await publish({
1471
+ path,
1472
+ filename,
1473
+ ttl_days: ttlDays.days,
1474
+ force_new: forceNew === true
1475
+ }, deps);
1199
1476
  return {
1200
1477
  jsonrpc: "2.0",
1201
1478
  id: id2,
@@ -1219,6 +1496,44 @@ async function callTool(id2, params, deps) {
1219
1496
  return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1220
1497
  }
1221
1498
  }
1499
+ async function callLookup(id2, params, deps) {
1500
+ const args = params["arguments"] ?? {};
1501
+ const path = args["path"];
1502
+ if (typeof path !== "string" || path.length === 0) {
1503
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
1504
+ }
1505
+ try {
1506
+ const lookup = await lookupPublishedSource(path, deps);
1507
+ const match = lookup.match;
1508
+ const republishCall = match === undefined ? null : republishToolCall(match.relic_id, lookup.resolved_path);
1509
+ const structuredContent = {
1510
+ found: match !== undefined,
1511
+ relic_id: match?.relic_id ?? null,
1512
+ version: match?.version ?? null,
1513
+ resolved_path: lookup.resolved_path,
1514
+ source_identity: lookup.source.identity,
1515
+ source_description: lookup.source.description,
1516
+ republish_call: republishCall
1517
+ };
1518
+ return {
1519
+ jsonrpc: "2.0",
1520
+ id: id2,
1521
+ result: {
1522
+ content: [
1523
+ {
1524
+ type: "text",
1525
+ text: match === undefined ? `No prior relic is recorded for ${lookup.source.description}.` : `Found ${lookup.source.description} as version ` + `${match.version} of relic ${match.relic_id}.
1526
+ ` + `Call relic_republish(${JSON.stringify(republishCall?.arguments)}).`
1527
+ }
1528
+ ],
1529
+ structuredContent,
1530
+ isError: false
1531
+ }
1532
+ };
1533
+ } catch (error) {
1534
+ return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1535
+ }
1536
+ }
1222
1537
  async function callRepublish(id2, params, deps) {
1223
1538
  const args = params["arguments"] ?? {};
1224
1539
  const relicId = args["relic_id"];
@@ -1323,13 +1638,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
1323
1638
  what coarse class it was declared as, the publishing IP, and when it was
1324
1639
  fetched. Never the contents, and never the key.
1325
1640
 
1326
- What this keeps on disk: for each relic you publish, its id, its key, and a
1327
- publish token, in a 0600 file under your user config directory. That record
1328
- is what lets a relic be republished later, and it is why republishing works
1641
+ What this keeps on disk: for each relic you publish, its id, source identity,
1642
+ key, and publish token, in a 0600 file under your user config directory. The
1643
+ source index lets a fresh session find the id for relic_republish. The key and
1644
+ token let that republish keep the same URL, and they are why republishing works
1329
1645
  only on the machine that published. The token's SHA-256 is the only copy the
1330
- service ever holds, and neither secret is ever printed or logged. Deleting
1331
- the file changes nothing for existing links; it only ends this machine's
1332
- ability to update those relics.
1646
+ service ever holds, and neither secret is ever printed or logged. Deleting the
1647
+ file changes nothing for existing links; it only ends this machine's ability
1648
+ to update those relics.
1333
1649
 
1334
1650
  What this does NOT protect against: the key is returned to your agent in the
1335
1651
  URL, so it enters the model's context and your session transcript. That is
@@ -1440,8 +1756,8 @@ function createHttpHandler(deps, options = {}) {
1440
1756
  import { spawnSync } from "node:child_process";
1441
1757
  import { copyFile, mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
1442
1758
  import { homedir as homedir2 } from "node:os";
1443
- import { dirname as dirname2, join as join2, resolve } from "node:path";
1444
- import { fileURLToPath } from "node:url";
1759
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
1760
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1445
1761
 
1446
1762
  // src/install.ts
1447
1763
  var HARNESSES = [
@@ -1669,7 +1985,7 @@ function parseArgs(argv) {
1669
1985
  return options;
1670
1986
  }
1671
1987
  function packageRoot() {
1672
- return resolve(dirname2(fileURLToPath(import.meta.url)), "..");
1988
+ return resolve2(dirname2(fileURLToPath2(import.meta.url)), "..");
1673
1989
  }
1674
1990
  async function exists(path) {
1675
1991
  return readFile3(path).then(() => true).catch(() => false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",
@@ -24,16 +24,34 @@ enters the conversation, so it is never in the transcript, never in a model
24
24
  context window, and never in whatever stores those. Do not read a file into
25
25
  context and pass its text; pass where it lives.
26
26
 
27
+ When the task is to update the same source, call `relic_lookup_source` first:
28
+
29
+ ```
30
+ relic_lookup_source(path: "/Users/me/Downloads/report.html")
31
+ ```
32
+
33
+ It reads local machine state only. If the source was published before, it
34
+ returns the relic id and the exact `relic_republish` call. This works across
35
+ Git worktrees and clones of the same remote, so a fresh session does not need
36
+ to retain the id from the first publish.
37
+
38
+ Do not publish an update as a new relic. That costs a second URL that nobody
39
+ holding the first one will ever see. `relic_publish` enforces this: when local
40
+ state matches the source, it refuses and points to `relic_republish`.
41
+
27
42
  Optional arguments worth knowing:
28
43
 
29
44
  - `filename` overrides the display name shown to the recipient.
30
45
  - `ttl_days` gives the link a lifetime in days, 1 to 3650. A relic is kept
31
46
  until it is deleted unless you set one. Shorter is better for anything
32
47
  sensitive: when the content should stop being available, say when.
48
+ - `force_new` deliberately creates a separate relic from a source this machine
49
+ already published. Use it only when two independent URLs are the goal, never
50
+ to get past the update refusal.
33
51
 
34
- The result reports the relic as version 1 and its id. Keep the id if a later
35
- version of the same content will replace this one; that is what
36
- `relic_republish` is for.
52
+ The result reports the relic as version 1 and its id. The client records that
53
+ id with the source locally, so a later session can recover it with
54
+ `relic_lookup_source`.
37
55
 
38
56
  ## Republishing
39
57
 
@@ -85,9 +103,15 @@ Also worth one line, unprompted, the first time in a session:
85
103
  ## What the recipient gets
86
104
 
87
105
  A page that fetches the ciphertext, decrypts it in their browser, and renders
88
- by type. Markdown, code, images, and plain text render inline. HTML renders in
89
- a sandboxed frame on a separate origin, so a published page cannot reach the
90
- key or the service. Anything else offers a download.
106
+ by type. Markdown, code, images, and plain text render inline. HTML and JSX
107
+ render in a sandboxed frame on a separate origin, so a published page cannot
108
+ reach the key or the service. Anything else offers a download.
109
+
110
+ That frame has **no network access**: its policy permits no remote source at
111
+ all, so a page cannot fetch, beacon, or load an external image, font, or
112
+ script. Inline what a page needs when you generate it, because a CDN
113
+ reference renders as nothing. The upside is that a relic cannot phone home or
114
+ learn the recipient's IP address.
91
115
 
92
116
  They need the whole URL including the `#...` part. A link truncated at the `#`
93
117
  is a page that cannot decrypt anything, and that is the most common way sharing