relic-mcp 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/relic-mcp.js CHANGED
@@ -190,6 +190,24 @@ function encodeKey(key) {
190
190
  }
191
191
  return out;
192
192
  }
193
+ function decodeKey(encoded) {
194
+ const bytes = [];
195
+ let accumulator = 0;
196
+ let bitsHeld = 0;
197
+ for (const ch of encoded) {
198
+ const index = B64URL.indexOf(ch);
199
+ if (index < 0) {
200
+ throw new MalformedFragmentError(`key contains ${JSON.stringify(ch)}, outside base64url`);
201
+ }
202
+ accumulator = accumulator << 6 | index;
203
+ bitsHeld += 6;
204
+ if (bitsHeld >= 8) {
205
+ bitsHeld -= 8;
206
+ bytes.push(accumulator >>> bitsHeld & 255);
207
+ }
208
+ }
209
+ return new Uint8Array(bytes);
210
+ }
193
211
  function generateKey() {
194
212
  const key = new Uint8Array(KEY_BYTES);
195
213
  crypto.getRandomValues(key);
@@ -329,6 +347,7 @@ function concat(parts) {
329
347
  // ../relic-format/src/id.ts
330
348
  var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
331
349
  var ID_ENTROPY_BYTES = 16;
350
+ var ID_LENGTH = 26;
332
351
  var RESERVED_SEGMENTS = [
333
352
  "abuse",
334
353
  "policy",
@@ -341,7 +360,8 @@ var RESERVED_SEGMENTS = [
341
360
  "api",
342
361
  "health",
343
362
  ".well-known",
344
- "sandbox.html"
363
+ "sandbox.html",
364
+ "install"
345
365
  ];
346
366
  var RESERVED_NORMALIZED = new Set(RESERVED_SEGMENTS.map((word) => foldAliases(word.toLowerCase())));
347
367
 
@@ -365,6 +385,32 @@ function foldAliases(lowered) {
365
385
  }
366
386
  return out;
367
387
  }
388
+ function normalizeRelicId(candidate) {
389
+ return foldAliases(candidate.toLowerCase());
390
+ }
391
+ function parseRelicId(candidate) {
392
+ if (candidate.length !== ID_LENGTH) {
393
+ throw new InvalidRelicIdError("length", `relic id must be ${ID_LENGTH} characters, got ${candidate.length}`);
394
+ }
395
+ const normalized = normalizeRelicId(candidate);
396
+ for (const ch of normalized) {
397
+ if (!ALPHABET.includes(ch)) {
398
+ throw new InvalidRelicIdError("alphabet", `relic id contains ${JSON.stringify(ch)}, outside Crockford base32`);
399
+ }
400
+ }
401
+ if (RESERVED_NORMALIZED.has(normalized)) {
402
+ throw new InvalidRelicIdError("reserved", `relic id normalizes onto the reserved segment ${normalized}`);
403
+ }
404
+ return normalized;
405
+ }
406
+ function isValidRelicId(candidate) {
407
+ try {
408
+ parseRelicId(candidate);
409
+ return true;
410
+ } catch {
411
+ return false;
412
+ }
413
+ }
368
414
  function encodeRelicId(entropy) {
369
415
  if (entropy.length !== ID_ENTROPY_BYTES) {
370
416
  throw new InvalidRelicIdError("length", `relic id entropy must be ${ID_ENTROPY_BYTES} bytes`);
@@ -446,9 +492,9 @@ var EXTENSIONS = {
446
492
  "7z": "archive",
447
493
  rar: "archive",
448
494
  ts: "code",
449
- tsx: "code",
450
495
  js: "code",
451
- jsx: "code",
496
+ jsx: "jsx",
497
+ tsx: "jsx",
452
498
  mjs: "code",
453
499
  cjs: "code",
454
500
  json: "code",
@@ -540,7 +586,40 @@ function looksTextual(content) {
540
586
  }
541
587
  return suspicious / limit < 0.01;
542
588
  }
543
- var HTML_PREFIXES = ["<!doctype html", "<html", "<!-- ", "<svg"];
589
+ var HTML_PREFIXES = [
590
+ "<!doctype",
591
+ "<?xml",
592
+ "<html",
593
+ "<head",
594
+ "<body",
595
+ "<meta",
596
+ "<title",
597
+ "<link",
598
+ "<style",
599
+ "<script",
600
+ "<svg",
601
+ "<div",
602
+ "<main",
603
+ "<section",
604
+ "<article",
605
+ "<header",
606
+ "<footer",
607
+ "<nav",
608
+ "<aside",
609
+ "<table",
610
+ "<form",
611
+ "<ul",
612
+ "<ol",
613
+ "<dl",
614
+ "<h1",
615
+ "<h2",
616
+ "<h3",
617
+ "<h4",
618
+ "<h5",
619
+ "<h6",
620
+ "<p>",
621
+ "<!--"
622
+ ];
544
623
  function deriveRendererClass(content, filename) {
545
624
  if (content.length === 0)
546
625
  return "binary";
@@ -562,6 +641,80 @@ function deriveRendererClass(content, filename) {
562
641
  }
563
642
  return "binary";
564
643
  }
644
+ // src/state.ts
645
+ import { mkdir, readFile as readFile2, rename, stat, writeFile } from "node:fs/promises";
646
+ import { homedir } from "node:os";
647
+ import { dirname, join } from "node:path";
648
+ function publishStatePath() {
649
+ const override = process.env["RELIC_PUBLISH_STATE"];
650
+ if (override !== undefined && override.length > 0)
651
+ return override;
652
+ const configRoot = process.env["XDG_CONFIG_HOME"] !== undefined && process.env["XDG_CONFIG_HOME"].length > 0 ? process.env["XDG_CONFIG_HOME"] : join(homedir(), ".config");
653
+ return join(configRoot, "relic-mcp", "publish-state.json");
654
+ }
655
+ var writeChain = Promise.resolve();
656
+ async function loadPublishState(relicId) {
657
+ let parsed;
658
+ 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.");
666
+ }
667
+ throw new Error(`could not read publish state at ${publishStatePath()}: ${code}`);
668
+ }
669
+ const entry = parsed?.relics?.[relicId];
670
+ if (entry === undefined)
671
+ 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.");
674
+ }
675
+ return entry;
676
+ }
677
+ async function savePublishState(relicId, state) {
678
+ const run = writeChain.then(() => writeEntry(relicId, state));
679
+ writeChain = run.catch(() => {
680
+ return;
681
+ });
682
+ await run;
683
+ }
684
+ async function writeEntry(relicId, state) {
685
+ const path = publishStatePath();
686
+ const dir = dirname(path);
687
+ await mkdir(dir, { mode: 448, recursive: true });
688
+ const existing = await readWholeFile(path);
689
+ const relics = { ...existing?.relics, [relicId]: state };
690
+ const temp = `${path}.tmp`;
691
+ await writeFile(temp, `${JSON.stringify({ relics }, null, 2)}
692
+ `, {
693
+ mode: 384
694
+ });
695
+ await rename(temp, path);
696
+ }
697
+ async function readWholeFile(path) {
698
+ let raw;
699
+ try {
700
+ raw = await readFile2(path, "utf8");
701
+ } catch (error) {
702
+ const code = error.code;
703
+ if (code === "ENOENT")
704
+ return;
705
+ throw new Error(`could not read publish state at ${path}: ${code}`);
706
+ }
707
+ try {
708
+ const parsed = JSON.parse(raw);
709
+ if (parsed === null || typeof parsed !== "object") {
710
+ throw new Error("not an object");
711
+ }
712
+ return parsed;
713
+ } catch {
714
+ throw new Error(`publish state at ${path} is not valid JSON. It cannot be updated, so ` + "this publish cannot be recorded and this relic cannot later be " + "republished from this machine until the file is fixed or removed.");
715
+ }
716
+ }
717
+
565
718
  // src/publish.ts
566
719
  class PublishError extends Error {
567
720
  code;
@@ -619,7 +772,8 @@ async function publish(input, deps) {
619
772
  renderer_class: rendererClass,
620
773
  publishing_client: deps.clientName,
621
774
  declared_size_bytes: source.bytes.length,
622
- declared_ciphertext_bytes: container2.length
775
+ declared_ciphertext_bytes: container2.length,
776
+ ...input.ttl_days === undefined ? {} : { ttl_days: input.ttl_days }
623
777
  });
624
778
  } catch (error) {
625
779
  if (error instanceof ServerRefusal && error.code === "relic_id_collision") {
@@ -628,21 +782,27 @@ async function publish(input, deps) {
628
782
  }
629
783
  throw error;
630
784
  }
631
- const upload = await deps.fetch(String(grant["upload_url"]), {
632
- method: "PUT",
633
- headers: { "content-length": String(container2.length) },
634
- body: container2
635
- });
636
- if (!upload.ok) {
637
- throw new PublishError("upload_failed", `upload returned ${upload.status}`, { relic_id: relicId, status: upload.status });
785
+ const publishToken = grant["publish_token"];
786
+ if (typeof publishToken !== "string" || publishToken.length === 0) {
787
+ throw new PublishError("grant_missing_publish_token", "the grant carried no publish_token, so the relic would never be " + "republishable from this machine", { relic_id: relicId });
638
788
  }
789
+ await putContainer(deps, relicId, String(grant["upload_url"]), container2);
790
+ await reportComplete(deps, relicId);
791
+ const url = relicUrl(deps.relicOrigin, relicId, key);
639
792
  try {
640
- await postJson(deps, `${deps.serviceOrigin}/api/relics/${relicId}/complete`, {});
641
- } catch {}
793
+ await savePublishState(relicId, {
794
+ key: encodeKey(key),
795
+ publish_token: publishToken,
796
+ version: 1
797
+ });
798
+ } catch (error) {
799
+ 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 });
800
+ }
642
801
  return {
643
- url: relicUrl(deps.relicOrigin, relicId, key),
802
+ url,
644
803
  relic_id: relicId,
645
- relic_expires_at: String(grant["relic_expires_at"]),
804
+ version: 1,
805
+ relic_expires_at: grant["relic_expires_at"] == null ? null : String(grant["relic_expires_at"]),
646
806
  renderer_class: rendererClass,
647
807
  filename,
648
808
  resolved_path: source.resolvedPath,
@@ -654,11 +814,11 @@ async function publish(input, deps) {
654
814
  }
655
815
  async function readSource(path, files) {
656
816
  const resolvedPath = files.resolve(path);
657
- const stat = await files.stat(resolvedPath);
658
- if (stat.kind === "directory") {
817
+ const stat2 = await files.stat(resolvedPath);
818
+ if (stat2.kind === "directory") {
659
819
  throw new PublishError("source_is_directory", `${resolvedPath} is a directory. Create an archive yourself and ` + "publish that, so the download-only outcome is yours to choose.");
660
820
  }
661
- if (stat.kind === "other") {
821
+ if (stat2.kind === "other") {
662
822
  throw new PublishError("source_not_regular_file", `${resolvedPath} is not a regular file`);
663
823
  }
664
824
  let bytes;
@@ -686,6 +846,24 @@ async function postJson(deps, url, body) {
686
846
  }
687
847
  return parsed;
688
848
  }
849
+ async function putContainer(deps, relicId, uploadUrl, container2) {
850
+ const upload = await deps.fetch(uploadUrl, {
851
+ method: "PUT",
852
+ headers: { "content-length": String(container2.length) },
853
+ body: container2
854
+ });
855
+ if (!upload.ok) {
856
+ throw new PublishError("upload_failed", `upload returned ${upload.status}`, {
857
+ relic_id: relicId,
858
+ status: upload.status
859
+ });
860
+ }
861
+ }
862
+ async function reportComplete(deps, relicId) {
863
+ try {
864
+ await postJson(deps, `${deps.serviceOrigin}/api/relics/${relicId}/complete`, {});
865
+ } catch {}
866
+ }
689
867
  var MIMETYPES = {
690
868
  md: "text/markdown",
691
869
  markdown: "text/markdown",
@@ -708,12 +886,15 @@ var MIMETYPES = {
708
886
  ogg: "audio/ogg",
709
887
  zip: "application/zip",
710
888
  gz: "application/gzip",
711
- pdf: "application/pdf"
889
+ pdf: "application/pdf",
890
+ jsx: "text/jsx",
891
+ tsx: "text/tsx"
712
892
  };
713
893
  var CLASS_FALLBACK = {
714
894
  markdown: "text/markdown",
715
895
  code: "text/plain",
716
896
  html: "text/html",
897
+ jsx: "text/jsx",
717
898
  image: "application/octet-stream",
718
899
  media: "application/octet-stream",
719
900
  archive: "application/octet-stream",
@@ -730,9 +911,64 @@ function guessMimetype(filename, rendererClass) {
730
911
  return CLASS_FALLBACK[rendererClass];
731
912
  }
732
913
 
914
+ // src/republish.ts
915
+ async function republish(input, deps) {
916
+ if (!isValidRelicId(input.relic_id)) {
917
+ throw new PublishError("no_local_publish_state", `"${input.relic_id}" is not a relic id. Republish takes the ` + "26-character id the original publish returned.");
918
+ }
919
+ let state;
920
+ try {
921
+ const loaded = await loadPublishState(input.relic_id);
922
+ if (loaded === undefined) {
923
+ throw new PublishError("no_local_publish_state", `relic ${input.relic_id} was published from another machine and ` + "cannot be republished here. The key and the publish token live " + "only on the machine that made the first publish, and neither can " + "be reconstructed from the link or from the service. Publish the " + "content as a new relic instead.");
924
+ }
925
+ state = loaded;
926
+ } catch (error) {
927
+ if (error instanceof PublishError)
928
+ throw error;
929
+ throw new PublishError("local_state_unreadable", error.message);
930
+ }
931
+ const source = await readSource(input.path, deps.files);
932
+ const filename = input.filename ?? source.basename;
933
+ const rendererClass = deriveRendererClass(source.bytes, filename);
934
+ const container2 = await encryptRelic({
935
+ content: source.bytes,
936
+ filename,
937
+ mimetype: guessMimetype(filename, rendererClass),
938
+ key: decodeKey(state.key)
939
+ });
940
+ const grant = await postJson(deps, `${deps.serviceOrigin}/api/relics/${input.relic_id}/republish`, {
941
+ publish_token: state.publish_token,
942
+ renderer_class: rendererClass,
943
+ declared_size_bytes: source.bytes.length,
944
+ declared_ciphertext_bytes: container2.length,
945
+ ...input.ttl_days === undefined ? {} : { ttl_days: input.ttl_days }
946
+ });
947
+ await putContainer(deps, input.relic_id, String(grant["upload_url"]), container2);
948
+ await reportComplete(deps, input.relic_id);
949
+ const version = state.version + 1;
950
+ try {
951
+ await savePublishState(input.relic_id, { ...state, version });
952
+ } catch (error) {
953
+ throw new PublishError("local_state_write_failed", "the new version is live at the existing link, but updating the " + "local record failed, so the next republish from this machine may " + `report a stale version number: ${error.message}`, { relic_id: input.relic_id, version });
954
+ }
955
+ return {
956
+ relic_id: input.relic_id,
957
+ version,
958
+ relic_expires_at: grant["relic_expires_at"] == null ? null : String(grant["relic_expires_at"]),
959
+ renderer_class: rendererClass,
960
+ filename,
961
+ resolved_path: source.resolvedPath,
962
+ report_url: String(grant["report_url"]),
963
+ disclosure_url: String(grant["disclosure_url"])
964
+ };
965
+ }
966
+
733
967
  // src/server.ts
734
968
  var TOOL_NAME = "relic_publish";
735
969
  var DESCRIBE_TOOL_NAME = "relic_describe_client";
970
+ var REPUBLISH_TOOL_NAME = "relic_republish";
971
+ var MAX_TTL_DAYS = 3650;
736
972
  var TOOL_DEFINITION = {
737
973
  name: TOOL_NAME,
738
974
  title: "Publish a relic",
@@ -747,6 +983,12 @@ var TOOL_DEFINITION = {
747
983
  filename: {
748
984
  type: "string",
749
985
  description: "Optional. Overrides the name written into the encrypted envelope " + "header. Defaults to the basename of `path`."
986
+ },
987
+ ttl_days: {
988
+ type: "integer",
989
+ minimum: 1,
990
+ maximum: MAX_TTL_DAYS,
991
+ 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."
750
992
  }
751
993
  },
752
994
  required: ["path"],
@@ -757,7 +999,12 @@ var TOOL_DEFINITION = {
757
999
  properties: {
758
1000
  url: { type: "string" },
759
1001
  relic_id: { type: "string" },
760
- relic_expires_at: { type: "string" },
1002
+ version: {
1003
+ type: "integer",
1004
+ minimum: 1,
1005
+ description: "Always 1 from this tool; republish counts upward."
1006
+ },
1007
+ relic_expires_at: { type: ["string", "null"] },
761
1008
  renderer_class: { type: "string" },
762
1009
  filename: { type: "string" },
763
1010
  resolved_path: { type: "string" },
@@ -767,6 +1014,65 @@ var TOOL_DEFINITION = {
767
1014
  required: [
768
1015
  "url",
769
1016
  "relic_id",
1017
+ "version",
1018
+ "relic_expires_at",
1019
+ "renderer_class",
1020
+ "filename",
1021
+ "resolved_path",
1022
+ "report_url",
1023
+ "disclosure_url"
1024
+ ],
1025
+ additionalProperties: false
1026
+ }
1027
+ };
1028
+ var REPUBLISH_TOOL_DEFINITION = {
1029
+ name: REPUBLISH_TOOL_NAME,
1030
+ 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.",
1032
+ inputSchema: {
1033
+ type: "object",
1034
+ properties: {
1035
+ relic_id: {
1036
+ type: "string",
1037
+ description: "The 26-character relic id the original publish returned."
1038
+ },
1039
+ path: {
1040
+ type: "string",
1041
+ description: "Filesystem path to the file that becomes the new version."
1042
+ },
1043
+ filename: {
1044
+ type: "string",
1045
+ description: "Optional. Overrides the name written into the encrypted envelope " + "header of the new version. Defaults to the basename of `path`."
1046
+ },
1047
+ ttl_days: {
1048
+ type: "integer",
1049
+ minimum: 1,
1050
+ maximum: MAX_TTL_DAYS,
1051
+ description: "Optional. A lifetime in days, forwarded on the republish " + "request. The service fixes a relic's lifetime at its first " + "publish, so treat this as reserved."
1052
+ }
1053
+ },
1054
+ required: ["relic_id", "path"],
1055
+ additionalProperties: false
1056
+ },
1057
+ outputSchema: {
1058
+ type: "object",
1059
+ properties: {
1060
+ relic_id: { type: "string" },
1061
+ version: {
1062
+ type: "integer",
1063
+ minimum: 2,
1064
+ description: "The version just published."
1065
+ },
1066
+ relic_expires_at: { type: ["string", "null"] },
1067
+ renderer_class: { type: "string" },
1068
+ filename: { type: "string" },
1069
+ resolved_path: { type: "string" },
1070
+ report_url: { type: "string" },
1071
+ disclosure_url: { type: "string" }
1072
+ },
1073
+ required: [
1074
+ "relic_id",
1075
+ "version",
770
1076
  "relic_expires_at",
771
1077
  "renderer_class",
772
1078
  "filename",
@@ -787,12 +1093,21 @@ var DESCRIBE_TOOL_DEFINITION = {
787
1093
  additionalProperties: false
788
1094
  }
789
1095
  };
1096
+ var SERVER_VERSION = "0.3.1";
790
1097
  var SERVER_INFO = {
791
1098
  name: "relic",
792
1099
  title: "Relic",
793
- version: "0.1.0"
1100
+ version: SERVER_VERSION
794
1101
  };
795
1102
  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.
1104
+
1105
+ Four things that change how you should act:
1106
+
1107
+ 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
+ 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.`;
796
1111
  async function handleMessage(message, deps) {
797
1112
  if (message.id === undefined)
798
1113
  return;
@@ -810,7 +1125,8 @@ async function handleMessage(message, deps) {
810
1125
  result: {
811
1126
  protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
812
1127
  capabilities: CAPABILITIES,
813
- serverInfo: SERVER_INFO
1128
+ serverInfo: SERVER_INFO,
1129
+ instructions: INSTRUCTIONS
814
1130
  }
815
1131
  };
816
1132
  case "initialize": {
@@ -821,7 +1137,8 @@ async function handleMessage(message, deps) {
821
1137
  result: {
822
1138
  protocolVersion: isSupportedVersion(asked) ? asked : PROTOCOL_VERSION,
823
1139
  capabilities: CAPABILITIES,
824
- serverInfo: SERVER_INFO
1140
+ serverInfo: SERVER_INFO,
1141
+ instructions: INSTRUCTIONS
825
1142
  }
826
1143
  };
827
1144
  }
@@ -831,7 +1148,13 @@ async function handleMessage(message, deps) {
831
1148
  return {
832
1149
  jsonrpc: "2.0",
833
1150
  id: id2,
834
- result: { tools: [TOOL_DEFINITION, DESCRIBE_TOOL_DEFINITION] }
1151
+ result: {
1152
+ tools: [
1153
+ TOOL_DEFINITION,
1154
+ REPUBLISH_TOOL_DEFINITION,
1155
+ DESCRIBE_TOOL_DEFINITION
1156
+ ]
1157
+ }
835
1158
  };
836
1159
  case "tools/call":
837
1160
  return callTool(id2, message.params ?? {}, deps);
@@ -839,6 +1162,12 @@ async function handleMessage(message, deps) {
839
1162
  return errorResponse(id2, ERROR_CODES.methodNotFound, `unknown method: ${message.method}`);
840
1163
  }
841
1164
  }
1165
+ function isolationNote(rendererClass) {
1166
+ if (rendererClass !== "html" && rendererClass !== "jsx")
1167
+ return "";
1168
+ return "It renders in an isolated frame with no network access, so external " + "images, fonts, scripts, and fetches will not load. Inline whatever the " + `page needs.
1169
+ `;
1170
+ }
842
1171
  async function callTool(id2, params, deps) {
843
1172
  if (params["name"] === DESCRIBE_TOOL_NAME) {
844
1173
  return {
@@ -852,12 +1181,16 @@ async function callTool(id2, params, deps) {
852
1181
  key_transmitted_to_service: false,
853
1182
  plaintext_transmitted_to_service: false,
854
1183
  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",
855
1185
  service_origin: deps.serviceOrigin
856
1186
  },
857
1187
  isError: false
858
1188
  }
859
1189
  };
860
1190
  }
1191
+ if (params["name"] === REPUBLISH_TOOL_NAME) {
1192
+ return callRepublish(id2, params, deps);
1193
+ }
861
1194
  if (params["name"] !== TOOL_NAME) {
862
1195
  return errorResponse(id2, ERROR_CODES.invalidParams, `unknown tool: ${String(params["name"])}`);
863
1196
  }
@@ -867,8 +1200,12 @@ async function callTool(id2, params, deps) {
867
1200
  return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
868
1201
  }
869
1202
  const filename = typeof args["filename"] === "string" ? args["filename"] : undefined;
1203
+ const ttlDays = parseTtlDays(args["ttl_days"]);
1204
+ if (!ttlDays.ok) {
1205
+ 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
+ }
870
1207
  try {
871
- const result = await publish({ path, filename }, deps);
1208
+ const result = await publish({ path, filename, ttl_days: ttlDays.days }, deps);
872
1209
  return {
873
1210
  jsonrpc: "2.0",
874
1211
  id: id2,
@@ -876,11 +1213,51 @@ async function callTool(id2, params, deps) {
876
1213
  content: [
877
1214
  {
878
1215
  type: "text",
879
- text: `Published ${result.filename} as a relic.
1216
+ text: `Published ${result.filename} as version 1 of a new relic.
880
1217
 
881
- ${result.url}
1218
+ ` + `${result.url}
882
1219
 
883
- ` + `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.
1220
+ ` + (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
+ ` + isolationNote(result.renderer_class) + `What Relic knows: ${result.disclosure_url}`
1222
+ }
1223
+ ],
1224
+ structuredContent: result,
1225
+ isError: false
1226
+ }
1227
+ };
1228
+ } catch (error) {
1229
+ return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1230
+ }
1231
+ }
1232
+ async function callRepublish(id2, params, deps) {
1233
+ const args = params["arguments"] ?? {};
1234
+ const relicId = args["relic_id"];
1235
+ if (typeof relicId !== "string" || relicId.length === 0) {
1236
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`relic_id` is required and must be a string");
1237
+ }
1238
+ const path = args["path"];
1239
+ if (typeof path !== "string" || path.length === 0) {
1240
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
1241
+ }
1242
+ const filename = typeof args["filename"] === "string" ? args["filename"] : undefined;
1243
+ const ttlDays = parseTtlDays(args["ttl_days"]);
1244
+ if (!ttlDays.ok) {
1245
+ return errorResponse(id2, ERROR_CODES.invalidParams, `\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` + "omitted to leave the lifetime as the first publish set it");
1246
+ }
1247
+ try {
1248
+ const result = await republish({ relic_id: relicId, path, filename, ttl_days: ttlDays.days }, deps);
1249
+ return {
1250
+ jsonrpc: "2.0",
1251
+ id: id2,
1252
+ result: {
1253
+ content: [
1254
+ {
1255
+ type: "text",
1256
+ text: `Republished ${result.filename} as version ${result.version} ` + `of relic ${result.relic_id}.
1257
+
1258
+ ` + "The share URL is unchanged: everyone holding the existing " + "link, including its fragment, now sees this content. There " + `is no new link to hand out.
1259
+
1260
+ ` + (result.relic_expires_at === null ? "The relic does not expire; it is kept until it is deleted." : `Expires ${result.relic_expires_at}.`) + `
884
1261
  ` + `What Relic knows: ${result.disclosure_url}`
885
1262
  }
886
1263
  ],
@@ -892,6 +1269,18 @@ ${result.url}
892
1269
  return { jsonrpc: "2.0", id: id2, result: toolError(error) };
893
1270
  }
894
1271
  }
1272
+ function parseTtlDays(raw) {
1273
+ if (raw === undefined || raw === null)
1274
+ return { ok: true, days: undefined };
1275
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1 || raw > MAX_TTL_DAYS) {
1276
+ return { ok: false };
1277
+ }
1278
+ return { ok: true, days: raw };
1279
+ }
1280
+ var REFUSAL_GUIDANCE = {
1281
+ invalid_publish_token: "The publish token this machine holds for that relic was rejected. It " + "is issued once, at first publish, and never changes, so a rejection " + "means the local record no longer matches the service's. The relic can " + "still be read at its existing link, but it cannot be republished from " + "here.",
1282
+ relic_removed: "That relic was taken down. A takedown is permanent: republishing " + "cannot revive it, whatever token is presented. Publish the content as " + "a new relic instead."
1283
+ };
895
1284
  function toolError(error) {
896
1285
  if (error instanceof PublishError) {
897
1286
  return {
@@ -901,8 +1290,15 @@ function toolError(error) {
901
1290
  };
902
1291
  }
903
1292
  if (error instanceof ServerRefusal) {
1293
+ const guidance = REFUSAL_GUIDANCE[error.code];
904
1294
  return {
905
- content: [{ type: "text", text: `${error.code}: ${error.message}` }],
1295
+ content: [
1296
+ {
1297
+ type: "text",
1298
+ text: `${error.code}: ${error.message}` + (guidance === undefined ? "" : `
1299
+ ${guidance}`)
1300
+ }
1301
+ ],
906
1302
  structuredContent: { code: error.code, ...error.problem },
907
1303
  isError: true
908
1304
  };
@@ -937,6 +1333,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
937
1333
  what coarse class it was declared as, the publishing IP, and when it was
938
1334
  fetched. Never the contents, and never the key.
939
1335
 
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
1339
+ 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.
1343
+
940
1344
  What this does NOT protect against: the key is returned to your agent in the
941
1345
  URL, so it enters the model's context and your session transcript. That is
942
1346
  structural, not a defect. Anyone who can read this conversation can open the
@@ -1044,9 +1448,9 @@ function createHttpHandler(deps, options = {}) {
1044
1448
 
1045
1449
  // src/installer.ts
1046
1450
  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";
1451
+ import { copyFile, mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
1452
+ import { homedir as homedir2 } from "node:os";
1453
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
1050
1454
  import { fileURLToPath } from "node:url";
1051
1455
 
1052
1456
  // src/install.ts
@@ -1094,6 +1498,12 @@ var HARNESSES = [
1094
1498
  configPath: ".codex/config.toml"
1095
1499
  }
1096
1500
  ];
1501
+ var DEFAULT_SERVER_SPEC = {
1502
+ name: "relic",
1503
+ command: "npx",
1504
+ args: ["-y", "relic-mcp@latest"],
1505
+ env: {}
1506
+ };
1097
1507
 
1098
1508
  class UnknownHarnessError extends Error {
1099
1509
  }
@@ -1269,10 +1679,10 @@ function parseArgs(argv) {
1269
1679
  return options;
1270
1680
  }
1271
1681
  function packageRoot() {
1272
- return resolve(dirname(fileURLToPath(import.meta.url)), "..");
1682
+ return resolve(dirname2(fileURLToPath(import.meta.url)), "..");
1273
1683
  }
1274
1684
  async function exists(path) {
1275
- return readFile2(path).then(() => true).catch(() => false);
1685
+ return readFile3(path).then(() => true).catch(() => false);
1276
1686
  }
1277
1687
  async function runInstall(argv) {
1278
1688
  let options;
@@ -1284,12 +1694,7 @@ async function runInstall(argv) {
1284
1694
  ${USAGE}`);
1285
1695
  process.exit(2);
1286
1696
  }
1287
- const spec = {
1288
- name: options.name,
1289
- command: "npx",
1290
- args: ["-y", "relic-mcp"],
1291
- env: {}
1292
- };
1697
+ const spec = { ...DEFAULT_SERVER_SPEC, name: options.name };
1293
1698
  if (options.print && options.client === undefined) {
1294
1699
  process.stdout.write(snippetFor(withOrigin(spec, options)));
1295
1700
  return;
@@ -1303,15 +1708,15 @@ ${USAGE}`);
1303
1708
  await installClaudeCode(options);
1304
1709
  return;
1305
1710
  }
1306
- const home = homedir();
1711
+ const home = homedir2();
1307
1712
  const harness = HARNESSES.find((h) => h.id === options.client);
1308
1713
  if (harness === undefined) {
1309
1714
  process.stderr.write(`Unknown client ${options.client}. Known: ` + `${HARNESSES.map((h) => h.id).join(", ")}
1310
1715
  `);
1311
1716
  process.exit(2);
1312
1717
  }
1313
- const path = join(home, harness.configPath);
1314
- const existing = await readFile2(path, "utf8").catch(() => {
1718
+ const path = join2(home, harness.configPath);
1719
+ const existing = await readFile3(path, "utf8").catch(() => {
1315
1720
  return;
1316
1721
  });
1317
1722
  let plan;
@@ -1339,8 +1744,8 @@ ${USAGE}`);
1339
1744
  if (existing !== undefined) {
1340
1745
  await copyFile(path, `${path}.relic-backup`);
1341
1746
  }
1342
- await mkdir(dirname(path), { recursive: true });
1343
- await writeFile(path, plan.contents);
1747
+ await mkdir2(dirname2(path), { recursive: true });
1748
+ await writeFile2(path, plan.contents);
1344
1749
  process.stdout.write(`${plan.replaced ? "Replaced" : "Added"} "${resolved.name}" in ${path}
1345
1750
  ` + (existing === undefined ? "" : `Previous config saved to ${path}.relic-backup
1346
1751
  `) + `Restart ${harness.label} to pick it up.
@@ -1348,7 +1753,7 @@ ${USAGE}`);
1348
1753
  }
1349
1754
  async function installClaudeCode(options) {
1350
1755
  const root = packageRoot();
1351
- if (!await exists(join(root, ".claude-plugin", "marketplace.json"))) {
1756
+ if (!await exists(join2(root, ".claude-plugin", "marketplace.json"))) {
1352
1757
  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
1758
  `);
1354
1759
  process.exit(1);
@@ -1382,14 +1787,14 @@ function withOrigin(spec, options) {
1382
1787
  } catch (error) {
1383
1788
  process.stderr.write(`${error.message}
1384
1789
 
1385
- Pass --origin https://your-relic-service
1790
+ Pass --origin https://relik.link
1386
1791
  `);
1387
1792
  process.exit(2);
1388
1793
  }
1389
1794
  return { ...spec, env: { RELIC_SERVICE_ORIGIN: origin } };
1390
1795
  }
1391
1796
  async function reportDetected() {
1392
- const home = homedir();
1797
+ const home = homedir2();
1393
1798
  const lines = ["Harnesses detected here:", ""];
1394
1799
  for (const harness of HARNESSES) {
1395
1800
  if (harness.format === "plugin") {
@@ -1397,8 +1802,8 @@ async function reportDetected() {
1397
1802
  lines.push(` ${found2.status === 0 ? "*" : " "} ${harness.id.padEnd(15)}${harness.label}`);
1398
1803
  continue;
1399
1804
  }
1400
- const dir = dirname(join(home, harness.configPath));
1401
- const found = await stat(dir).then((info) => info.isDirectory()).catch(() => false);
1805
+ const dir = dirname2(join2(home, harness.configPath));
1806
+ const found = await stat2(dir).then((info) => info.isDirectory()).catch(() => false);
1402
1807
  lines.push(` ${found ? "*" : " "} ${harness.id.padEnd(15)}${harness.label}`);
1403
1808
  }
1404
1809
  lines.push("", "Install with:", " relic-mcp install --client <id>", "");