relic-mcp 0.3.2 → 0.4.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/dist/relic-mcp.js CHANGED
@@ -117,6 +117,179 @@ class ContentTooLargeError extends RelicFormatError {
117
117
  }
118
118
  }
119
119
 
120
+ class MalformedCommentError extends RelicFormatError {
121
+ name = "MalformedCommentError";
122
+ }
123
+
124
+ class CommentDecryptFailedError extends RelicFormatError {
125
+ name = "CommentDecryptFailedError";
126
+ constructor() {
127
+ super("the comment failed authentication");
128
+ }
129
+ }
130
+
131
+ class CommentTooLargeError extends RelicFormatError {
132
+ field;
133
+ declaredBytes;
134
+ limitBytes;
135
+ name = "CommentTooLargeError";
136
+ constructor(field, declaredBytes, limitBytes) {
137
+ super(`comment ${field} is ${declaredBytes} bytes of UTF-8, over the ` + `${limitBytes} cap`);
138
+ this.field = field;
139
+ this.declaredBytes = declaredBytes;
140
+ this.limitBytes = limitBytes;
141
+ }
142
+ }
143
+
144
+ // ../relic-format/src/comments.ts
145
+ var COMMENT_INFO = new TextEncoder().encode("relic/comments/v1");
146
+ var COMMENT_NONCE_BYTES = 12;
147
+ var COMMENT_BODY_LIMIT_BYTES = 4096;
148
+ var COMMENT_DISPLAY_NAME_LIMIT_BYTES = 64;
149
+ var COMMENT_ANCHOR_QUOTE_LIMIT_BYTES = 512;
150
+ async function deriveCommentKey(keyBytes) {
151
+ const material = await crypto.subtle.importKey("raw", toBufferSource(keyBytes), "HKDF", false, ["deriveBits"]);
152
+ const bits = await crypto.subtle.deriveBits({
153
+ name: "HKDF",
154
+ hash: "SHA-256",
155
+ salt: new Uint8Array(0),
156
+ info: COMMENT_INFO
157
+ }, material, 128);
158
+ return crypto.subtle.importKey("raw", bits, { name: "AES-GCM" }, false, [
159
+ "encrypt",
160
+ "decrypt"
161
+ ]);
162
+ }
163
+ async function encryptComment(commentKey, plaintext) {
164
+ const bodyBytes = new TextEncoder().encode(plaintext.body);
165
+ if (bodyBytes.length > COMMENT_BODY_LIMIT_BYTES) {
166
+ throw new CommentTooLargeError("body", bodyBytes.length, COMMENT_BODY_LIMIT_BYTES);
167
+ }
168
+ if (plaintext.display_name !== null) {
169
+ const nameBytes = new TextEncoder().encode(plaintext.display_name).length;
170
+ if (nameBytes > COMMENT_DISPLAY_NAME_LIMIT_BYTES) {
171
+ throw new CommentTooLargeError("display_name", nameBytes, COMMENT_DISPLAY_NAME_LIMIT_BYTES);
172
+ }
173
+ }
174
+ if (plaintext.anchor != null && plaintext.anchor.kind === "text") {
175
+ const quoteBytes = new TextEncoder().encode(plaintext.anchor.quote).length;
176
+ if (quoteBytes > COMMENT_ANCHOR_QUOTE_LIMIT_BYTES) {
177
+ throw new CommentTooLargeError("anchor", quoteBytes, COMMENT_ANCHOR_QUOTE_LIMIT_BYTES);
178
+ }
179
+ }
180
+ const encoded = new TextEncoder().encode(JSON.stringify({
181
+ body: plaintext.body,
182
+ display_name: plaintext.display_name,
183
+ ...plaintext.anchor == null ? {} : { anchor: plaintext.anchor }
184
+ }));
185
+ const nonce = crypto.getRandomValues(new Uint8Array(COMMENT_NONCE_BYTES));
186
+ const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: toBufferSource(nonce) }, commentKey, toBufferSource(encoded)));
187
+ const framed = new Uint8Array(nonce.length + sealed.length);
188
+ framed.set(nonce, 0);
189
+ framed.set(sealed, nonce.length);
190
+ return base64url(framed);
191
+ }
192
+ async function decryptComment(commentKey, ciphertext) {
193
+ const framed = decodeBase64url(ciphertext);
194
+ if (framed.length <= COMMENT_NONCE_BYTES) {
195
+ throw new MalformedCommentError(`comment is ${framed.length} bytes, too short to carry a ` + `${COMMENT_NONCE_BYTES}-byte nonce and a tag`);
196
+ }
197
+ let opened;
198
+ try {
199
+ opened = new Uint8Array(await crypto.subtle.decrypt({
200
+ name: "AES-GCM",
201
+ iv: toBufferSource(framed.subarray(0, COMMENT_NONCE_BYTES))
202
+ }, commentKey, toBufferSource(framed.subarray(COMMENT_NONCE_BYTES))));
203
+ } catch {
204
+ throw new CommentDecryptFailedError;
205
+ }
206
+ let parsed;
207
+ try {
208
+ parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(opened));
209
+ } catch {
210
+ throw new MalformedCommentError("comment plaintext is not valid JSON");
211
+ }
212
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
213
+ throw new MalformedCommentError("comment plaintext is not a JSON object");
214
+ }
215
+ const fields = parsed;
216
+ const unknown = Object.keys(fields).filter((key) => key !== "body" && key !== "display_name" && key !== "anchor");
217
+ if (unknown.length > 0) {
218
+ throw new MalformedCommentError(`comment carries unknown field(s): ${unknown.join(", ")}`);
219
+ }
220
+ const body = fields["body"];
221
+ if (typeof body !== "string") {
222
+ throw new MalformedCommentError("comment body is missing or not a string");
223
+ }
224
+ const displayName = fields["display_name"];
225
+ if (displayName !== null && typeof displayName !== "string") {
226
+ throw new MalformedCommentError("comment display_name is present and is neither a string nor null");
227
+ }
228
+ return {
229
+ body,
230
+ display_name: displayName,
231
+ anchor: parseAnchor(fields["anchor"])
232
+ };
233
+ }
234
+ function parseAnchor(value) {
235
+ if (value === undefined)
236
+ return null;
237
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
238
+ throw new MalformedCommentError("comment anchor is not an object");
239
+ }
240
+ const anchor = value;
241
+ const kind = anchor["kind"];
242
+ if (kind === "text") {
243
+ const quote = anchor["quote"];
244
+ if (typeof quote !== "string" || quote.length === 0) {
245
+ throw new MalformedCommentError("text anchor quote is missing");
246
+ }
247
+ const extra = Object.keys(anchor).filter((key) => key !== "kind" && key !== "quote");
248
+ if (extra.length > 0) {
249
+ throw new MalformedCommentError(`text anchor carries unknown field(s): ${extra.join(", ")}`);
250
+ }
251
+ return { kind: "text", quote };
252
+ }
253
+ if (kind === "pin") {
254
+ const x = anchor["x"];
255
+ const y = anchor["y"];
256
+ if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1) {
257
+ throw new MalformedCommentError("pin anchor is out of unit range");
258
+ }
259
+ const extra = Object.keys(anchor).filter((key) => key !== "kind" && key !== "x" && key !== "y");
260
+ if (extra.length > 0) {
261
+ throw new MalformedCommentError(`pin anchor carries unknown field(s): ${extra.join(", ")}`);
262
+ }
263
+ return { kind: "pin", x, y };
264
+ }
265
+ throw new MalformedCommentError("comment anchor kind is not text or pin");
266
+ }
267
+ function base64url(bytes) {
268
+ let binary = "";
269
+ for (const byte of bytes)
270
+ binary += String.fromCharCode(byte);
271
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
272
+ }
273
+ function decodeBase64url(encoded) {
274
+ if (!/^[A-Za-z0-9_-]*$/.test(encoded)) {
275
+ throw new MalformedCommentError("comment is not unpadded base64url");
276
+ }
277
+ const padded = encoded.replace(/-/g, "+").replace(/_/g, "/");
278
+ let binary;
279
+ try {
280
+ binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="));
281
+ } catch {
282
+ throw new MalformedCommentError("comment is not decodable base64url");
283
+ }
284
+ const bytes = new Uint8Array(binary.length);
285
+ for (let index = 0;index < binary.length; index += 1) {
286
+ bytes[index] = binary.charCodeAt(index);
287
+ }
288
+ return bytes;
289
+ }
290
+ function toBufferSource(bytes) {
291
+ return bytes.slice().buffer;
292
+ }
120
293
  // ../relic-format/src/envelope.ts
