relic-mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +59 -8
- package/dist/relic-mcp.js +444 -49
- package/mcp-servers.json +3 -3
- package/package.json +1 -1
- package/skills/relic/SKILL.md +43 -9
- 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 +315 -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,10 +1093,11 @@ var DESCRIBE_TOOL_DEFINITION = {
|
|
|
787
1093
|
additionalProperties: false
|
|
788
1094
|
}
|
|
789
1095
|
};
|
|
1096
|
+
var SERVER_VERSION = "0.3.0";
|
|
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: {} };
|
|
796
1103
|
async function handleMessage(message, deps) {
|
|
@@ -831,7 +1138,13 @@ async function handleMessage(message, deps) {
|
|
|
831
1138
|
return {
|
|
832
1139
|
jsonrpc: "2.0",
|
|
833
1140
|
id: id2,
|
|
834
|
-
result: {
|
|
1141
|
+
result: {
|
|
1142
|
+
tools: [
|
|
1143
|
+
TOOL_DEFINITION,
|
|
1144
|
+
REPUBLISH_TOOL_DEFINITION,
|
|
1145
|
+
DESCRIBE_TOOL_DEFINITION
|
|
1146
|
+
]
|
|
1147
|
+
}
|
|
835
1148
|
};
|
|
836
1149
|
case "tools/call":
|
|
837
1150
|
return callTool(id2, message.params ?? {}, deps);
|
|
@@ -839,6 +1152,12 @@ async function handleMessage(message, deps) {
|
|
|
839
1152
|
return errorResponse(id2, ERROR_CODES.methodNotFound, `unknown method: ${message.method}`);
|
|
840
1153
|
}
|
|
841
1154
|
}
|
|
1155
|
+
function isolationNote(rendererClass) {
|
|
1156
|
+
if (rendererClass !== "html" && rendererClass !== "jsx")
|
|
1157
|
+
return "";
|
|
1158
|
+
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.
|
|
1159
|
+
`;
|
|
1160
|
+
}
|
|
842
1161
|
async function callTool(id2, params, deps) {
|
|
843
1162
|
if (params["name"] === DESCRIBE_TOOL_NAME) {
|
|
844
1163
|
return {
|
|
@@ -852,12 +1171,16 @@ async function callTool(id2, params, deps) {
|
|
|
852
1171
|
key_transmitted_to_service: false,
|
|
853
1172
|
plaintext_transmitted_to_service: false,
|
|
854
1173
|
ciphertext_destination: "object storage, via a signed URL",
|
|
1174
|
+
local_publish_state: "relic id, key, and publish token per relic, written 0600 under " + "the user config directory; never printed, never sent",
|
|
855
1175
|
service_origin: deps.serviceOrigin
|
|
856
1176
|
},
|
|
857
1177
|
isError: false
|
|
858
1178
|
}
|
|
859
1179
|
};
|
|
860
1180
|
}
|
|
1181
|
+
if (params["name"] === REPUBLISH_TOOL_NAME) {
|
|
1182
|
+
return callRepublish(id2, params, deps);
|
|
1183
|
+
}
|
|
861
1184
|
if (params["name"] !== TOOL_NAME) {
|
|
862
1185
|
return errorResponse(id2, ERROR_CODES.invalidParams, `unknown tool: ${String(params["name"])}`);
|
|
863
1186
|
}
|
|
@@ -867,8 +1190,12 @@ async function callTool(id2, params, deps) {
|
|
|
867
1190
|
return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
|
|
868
1191
|
}
|
|
869
1192
|
const filename = typeof args["filename"] === "string" ? args["filename"] : undefined;
|
|
1193
|
+
const ttlDays = parseTtlDays(args["ttl_days"]);
|
|
1194
|
+
if (!ttlDays.ok) {
|
|
1195
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, `\`ttl_days\` must be an integer between 1 and ${MAX_TTL_DAYS}, or ` + "omitted to keep the relic until it is deleted");
|
|
1196
|
+
}
|
|
870
1197
|
try {
|
|
871
|
-
const result = await publish({ path, filename }, deps);
|
|
1198
|
+
const result = await publish({ path, filename, ttl_days: ttlDays.days }, deps);
|
|
872
1199
|
return {
|
|
873
1200
|
jsonrpc: "2.0",
|
|
874
1201
|
id: id2,
|
|
@@ -876,11 +1203,51 @@ async function callTool(id2, params, deps) {
|
|
|
876
1203
|
content: [
|
|
877
1204
|
{
|
|
878
1205
|
type: "text",
|
|
879
|
-
text: `Published ${result.filename} as a relic.
|
|
1206
|
+
text: `Published ${result.filename} as version 1 of a new relic.
|
|
880
1207
|
|
|
881
|
-
|
|
1208
|
+
` + `${result.url}
|
|
882
1209
|
|
|
883
|
-
` + `Expires ${result.relic_expires_at}. Anyone with this link,
|
|
1210
|
+
` + (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.
|
|
1211
|
+
` + isolationNote(result.renderer_class) + `What Relic knows: ${result.disclosure_url}`
|
|
1212
|
+
}
|
|
1213
|
+
],
|
|
1214
|
+
structuredContent: result,
|
|
1215
|
+
isError: false
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
async function callRepublish(id2, params, deps) {
|
|
1223
|
+
const args = params["arguments"] ?? {};
|
|
1224
|
+
const relicId = args["relic_id"];
|
|
1225
|
+
if (typeof relicId !== "string" || relicId.length === 0) {
|
|
1226
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`relic_id` is required and must be a string");
|
|
1227
|
+
}
|
|
1228
|
+
const path = args["path"];
|
|
1229
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
1230
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
|
|
1231
|
+
}
|
|
1232
|
+
const filename = typeof args["filename"] === "string" ? args["filename"] : undefined;
|
|
1233
|
+
const ttlDays = parseTtlDays(args["ttl_days"]);
|
|
1234
|
+
if (!ttlDays.ok) {
|
|
1235
|
+
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");
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
const result = await republish({ relic_id: relicId, path, filename, ttl_days: ttlDays.days }, deps);
|
|
1239
|
+
return {
|
|
1240
|
+
jsonrpc: "2.0",
|
|
1241
|
+
id: id2,
|
|
1242
|
+
result: {
|
|
1243
|
+
content: [
|
|
1244
|
+
{
|
|
1245
|
+
type: "text",
|
|
1246
|
+
text: `Republished ${result.filename} as version ${result.version} ` + `of relic ${result.relic_id}.
|
|
1247
|
+
|
|
1248
|
+
` + "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.
|
|
1249
|
+
|
|
1250
|
+
` + (result.relic_expires_at === null ? "The relic does not expire; it is kept until it is deleted." : `Expires ${result.relic_expires_at}.`) + `
|
|
884
1251
|
` + `What Relic knows: ${result.disclosure_url}`
|
|
885
1252
|
}
|
|
886
1253
|
],
|
|
@@ -892,6 +1259,18 @@ ${result.url}
|
|
|
892
1259
|
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
893
1260
|
}
|
|
894
1261
|
}
|
|
1262
|
+
function parseTtlDays(raw) {
|
|
1263
|
+
if (raw === undefined || raw === null)
|
|
1264
|
+
return { ok: true, days: undefined };
|
|
1265
|
+
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1 || raw > MAX_TTL_DAYS) {
|
|
1266
|
+
return { ok: false };
|
|
1267
|
+
}
|
|
1268
|
+
return { ok: true, days: raw };
|
|
1269
|
+
}
|
|
1270
|
+
var REFUSAL_GUIDANCE = {
|
|
1271
|
+
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.",
|
|
1272
|
+
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."
|
|
1273
|
+
};
|
|
895
1274
|
function toolError(error) {
|
|
896
1275
|
if (error instanceof PublishError) {
|
|
897
1276
|
return {
|
|
@@ -901,8 +1280,15 @@ function toolError(error) {
|
|
|
901
1280
|
};
|
|
902
1281
|
}
|
|
903
1282
|
if (error instanceof ServerRefusal) {
|
|
1283
|
+
const guidance = REFUSAL_GUIDANCE[error.code];
|
|
904
1284
|
return {
|
|
905
|
-
content: [
|
|
1285
|
+
content: [
|
|
1286
|
+
{
|
|
1287
|
+
type: "text",
|
|
1288
|
+
text: `${error.code}: ${error.message}` + (guidance === undefined ? "" : `
|
|
1289
|
+
${guidance}`)
|
|
1290
|
+
}
|
|
1291
|
+
],
|
|
906
1292
|
structuredContent: { code: error.code, ...error.problem },
|
|
907
1293
|
isError: true
|
|
908
1294
|
};
|
|
@@ -937,6 +1323,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
|
|
|
937
1323
|
what coarse class it was declared as, the publishing IP, and when it was
|
|
938
1324
|
fetched. Never the contents, and never the key.
|
|
939
1325
|
|
|
1326
|
+
What this keeps on disk: for each relic you publish, its id, its key, and a
|
|
1327
|
+
publish token, in a 0600 file under your user config directory. That record
|
|
1328
|
+
is what lets a relic be republished later, and it is why republishing works
|
|
1329
|
+
only on the machine that published. The token's SHA-256 is the only copy the
|
|
1330
|
+
service ever holds, and neither secret is ever printed or logged. Deleting
|
|
1331
|
+
the file changes nothing for existing links; it only ends this machine's
|
|
1332
|
+
ability to update those relics.
|
|
1333
|
+
|
|
940
1334
|
What this does NOT protect against: the key is returned to your agent in the
|
|
941
1335
|
URL, so it enters the model's context and your session transcript. That is
|
|
942
1336
|
structural, not a defect. Anyone who can read this conversation can open the
|
|
@@ -1044,9 +1438,9 @@ function createHttpHandler(deps, options = {}) {
|
|
|
1044
1438
|
|
|
1045
1439
|
// src/installer.ts
|
|
1046
1440
|
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";
|
|
1441
|
+
import { copyFile, mkdir as mkdir2, readFile as readFile3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1442
|
+
import { homedir as homedir2 } from "node:os";
|
|
1443
|
+
import { dirname as dirname2, join as join2, resolve } from "node:path";
|
|
1050
1444
|
import { fileURLToPath } from "node:url";
|
|
1051
1445
|
|
|
1052
1446
|
// src/install.ts
|
|
@@ -1094,6 +1488,12 @@ var HARNESSES = [
|
|
|
1094
1488
|
configPath: ".codex/config.toml"
|
|
1095
1489
|
}
|
|
1096
1490
|
];
|
|
1491
|
+
var DEFAULT_SERVER_SPEC = {
|
|
1492
|
+
name: "relic",
|
|
1493
|
+
command: "npx",
|
|
1494
|
+
args: ["-y", "relic-mcp@latest"],
|
|
1495
|
+
env: {}
|
|
1496
|
+
};
|
|
1097
1497
|
|
|
1098
1498
|
class UnknownHarnessError extends Error {
|
|
1099
1499
|
}
|
|
@@ -1269,10 +1669,10 @@ function parseArgs(argv) {
|
|
|
1269
1669
|
return options;
|
|
1270
1670
|
}
|
|
1271
1671
|
function packageRoot() {
|
|
1272
|
-
return resolve(
|
|
1672
|
+
return resolve(dirname2(fileURLToPath(import.meta.url)), "..");
|
|
1273
1673
|
}
|
|
1274
1674
|
async function exists(path) {
|
|
1275
|
-
return
|
|
1675
|
+
return readFile3(path).then(() => true).catch(() => false);
|
|
1276
1676
|
}
|
|
1277
1677
|
async function runInstall(argv) {
|
|
1278
1678
|
let options;
|
|
@@ -1284,12 +1684,7 @@ async function runInstall(argv) {
|
|
|
1284
1684
|
${USAGE}`);
|
|
1285
1685
|
process.exit(2);
|
|
1286
1686
|
}
|
|
1287
|
-
const spec = {
|
|
1288
|
-
name: options.name,
|
|
1289
|
-
command: "npx",
|
|
1290
|
-
args: ["-y", "relic-mcp"],
|
|
1291
|
-
env: {}
|
|
1292
|
-
};
|
|
1687
|
+
const spec = { ...DEFAULT_SERVER_SPEC, name: options.name };
|
|
1293
1688
|
if (options.print && options.client === undefined) {
|
|
1294
1689
|
process.stdout.write(snippetFor(withOrigin(spec, options)));
|
|
1295
1690
|
return;
|
|
@@ -1303,15 +1698,15 @@ ${USAGE}`);
|
|
|
1303
1698
|
await installClaudeCode(options);
|
|
1304
1699
|
return;
|
|
1305
1700
|
}
|
|
1306
|
-
const home =
|
|
1701
|
+
const home = homedir2();
|
|
1307
1702
|
const harness = HARNESSES.find((h) => h.id === options.client);
|
|
1308
1703
|
if (harness === undefined) {
|
|
1309
1704
|
process.stderr.write(`Unknown client ${options.client}. Known: ` + `${HARNESSES.map((h) => h.id).join(", ")}
|
|
1310
1705
|
`);
|
|
1311
1706
|
process.exit(2);
|
|
1312
1707
|
}
|
|
1313
|
-
const path =
|
|
1314
|
-
const existing = await
|
|
1708
|
+
const path = join2(home, harness.configPath);
|
|
1709
|
+
const existing = await readFile3(path, "utf8").catch(() => {
|
|
1315
1710
|
return;
|
|
1316
1711
|
});
|
|
1317
1712
|
let plan;
|
|
@@ -1339,8 +1734,8 @@ ${USAGE}`);
|
|
|
1339
1734
|
if (existing !== undefined) {
|
|
1340
1735
|
await copyFile(path, `${path}.relic-backup`);
|
|
1341
1736
|
}
|
|
1342
|
-
await
|
|
1343
|
-
await
|
|
1737
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1738
|
+
await writeFile2(path, plan.contents);
|
|
1344
1739
|
process.stdout.write(`${plan.replaced ? "Replaced" : "Added"} "${resolved.name}" in ${path}
|
|
1345
1740
|
` + (existing === undefined ? "" : `Previous config saved to ${path}.relic-backup
|
|
1346
1741
|
`) + `Restart ${harness.label} to pick it up.
|
|
@@ -1348,7 +1743,7 @@ ${USAGE}`);
|
|
|
1348
1743
|
}
|
|
1349
1744
|
async function installClaudeCode(options) {
|
|
1350
1745
|
const root = packageRoot();
|
|
1351
|
-
if (!await exists(
|
|
1746
|
+
if (!await exists(join2(root, ".claude-plugin", "marketplace.json"))) {
|
|
1352
1747
|
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
1748
|
`);
|
|
1354
1749
|
process.exit(1);
|
|
@@ -1382,14 +1777,14 @@ function withOrigin(spec, options) {
|
|
|
1382
1777
|
} catch (error) {
|
|
1383
1778
|
process.stderr.write(`${error.message}
|
|
1384
1779
|
|
|
1385
|
-
Pass --origin https://
|
|
1780
|
+
Pass --origin https://relik.link
|
|
1386
1781
|
`);
|
|
1387
1782
|
process.exit(2);
|
|
1388
1783
|
}
|
|
1389
1784
|
return { ...spec, env: { RELIC_SERVICE_ORIGIN: origin } };
|
|
1390
1785
|
}
|
|
1391
1786
|
async function reportDetected() {
|
|
1392
|
-
const home =
|
|
1787
|
+
const home = homedir2();
|
|
1393
1788
|
const lines = ["Harnesses detected here:", ""];
|
|
1394
1789
|
for (const harness of HARNESSES) {
|
|
1395
1790
|
if (harness.format === "plugin") {
|
|
@@ -1397,8 +1792,8 @@ async function reportDetected() {
|
|
|
1397
1792
|
lines.push(` ${found2.status === 0 ? "*" : " "} ${harness.id.padEnd(15)}${harness.label}`);
|
|
1398
1793
|
continue;
|
|
1399
1794
|
}
|
|
1400
|
-
const dir =
|
|
1401
|
-
const found = await
|
|
1795
|
+
const dir = dirname2(join2(home, harness.configPath));
|
|
1796
|
+
const found = await stat2(dir).then((info) => info.isDirectory()).catch(() => false);
|
|
1402
1797
|
lines.push(` ${found ? "*" : " "} ${harness.id.padEnd(15)}${harness.label}`);
|
|
1403
1798
|
}
|
|
1404
1799
|
lines.push("", "Install with:", " relic-mcp install --client <id>", "");
|