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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +59 -8
- package/dist/relic-mcp.js +456 -51
- package/mcp-servers.json +3 -3
- package/package.json +1 -1
- package/skills/relic/SKILL.md +52 -12
- package/src/http.ts +7 -3
- package/src/install.ts +13 -0
- package/src/installer.ts +7 -7
- package/src/publish.ts +139 -31
- package/src/republish.ts +182 -0
- package/src/server.ts +351 -8
- package/src/state.ts +185 -0
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: "
|
|
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 = [
|
|
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
|
|
632
|
-
|
|
633
|
-
|
|
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
|
|
641
|
-
|
|
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
|
|
802
|
+
url,
|
|
644
803
|
relic_id: relicId,
|
|
645
|
-
|
|
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
|
|
658
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
1218
|
+
` + `${result.url}
|
|
882
1219
|
|
|
883
|
-
` + `Expires ${result.relic_expires_at}. Anyone with this link,
|
|
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: [
|
|
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
|
|
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(
|
|
1682
|
+
return resolve(dirname2(fileURLToPath(import.meta.url)), "..");
|
|
1273
1683
|
}
|
|
1274
1684
|
async function exists(path) {
|
|
1275
|
-
return
|
|
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 =
|
|
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 =
|
|
1314
|
-
const existing = await
|
|
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
|
|
1343
|
-
await
|
|
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(
|
|
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://
|
|
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 =
|
|
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 =
|
|
1401
|
-
const found = await
|
|
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>", "");
|