121
294
  var MAX_FILENAME_BYTES = 1024;
122
295
  var MAX_MIMETYPE_BYTES = 255;
@@ -237,17 +410,17 @@ function recordCapacity(rs) {
237
410
  var CEK_INFO = new TextEncoder().encode("Content-Encoding: aes128gcm\x00");
238
411
  var NONCE_INFO = new TextEncoder().encode("Content-Encoding: nonce\x00");
239
412
  async function deriveKeys(ikm, salt) {
240
- const material = await crypto.subtle.importKey("raw", toBufferSource(ikm), "HKDF", false, ["deriveBits"]);
413
+ const material = await crypto.subtle.importKey("raw", toBufferSource2(ikm), "HKDF", false, ["deriveBits"]);
241
414
  const cekBits = await crypto.subtle.deriveBits({
242
415
  name: "HKDF",
243
416
  hash: "SHA-256",
244
- salt: toBufferSource(salt),
417
+ salt: toBufferSource2(salt),
245
418
  info: CEK_INFO
246
419
  }, material, 128);
247
420
  const nonceBits = await crypto.subtle.deriveBits({
248
421
  name: "HKDF",
249
422
  hash: "SHA-256",
250
- salt: toBufferSource(salt),
423
+ salt: toBufferSource2(salt),
251
424
  info: NONCE_INFO
252
425
  }, material, 96);
253
426
  const cek = await crypto.subtle.importKey("raw", cekBits, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
@@ -282,10 +455,10 @@ async function encryptRecord(keys, seq, data, isLast, targetDataBytes = data.len
282
455
  const plaintext = new Uint8Array(targetDataBytes + DELIMITER_BYTES);
283
456
  plaintext.set(data, 0);
284
457
  plaintext[data.length] = isLast ? 2 : 1;
285
- const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv: toBufferSource(recordNonce(keys.nonceBase, seq)) }, keys.cek, toBufferSource(plaintext));
458
+ const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv: toBufferSource2(recordNonce(keys.nonceBase, seq)) }, keys.cek, toBufferSource2(plaintext));
286
459
  return new Uint8Array(sealed);
