relic-mcp 0.3.1 → 0.3.3

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.1",
13
+ "version": "0.3.3",
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.1"
24
+ "version": "0.3.3"
25
25
  }
26
26
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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,13 @@ 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;
1166
+ var VERSION_HISTORY_DISCLOSURE = "Anyone holding a relic's link can fetch every version it has ever held, " + "so republishing does not withdraw earlier content.";
972
1167
  var TOOL_DEFINITION = {
973
1168
  name: TOOL_NAME,
974
1169
  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.",
1170
+ 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. " + VERSION_HISTORY_DISCLOSURE + " The encryption key is generated locally and never sent to the service. " + "Takes a filesystem path, never " + "inline content.",
976
1171
  inputSchema: {
977
1172
  type: "object",
978
1173
  properties: {
@@ -989,6 +1184,11 @@ var TOOL_DEFINITION = {
989
1184
  minimum: 1,
990
1185
  maximum: MAX_TTL_DAYS,
991
1186
  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."
1187
+ },
1188
+ force_new: {
1189
+ type: "boolean",
1190
+ default: false,
1191
+ 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
1192
  }
993
1193
  },
994
1194
  required: ["path"],
@@ -1028,7 +1228,7 @@ var TOOL_DEFINITION = {
1028
1228
  var REPUBLISH_TOOL_DEFINITION = {
1029
1229
  name: REPUBLISH_TOOL_NAME,
1030
1230
  title: "Republish a relic",
1031
- description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + "Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived.",
1231
+ description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + VERSION_HISTORY_DISCLOSURE + " Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived.",
1032
1232
  inputSchema: {
1033
1233
  type: "object",
1034
1234
  properties: {
@@ -1083,6 +1283,60 @@ var REPUBLISH_TOOL_DEFINITION = {
1083
1283
  additionalProperties: false
1084
1284
  }
1085
1285
  };
1286
+ var LOOKUP_TOOL_DEFINITION = {
1287
+ name: LOOKUP_TOOL_NAME,
1288
+ title: "Look up a published source",
1289
+ 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.",
1290
+ inputSchema: {
1291
+ type: "object",
1292
+ properties: {
1293
+ path: {
1294
+ type: "string",
1295
+ description: "Filesystem path to the source to look up."
1296
+ }
1297
+ },
1298
+ required: ["path"],
1299
+ additionalProperties: false
1300
+ },
1301
+ outputSchema: {
1302
+ type: "object",
1303
+ properties: {
1304
+ found: { type: "boolean" },
1305
+ relic_id: { type: ["string", "null"] },
1306
+ version: { type: ["integer", "null"], minimum: 1 },
1307
+ resolved_path: { type: "string" },
1308
+ source_identity: { type: "string" },
1309
+ source_description: { type: "string" },
1310
+ republish_call: {
1311
+ type: ["object", "null"],
1312
+ properties: {
1313
+ name: { type: "string", const: REPUBLISH_TOOL_NAME },
1314
+ arguments: {
1315
+ type: "object",
1316
+ properties: {
1317
+ relic_id: { type: "string" },
1318
+ path: { type: "string" }
1319
+ },
1320
+ required: ["relic_id", "path"],
1321
+ additionalProperties: false
1322
+ }
1323
+ },
1324
+ required: ["name", "arguments"],
1325
+ additionalProperties: false
1326
+ }
1327
+ },
1328
+ required: [
1329
+ "found",
1330
+ "relic_id",
1331
+ "version",
1332
+ "resolved_path",
1333
+ "source_identity",
1334
+ "source_description",
1335
+ "republish_call"
1336
+ ],
1337
+ additionalProperties: false
1338
+ }
1339
+ };
1086
1340
  var DESCRIBE_TOOL_DEFINITION = {
1087
1341
  name: DESCRIBE_TOOL_NAME,
1088
1342
  title: "Describe the Relic client",
@@ -1093,21 +1347,22 @@ var DESCRIBE_TOOL_DEFINITION = {
1093
1347
  additionalProperties: false
1094
1348
  }
1095
1349
  };
1096
- var SERVER_VERSION = "0.3.1";
1350
+ var SERVER_VERSION = "0.3.3";
1097
1351
  var SERVER_INFO = {
1098
1352
  name: "relic",
1099
1353
  title: "Relic",
1100
1354
  version: SERVER_VERSION
1101
1355
  };
1102
1356
  var CAPABILITIES = { tools: {} };
1103
- var INSTRUCTIONS = `Relic turns a file on this machine into an encrypted link. Encryption happens locally, only ciphertext is uploaded, and the key lives in the URL fragment, which browsers never send to a server.
1357
+ 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.
1104
1358
 
1105
- Four things that change how you should act:
1359
+ Five things that change how you should act:
1106
1360
 
1107
1361
  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.
1108
1362
  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.
1109
- 3. 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.
1110
- 4. 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.`;
1363
+ 3. Check existing sources with relic_lookup_source. Use relic_republish when found; relic_publish otherwise costs a second URL. ${VERSION_HISTORY_DISCLOSURE}
1364
+ 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.
1365
+ 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.`;
1111
1366
  async function handleMessage(message, deps) {
1112
1367
  if (message.id === undefined)
1113
1368
  return;
@@ -1151,6 +1406,7 @@ async function handleMessage(message, deps) {
1151
1406
  result: {
1152
1407
  tools: [
1153
1408
  TOOL_DEFINITION,
1409
+ LOOKUP_TOOL_DEFINITION,
1154
1410
  REPUBLISH_TOOL_DEFINITION,
1155
1411
  DESCRIBE_TOOL_DEFINITION
1156
1412
  ]
@@ -1181,13 +1437,16 @@ async function callTool(id2, params, deps) {
1181
1437
  key_transmitted_to_service: false,
1182
1438
  plaintext_transmitted_to_service: false,
1183
1439
  ciphertext_destination: "object storage, via a signed URL",
1184
- local_publish_state: "relic id, key, and publish token per relic, written 0600 under " + "the user config directory; never printed, never sent",
1440
+ 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",
1185
1441
  service_origin: deps.serviceOrigin
1186
1442
  },
1187
1443
  isError: false
1188
1444
  }
1189
1445
  };
1190
1446
  }
1447
+ if (params["name"] === LOOKUP_TOOL_NAME) {
1448
+ return callLookup(id2, params, deps);
1449
+ }
1191
1450
  if (params["name"] === REPUBLISH_TOOL_NAME) {
1192
1451
  return callRepublish(id2, params, deps);
1193
1452
  }
@@ -1204,8 +1463,17 @@ async function callTool(id2, params, deps) {
1204
1463
  if (!ttlDays.ok) {
1205
1464
  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");
1206
1465
  }
1466
+ const forceNew = args["force_new"];
1467
+ if (forceNew !== undefined && typeof forceNew !== "boolean") {
1468
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`force_new` must be a boolean or omitted");
1469
+ }
1207
1470
  try {
1208
- const result = await publish({ path, filename, ttl_days: ttlDays.days }, deps);
1471
+ const result = await publish({
1472
+ path,
1473
+ filename,
1474
+ ttl_days: ttlDays.days,
1475
+ force_new: forceNew === true
1476
+ }, deps);
1209
1477
  return {
1210
1478
  jsonrpc: "2.0",
1211
1479
  id: id2,
@@ -1219,7 +1487,8 @@ async function callTool(id2, params, deps) {
1219
1487
 
1220
1488
  ` + (result.relic_expires_at === null ? "It does not expire; it is kept until it is deleted. " : `Expires ${result.relic_expires_at}. `) + "Anyone with this link, " + "including its fragment, can read the file. The key is in the " + "fragment and it is now in this transcript. This machine can " + `republish it later; the link will not change.
1221
1489
  ` + isolationNote(result.renderer_class) + `What Relic knows: ${result.disclosure_url}`
1222
- }
1490
+ },
1491
+ { type: "text", text: VERSION_HISTORY_DISCLOSURE }
1223
1492
  ],
1224
1493
  structuredContent: result,
1225
1494
  isError: false
@@ -1229,6 +1498,44 @@ async function callTool(id2, params, deps) {
1229
1498
  return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1230
1499
  }
1231
1500
  }
1501
+ async function callLookup(id2, params, deps) {
1502
+ const args = params["arguments"] ?? {};
1503
+ const path = args["path"];
1504
+ if (typeof path !== "string" || path.length === 0) {
1505
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
1506
+ }
1507
+ try {
1508
+ const lookup = await lookupPublishedSource(path, deps);
1509
+ const match = lookup.match;
1510
+ const republishCall = match === undefined ? null : republishToolCall(match.relic_id, lookup.resolved_path);
1511
+ const structuredContent = {
1512
+ found: match !== undefined,
1513
+ relic_id: match?.relic_id ?? null,
1514
+ version: match?.version ?? null,
1515
+ resolved_path: lookup.resolved_path,
1516
+ source_identity: lookup.source.identity,
1517
+ source_description: lookup.source.description,
1518
+ republish_call: republishCall
1519
+ };
1520
+ return {
1521
+ jsonrpc: "2.0",
1522
+ id: id2,
1523
+ result: {
1524
+ content: [
1525
+ {
1526
+ type: "text",
1527
+ 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}.
1528
+ ` + `Call relic_republish(${JSON.stringify(republishCall?.arguments)}).`
1529
+ }
1530
+ ],
1531
+ structuredContent,
1532
+ isError: false
1533
+ }
1534
+ };
1535
+ } catch (error) {
1536
+ return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1537
+ }
1538
+ }
1232
1539
  async function callRepublish(id2, params, deps) {
1233
1540
  const args = params["arguments"] ?? {};
1234
1541
  const relicId = args["relic_id"];
@@ -1259,7 +1566,8 @@ async function callRepublish(id2, params, deps) {
1259
1566
 
1260
1567
  ` + (result.relic_expires_at === null ? "The relic does not expire; it is kept until it is deleted." : `Expires ${result.relic_expires_at}.`) + `
1261
1568
  ` + `What Relic knows: ${result.disclosure_url}`
1262
- }
1569
+ },
1570
+ { type: "text", text: VERSION_HISTORY_DISCLOSURE }
1263
1571
  ],
1264
1572
  structuredContent: result,
1265
1573
  isError: false
@@ -1333,13 +1641,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
1333
1641
  what coarse class it was declared as, the publishing IP, and when it was
1334
1642
  fetched. Never the contents, and never the key.
1335
1643
 
1336
- What this keeps on disk: for each relic you publish, its id, its key, and a
1337
- publish token, in a 0600 file under your user config directory. That record
1338
- is what lets a relic be republished later, and it is why republishing works
1644
+ What this keeps on disk: for each relic you publish, its id, source identity,
1645
+ key, and publish token, in a 0600 file under your user config directory. The
1646
+ source index lets a fresh session find the id for relic_republish. The key and
1647
+ token let that republish keep the same URL, and they are why republishing works
1339
1648
  only on the machine that published. The token's SHA-256 is the only copy the
1340
- service ever holds, and neither secret is ever printed or logged. Deleting
1341
- the file changes nothing for existing links; it only ends this machine's
1342
- ability to update those relics.
1649
+ service ever holds, and neither secret is ever printed or logged. Deleting the
1650
+ file changes nothing for existing links; it only ends this machine's ability
1651
+ to update those relics.
1343
1652
 
1344
1653
  What this does NOT protect against: the key is returned to your agent in the
1345
1654
  URL, so it enters the model's context and your session transcript. That is
@@ -1450,8 +1759,8 @@ function createHttpHandler(deps, options = {}) {
1450
1759
  import { spawnSync } from "node:child_process";
1451
1760
  import { copyFile, mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
1452
1761
  import { homedir as homedir2 } from "node:os";
1453
- import { dirname as dirname2, join as join2, resolve } from "node:path";
1454
- import { fileURLToPath } from "node:url";
1762
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
1763
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1455
1764
 
1456
1765
  // src/install.ts
1457
1766
  var HARNESSES = [
@@ -1679,7 +1988,7 @@ function parseArgs(argv) {
1679
1988
  return options;
1680
1989
  }
1681
1990
  function packageRoot() {
1682
- return resolve(dirname2(fileURLToPath(import.meta.url)), "..");
1991
+ return resolve2(dirname2(fileURLToPath2(import.meta.url)), "..");
1683
1992
  }
1684
1993
  async function exists(path) {
1685
1994
  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.1",
3
+ "version": "0.3.3",
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,39 @@ 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
+
42
+ Anyone holding a relic's link can fetch every version it has ever held, so
43
+ republishing does not withdraw earlier content. Republishing moves the artifact
44
+ forward without retracting what came before. Deleting the relic still removes
45
+ every version.
46
+
27
47
  Optional arguments worth knowing:
28
48
 
29
49
  - `filename` overrides the display name shown to the recipient.
30
50
  - `ttl_days` gives the link a lifetime in days, 1 to 3650. A relic is kept
31
51
  until it is deleted unless you set one. Shorter is better for anything
32
52
  sensitive: when the content should stop being available, say when.
53
+ - `force_new` deliberately creates a separate relic from a source this machine
54
+ already published. Use it only when two independent URLs are the goal, never
55
+ to get past the update refusal.
33
56
 
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.
57
+ The result reports the relic as version 1 and its id. The client records that
58
+ id with the source locally, so a later session can recover it with
59
+ `relic_lookup_source`.
37
60
 
38
61
  ## Republishing
39
62