287
460
  }
288
- function toBufferSource(bytes) {
461
+ function toBufferSource2(bytes) {
289
462
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
290
463
  }
291
464
 
@@ -1033,9 +1206,25 @@ async function postJson(deps, url, body) {
1033
1206
  } catch (error) {
1034
1207
  throw new PublishError("service_unreachable", `could not reach ${url}: ${error.message}`);
1035
1208
  }
1036
- const parsed = await response.json().catch(() => ({}));
1209
+ const parsed = await readJson(response);
1210
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
1211
+ }
1212
+ async function getJson(deps, url) {
1213
+ let response;
1214
+ try {
1215
+ response = await deps.fetch(url);
1216
+ } catch (error) {
1217
+ throw new PublishError("service_unreachable", `could not reach ${url}: ${error.message}`);
1218
+ }
1219
+ return readJson(response);
1220
+ }
1221
+ async function readJson(response) {
1222
+ const parsed = await response.json().catch(() => {
1223
+ return;
1224
+ });
1037
1225
  if (!response.ok) {
1038
- throw new ServerRefusal(String(parsed["code"] ?? "unknown"), response.status, parsed);
1226
+ const problem = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
1227
+ throw new ServerRefusal(String(problem["code"] ?? "unknown"), response.status, problem);
1039
1228
  }
1040
1229
  return parsed;
1041
1230
  }
@@ -1104,6 +1293,112 @@ function guessMimetype(filename, rendererClass) {
1104
1293
  return CLASS_FALLBACK[rendererClass];
1105
1294
  }
1106
1295
 
1296
+ // src/comments.ts
1297
+ async function readComments(relicId, deps) {
1298
+ const state = await localState(relicId);
1299
+ const listed = await getJson(deps, `${deps.serviceOrigin}/api/relics/${relicId}/comments`);
1300
+ if (!Array.isArray(listed)) {
1301
+ throw new PublishError("app_response_unusable", "the comment list did not come back as a JSON array, so there is no " + "way to tell an empty conversation from an unreadable response", { relic_id: relicId, leg: "comments" });
1302
+ }
1303
+ const commentKey = await deriveCommentKey(decodeKey(state.key));
1304
+ const comments2 = [];
1305
+ let unreadable = 0;
1306
+ for (const [index, entry] of listed.entries()) {
1307
+ const row = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry : {};
1308
+ const commentId = typeof row["comment_id"] === "string" ? row["comment_id"] : `unidentified-${index}`;
1309
+ const author = typeof row["author"] === "string" ? row["author"] : "unknown";
1310
+ const createdAt = typeof row["created_at"] === "string" ? row["created_at"] : "unknown";
1311
+ const ciphertext = row["ciphertext"];
1312
+ if (typeof ciphertext !== "string") {
1313
+ unreadable += 1;
1314
+ comments2.push({
1315
+ comment_id: commentId,
1316
+ author,
1317
+ created_at: createdAt,
1318
+ display_name: null,
1319
+ body: null,
1320
+ readable: false,
1321
+ unreadable_reason: "the row carried no ciphertext"
1322
+ });
1323
+ continue;
1324
+ }
1325
+ try {
1326
+ const plaintext = await decryptComment(commentKey, ciphertext);
1327
+ comments2.push({
1328
+ comment_id: commentId,
1329
+ author,
1330
+ created_at: createdAt,
1331
+ display_name: plaintext.display_name,
1332
+ body: plaintext.body,
1333
+ readable: true,
1334
+ unreadable_reason: null
1335
+ });
1336
+ } catch (error) {
1337
+ unreadable += 1;
1338
+ comments2.push({
1339
+ comment_id: commentId,
1340
+ author,
1341
+ created_at: createdAt,
1342
+ display_name: null,
1343
+ body: null,
1344
+ readable: false,
1345
+ unreadable_reason: `it did not decrypt under this relic's comment key: ${error.message}`
1346
+ });
1347
+ }
1348
+ }
1349
+ return {
1350
+ relic_id: relicId,
1351
+ count: comments2.length,
1352
+ unreadable_count: unreadable,
1353
+ comments: comments2
1354
+ };
1355
+ }
1356
+ async function postComment(input, deps) {
1357
+ const state = await localState(input.relic_id);
1358
+ const bodyBytes = new TextEncoder().encode(input.body).length;
1359
+ if (input.body.trim().length === 0) {
1360
+ throw new PublishError("local_comment_body_empty", "a comment needs a body. An empty one is attributable noise nobody can " + "answer.");
1361
+ }
1362
+ if (bodyBytes > COMMENT_BODY_LIMIT_BYTES) {
1363
+ throw new PublishError("local_comment_body_too_long", `the comment body is ${bodyBytes} bytes of UTF-8 and the limit is ` + `${COMMENT_BODY_LIMIT_BYTES}. Shorten it; a truncated comment would ` + "change what it says.", { body_bytes: bodyBytes, limit_bytes: COMMENT_BODY_LIMIT_BYTES });
1364
+ }
1365
+ const displayName = input.display_name ?? null;
1366
+ if (displayName !== null) {
1367
+ const nameBytes = new TextEncoder().encode(displayName).length;
1368
+ if (nameBytes > COMMENT_DISPLAY_NAME_LIMIT_BYTES) {
1369
+ throw new PublishError("local_comment_name_too_long", `the display name is ${nameBytes} bytes of UTF-8 and the limit is ` + `${COMMENT_DISPLAY_NAME_LIMIT_BYTES}.`, { name_bytes: nameBytes, limit_bytes: COMMENT_DISPLAY_NAME_LIMIT_BYTES });
1370
+ }
1371
+ }
1372
+ const commentKey = await deriveCommentKey(decodeKey(state.key));
1373
+ const ciphertext = await encryptComment(commentKey, {
1374
+ body: input.body,
1375
+ display_name: displayName
1376
+ });
1377
+ const posted = await postJson(deps, `${deps.serviceOrigin}/api/relics/${input.relic_id}/comments`, { publish_token: state.publish_token, ciphertext });
1378
+ return {
1379
+ relic_id: input.relic_id,
1380
+ comment_id: String(posted["comment_id"]),
1381
+ author: String(posted["author"]),
1382
+ created_at: String(posted["created_at"])
1383
+ };
1384
+ }
1385
+ async function localState(relicId) {
1386
+ if (!isValidRelicId(relicId)) {
1387
+ throw new PublishError("no_local_publish_state", `"${relicId}" is not a relic id. These tools take the 26-character id ` + "the original publish returned, never the share URL: the URL carries " + "the key in its fragment, and passing it would put the key in this " + "transcript for nothing.");
1388
+ }
1389
+ try {
1390
+ const loaded = await loadPublishState(relicId);
1391
+ if (loaded === undefined) {
1392
+ throw new PublishError("no_local_publish_state", `relic ${relicId} was published from another machine, so its ` + "comments can be neither read nor written here. The key that " + "decrypts a comment and the publish token that authorizes one live " + "only on the machine that made the first publish, and neither can " + "be reconstructed from the link or from the service. Open the " + "relic's own page to read its comments, or ask whoever published it.");
1393
+ }
1394
+ return loaded;
1395
+ } catch (error) {
1396
+ if (error instanceof PublishError)
1397
+ throw error;
1398
+ throw new PublishError("local_state_unreadable", error.message);
1399
+ }
1400
+ }
1401
+
1107
1402
  // src/republish.ts
1108
1403
  async function republish(input, deps) {
1109
1404
  if (!isValidRelicId(input.relic_id)) {
@@ -1162,11 +1457,15 @@ var TOOL_NAME = "relic_publish";
1162
1457
  var DESCRIBE_TOOL_NAME = "relic_describe_client";
1163
1458
  var REPUBLISH_TOOL_NAME = "relic_republish";
1164
1459
  var LOOKUP_TOOL_NAME = "relic_lookup_source";
1460
+ var READ_COMMENTS_TOOL_NAME = "relic_read_comments";
1461
+ var COMMENT_TOOL_NAME = "relic_comment";
1462
+ var COMMENT_MACHINE_BOUNDARY = "Only works for a relic this machine published: the comment key is derived " + "from that relic's key, which lives in local publish state and nowhere the " + "service can reach.";
1165
1463
  var MAX_TTL_DAYS = 3650;
1464
+ var VERSION_HISTORY_DISCLOSURE = "Anyone holding a relic's link can fetch every version it has ever held, " + "so republishing does not withdraw earlier content.";
1166
1465
  var TOOL_DEFINITION = {
1167
1466
  name: TOOL_NAME,
1168
1467
  title: "Publish a relic",
1169
- description: "Encrypt a file on this machine and publish it as a new relic, returning " + "a shareable URL. Publishing an update this way costs a second URL that " + "nobody holding the first one will ever see; use relic_republish instead " + "so the existing URL keeps working. The encryption key is generated " + "locally and never sent to the service. Takes a filesystem path, never " + "inline content.",
1468
+ description: "Encrypt a file on this machine and publish it as a new relic, returning " + "a shareable URL. Publishing an update this way costs a second URL that " + "nobody holding the first one will ever see; use relic_republish instead " + "so the existing URL keeps working. " + VERSION_HISTORY_DISCLOSURE + " The encryption key is generated locally and never sent to the service. " + "Takes a filesystem path, never " + "inline content.",
1170
1469
  inputSchema: {
1171
1470
  type: "object",
1172
1471
  properties: {
@@ -1227,7 +1526,7 @@ var TOOL_DEFINITION = {
1227
1526
  var REPUBLISH_TOOL_DEFINITION = {
1228
1527
  name: REPUBLISH_TOOL_NAME,
1229
1528
  title: "Republish a relic",
1230
- 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.",
1529
+ description: "Publish a new version of a relic this machine originally published, " + "encrypting under the same key so the existing share URL keeps working. " + VERSION_HISTORY_DISCLOSURE + " Only possible from the machine that holds the relic's key and publish " + "token; a relic that was taken down can never be revived.",
1231
1530
  inputSchema: {
1232
1531
  type: "object",
1233
1532
  properties: {
@@ -1336,6 +1635,99 @@ var LOOKUP_TOOL_DEFINITION = {
1336
1635
  additionalProperties: false
1337
1636
  }
1338
1637
  };
1638
+ var READ_COMMENTS_TOOL_DEFINITION = {
1639
+ name: READ_COMMENTS_TOOL_NAME,
1640
+ title: "Read a relic's comments",
1641
+ description: "Read the comments people have left on a relic, oldest first, decrypted " + "on this machine. Use it before changing content somebody was asked to " + "review, and after sharing a link, because a comment is the only way a " + "reader can answer back. " + COMMENT_MACHINE_BOUNDARY + " Takes the relic id, never the share URL: the URL carries the key in " + "its fragment. A comment that will not decrypt is returned marked " + "unreadable rather than dropped, so a shortened list never reads as " + "agreement.",
1642
+ inputSchema: {
1643
+ type: "object",
1644
+ properties: {
1645
+ relic_id: {
1646
+ type: "string",
1647
+ description: "The 26-character relic id the original publish returned."
1648
+ }
1649
+ },
1650
+ required: ["relic_id"],
1651
+ additionalProperties: false
1652
+ },
1653
+ outputSchema: {
1654
+ type: "object",
1655
+ properties: {
1656
+ relic_id: { type: "string" },
1657
+ count: { type: "integer", minimum: 0 },
1658
+ unreadable_count: {
1659
+ type: "integer",
1660
+ minimum: 0,
1661
+ description: "How many of `count` did not decrypt. Above zero means part of the " + "conversation is unread, not absent."
1662
+ },
1663
+ comments: {
1664
+ type: "array",
1665
+ items: {
1666
+ type: "object",
1667
+ properties: {
1668
+ comment_id: { type: "string" },
1669
+ author: {
1670
+ type: "string",
1671
+ description: 'The commenter’s verified email address, or "publisher" ' + "for a comment written with a publish token."
1672
+ },
1673
+ created_at: { type: "string" },
1674
+ display_name: { type: ["string", "null"] },
1675
+ body: { type: ["string", "null"] },
1676
+ readable: { type: "boolean" },
1677
+ unreadable_reason: { type: ["string", "null"] }
1678
+ },
1679
+ required: [
1680
+ "comment_id",
1681
+ "author",
1682
+ "created_at",
1683
+ "display_name",
1684
+ "body",
1685
+ "readable",
1686
+ "unreadable_reason"
1687
+ ],
1688
+ additionalProperties: false
1689
+ }
1690
+ }
1691
+ },
1692
+ required: ["relic_id", "count", "unreadable_count", "comments"],
1693
+ additionalProperties: false
1694
+ }
1695
+ };
1696
+ var COMMENT_TOOL_DEFINITION = {
1697
+ name: COMMENT_TOOL_NAME,
1698
+ title: "Comment on a relic",
1699
+ description: "Leave a comment on a relic this machine published, encrypted here so " + "the service stores ciphertext it cannot read. Everyone holding the " + "link sees it. Attribution is the publish token, so the comment is " + 'attributed to "publisher" rather than to an email address: an agent has ' + "no mailbox and cannot verify one. That is attribution and not " + "authorization. " + COMMENT_MACHINE_BOUNDARY + " Takes the relic id, never the share URL.",
1700
+ inputSchema: {
1701
+ type: "object",
1702
+ properties: {
1703
+ relic_id: {
1704
+ type: "string",
1705
+ description: "The 26-character relic id the original publish returned."
1706
+ },
1707
+ body: {
1708
+ type: "string",
1709
+ description: "The comment text, up to 4096 bytes of UTF-8. It is encrypted " + "before it leaves this machine."
1710
+ },
1711
+ display_name: {
1712
+ type: "string",
1713
+ description: "Optional. A name shown beside the comment, up to 64 bytes of " + "UTF-8. It aliases the attribution for presentation and never " + "replaces it."
1714
+ }
1715
+ },
1716
+ required: ["relic_id", "body"],
1717
+ additionalProperties: false
1718
+ },
1719
+ outputSchema: {
1720
+ type: "object",
1721
+ properties: {
1722
+ relic_id: { type: "string" },
1723
+ comment_id: { type: "string" },
1724
+ author: { type: "string" },
1725
+ created_at: { type: "string" }
1726
+ },
1727
+ required: ["relic_id", "comment_id", "author", "created_at"],
1728
+ additionalProperties: false
1729
+ }
1730
+ };
1339
1731
  var DESCRIBE_TOOL_DEFINITION = {
1340
1732
  name: DESCRIBE_TOOL_NAME,
1341
1733
  title: "Describe the Relic client",
@@ -1346,7 +1738,7 @@ var DESCRIBE_TOOL_DEFINITION = {
1346
1738
  additionalProperties: false
1347
1739
  }
1348
1740
  };
1349
- var SERVER_VERSION = "0.3.2";
1741
+ var SERVER_VERSION = "0.4.0";
1350
1742
  var SERVER_INFO = {
1351
1743
  name: "relic",
1352
1744
  title: "Relic",
@@ -1355,13 +1747,14 @@ var SERVER_INFO = {
1355
1747
  var CAPABILITIES = { tools: {} };
1356
1748
  var INSTRUCTIONS = `Relic encrypts a file on this machine and uploads only ciphertext. The key lives in the URL fragment, which browsers never send to a server.
1357
1749
 
1358
- Five things that change how you should act:
1750
+ Six things that change how you should act:
1359
1751
 
1360
1752
  1. The link is the credential. Anyone holding it, fragment included, can read the file. Do not paste it into a tracker, a log, or a public channel.
1361
1753
  2. Publishing puts the key in this transcript. That is structural rather than a defect, and worth saying plainly when you hand the link over.
1362
- 3. If a source was published before, use relic_republish so its URL keeps working. Publishing it as new costs a second URL that nobody holding the first one will ever see. relic_publish refuses by default, relic_lookup_source recovers the id, and force_new is only for a deliberate second link.
1754
+ 3. Check existing sources with relic_lookup_source. Use relic_republish when found; relic_publish otherwise costs a second URL. ${VERSION_HISTORY_DISCLOSURE}
1363
1755
  4. A relic can be republished only from the machine that published it, which is where its key and publish token are stored. Anywhere else it refuses, and no retry changes that.
1364
- 5. Rendered HTML and JSX run in an isolated frame with no network access. Inline the styles, scripts, fonts, and images a page needs, because a CDN reference renders as nothing. Decide that before you write the file.`;
1756
+ 5. Rendered HTML and JSX run in an isolated frame with no network access. Inline the styles, scripts, fonts, and images a page needs, because a CDN reference renders as nothing. Decide that before you write the file.
1757
+ 6. People can comment on a relic. Read them with relic_read_comments before you change reviewed content, and answer with relic_comment. Both take the relic id, work only on the machine that published, and attribute you as the publisher.`;
1365
1758
  async function handleMessage(message, deps) {
1366
1759
  if (message.id === undefined)
1367
1760
  return;
@@ -1407,6 +1800,8 @@ async function handleMessage(message, deps) {
1407
1800
  TOOL_DEFINITION,
1408
1801
  LOOKUP_TOOL_DEFINITION,
1409
1802
  REPUBLISH_TOOL_DEFINITION,
1803
+ READ_COMMENTS_TOOL_DEFINITION,
1804
+ COMMENT_TOOL_DEFINITION,
1410
1805
  DESCRIBE_TOOL_DEFINITION
1411
1806
  ]
1412
1807
  }
@@ -1437,6 +1832,8 @@ async function callTool(id2, params, deps) {
1437
1832
  plaintext_transmitted_to_service: false,
1438
1833
  ciphertext_destination: "object storage, via a signed URL",
1439
1834
  local_publish_state: "relic id, source identity, key, and publish token per relic, " + "written 0600 under the user config directory; key and token " + "are never printed or sent",
1835
+ comment_encryption: "AES-128-GCM under a key derived from the relic key with a " + "distinct HKDF label, so comment bodies reach the service as " + "ciphertext and the URL fragment is unchanged",
1836
+ comment_attribution: 'the publish token, reported by the service as "publisher"; the ' + "operator learns which identity commented on which relic and when",
1440
1837
  service_origin: deps.serviceOrigin
1441
1838
  },
1442
1839
  isError: false
@@ -1449,6 +1846,12 @@ async function callTool(id2, params, deps) {
1449
1846
  if (params["name"] === REPUBLISH_TOOL_NAME) {
1450
1847
  return callRepublish(id2, params, deps);
1451
1848
  }
1849
+ if (params["name"] === READ_COMMENTS_TOOL_NAME) {
1850
+ return callReadComments(id2, params, deps);
1851
+ }
1852
+ if (params["name"] === COMMENT_TOOL_NAME) {
1853
+ return callComment(id2, params, deps);
1854
+ }
1452
1855
  if (params["name"] !== TOOL_NAME) {
1453
1856
  return errorResponse(id2, ERROR_CODES.invalidParams, `unknown tool: ${String(params["name"])}`);
1454
1857
  }
@@ -1486,7 +1889,8 @@ async function callTool(id2, params, deps) {
1486
1889
 
1487
1890
  ` + (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.
1488
1891
  ` + isolationNote(result.renderer_class) + `What Relic knows: ${result.disclosure_url}`
1489
- }
1892
+ },
1893
+ { type: "text", text: VERSION_HISTORY_DISCLOSURE }
1490
1894
  ],
1491
1895
  structuredContent: result,
1492
1896
  isError: false
@@ -1564,6 +1968,63 @@ async function callRepublish(id2, params, deps) {
1564
1968
 
1565
1969
  ` + (result.relic_expires_at === null ? "The relic does not expire; it is kept until it is deleted." : `Expires ${result.relic_expires_at}.`) + `
1566
1970
  ` + `What Relic knows: ${result.disclosure_url}`
1971
+ },
1972
+ { type: "text", text: VERSION_HISTORY_DISCLOSURE }
1973
+ ],
1974
+ structuredContent: result,
1975
+ isError: false
1976
+ }
1977
+ };
1978
+ } catch (error) {
1979
+ return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1980
+ }
1981
+ }
1982
+ async function callReadComments(id2, params, deps) {
1983
+ const args = params["arguments"] ?? {};
1984
+ const relicId = args["relic_id"];
1985
+ if (typeof relicId !== "string" || relicId.length === 0) {
1986
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`relic_id` is required and must be a string");
1987
+ }
1988
+ try {
1989
+ const result = await readComments(relicId, deps);
1990
+ return {
1991
+ jsonrpc: "2.0",
1992
+ id: id2,
1993
+ result: {
1994
+ content: [{ type: "text", text: commentTranscript(result) }],
1995
+ structuredContent: result,
1996
+ isError: false
1997
+ }
1998
+ };
1999
+ } catch (error) {
2000
+ return { jsonrpc: "2.0", id: id2, result: toolError(error) };
2001
+ }
2002
+ }
2003
+ async function callComment(id2, params, deps) {
2004
+ const args = params["arguments"] ?? {};
2005
+ const relicId = args["relic_id"];
2006
+ if (typeof relicId !== "string" || relicId.length === 0) {
2007
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`relic_id` is required and must be a string");
2008
+ }
2009
+ const body = args["body"];
2010
+ if (typeof body !== "string") {
2011
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`body` is required and must be a string");
2012
+ }
2013
+ const displayName = args["display_name"];
2014
+ if (displayName !== undefined && typeof displayName !== "string") {
2015
+ return errorResponse(id2, ERROR_CODES.invalidParams, "`display_name` must be a string or omitted");
2016
+ }
2017
+ try {
2018
+ const result = await postComment({ relic_id: relicId, body, display_name: displayName }, deps);
2019
+ return {
2020
+ jsonrpc: "2.0",
2021
+ id: id2,
2022
+ result: {
2023
+ content: [
2024
+ {
2025
+ type: "text",
2026
+ text: `Commented on relic ${result.relic_id} as ${result.author}.
2027
+ ` + "Everyone holding the link sees it. The service stored " + "ciphertext it cannot read, and it knows that this address " + "commented on this relic at this time."
1567
2028
  }
1568
2029
  ],
1569
2030
  structuredContent: result,
@@ -1574,6 +2035,23 @@ async function callRepublish(id2, params, deps) {
1574
2035
  return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1575
2036
  }
1576
2037
  }
2038
+ function commentTranscript(result) {
2039
+ if (result.count === 0) {
2040
+ return `No comments on relic ${result.relic_id} yet.`;
2041
+ }
2042
+ const lines = result.comments.map((comment) => {
2043
+ const who = comment.display_name === null ? comment.author : `${comment.display_name} (${comment.author})`;
2044
+ return comment.readable ? `${comment.created_at} ${who}:
2045
+ ${comment.body}` : `${comment.created_at} ${who}:
2046
+ [unreadable: ${comment.unreadable_reason}]`;
2047
+ });
2048
+ const header = result.unreadable_count === 0 ? `${result.count} comment(s) on relic ${result.relic_id}, oldest first.` : `${result.count} comment(s) on relic ${result.relic_id}, oldest ` + `first. ${result.unreadable_count} did not decrypt and are shown as ` + "unreadable rather than dropped, so treat this conversation as " + "partially unread.";
2049
+ return `${header}
2050
+
2051
+ ${lines.join(`
2052
+
2053
+ `)}`;
2054
+ }
1577
2055
  function parseTtlDays(raw) {
1578
2056
  if (raw === undefined || raw === null)
1579
2057
  return { ok: true, days: undefined };
@@ -1584,7 +2062,8 @@ function parseTtlDays(raw) {
1584
2062
  }
1585
2063
  var REFUSAL_GUIDANCE = {
1586
2064
  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.",
1587
- 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."
2065
+ 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.",
2066
+ comment_rate_limited: "The service is rate limiting comments on that relic. The refusal " + "carries retry_after_seconds; wait it out rather than retrying in a " + "loop, which only extends the limit."
1588
2067
  };
1589
2068
  function toolError(error) {
1590
2069
  if (error instanceof PublishError) {
@@ -1609,7 +2088,7 @@ ${guidance}`)
1609
2088
  };
1610
2089
  }
1611
2090
  return {
1612
- content: [{ type: "text", text: `publish failed: ${String(error)}` }],
2091
+ content: [{ type: "text", text: `the call failed: ${String(error)}` }],
1613
2092
  structuredContent: { code: "unknown" },
1614
2093
  isError: true
1615
2094
  };
@@ -1647,6 +2126,17 @@ service ever holds, and neither secret is ever printed or logged. Deleting the
1647
2126
  file changes nothing for existing links; it only ends this machine's ability
1648
2127
  to update those relics.
1649
2128
 
2129
+ What happens with comments: a comment body is encrypted here too, under a key
2130
+ derived from that relic's key with a distinct HKDF label, so the service
2131
+ stores comment ciphertext it cannot read and the URL fragment does not change.
2132
+ Reading comments needs that key, and writing one is authorized by the publish
2133
+ token, so both work only on the machine that published. A comment this client
2134
+ writes is attributed to the publisher rather than an email address, because
2135
+ an agent has no mailbox to verify. What the operator does learn is who
2136
+ commented on which relic and when: for a person that is a verified email
2137
+ address, and that association is a real cost the content's encryption does
2138
+ not cover.
2139
+
1650
2140
  What this does NOT protect against: the key is returned to your agent in the
1651
2141
  URL, so it enters the model's context and your session transcript. That is
1652
2142
  structural, not a defect. Anyone who can read this conversation can open the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Publish a file as an encrypted relic. The key is generated on your machine and never sent to the service.",
5
5
  "license": "MIT",
6
6
  "type": "module",