relic-mcp 0.3.3 → 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,6 +1457,9 @@ 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;
1166
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.";
1167
1465
  var TOOL_DEFINITION = {
@@ -1337,6 +1635,99 @@ var LOOKUP_TOOL_DEFINITION = {
1337
1635
  additionalProperties: false
1338
1636
  }
1339
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
+ };
1340
1731
  var DESCRIBE_TOOL_DEFINITION = {
1341
1732
  name: DESCRIBE_TOOL_NAME,
1342
1733
  title: "Describe the Relic client",
@@ -1347,7 +1738,7 @@ var DESCRIBE_TOOL_DEFINITION = {
1347
1738
  additionalProperties: false
1348
1739
  }
1349
1740
  };
1350
- var SERVER_VERSION = "0.3.3";
1741
+ var SERVER_VERSION = "0.4.0";
1351
1742
  var SERVER_INFO = {
1352
1743
  name: "relic",
1353
1744
  title: "Relic",
@@ -1356,13 +1747,14 @@ var SERVER_INFO = {
1356
1747
  var CAPABILITIES = { tools: {} };
1357
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.
1358
1749
 
1359
- Five things that change how you should act:
1750
+ Six things that change how you should act:
1360
1751
 
1361
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.
1362
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.
1363
1754
  3. Check existing sources with relic_lookup_source. Use relic_republish when found; relic_publish otherwise costs a second URL. ${VERSION_HISTORY_DISCLOSURE}
1364
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.
1365
- 5. Rendered HTML and JSX run in an isolated frame with no network access. Inline the styles, scripts, fonts, and images a page needs, because a CDN reference renders as nothing. Decide that before you write the file.`;
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.`;
1366
1758
  async function handleMessage(message, deps) {
1367
1759
  if (message.id === undefined)
1368
1760
  return;
@@ -1408,6 +1800,8 @@ async function handleMessage(message, deps) {
1408
1800
  TOOL_DEFINITION,
1409
1801
  LOOKUP_TOOL_DEFINITION,
1410
1802
  REPUBLISH_TOOL_DEFINITION,
1803
+ READ_COMMENTS_TOOL_DEFINITION,
1804
+ COMMENT_TOOL_DEFINITION,
1411
1805
  DESCRIBE_TOOL_DEFINITION
1412
1806
  ]
1413
1807
  }
@@ -1438,6 +1832,8 @@ async function callTool(id2, params, deps) {
1438
1832
  plaintext_transmitted_to_service: false,
1439
1833
  ciphertext_destination: "object storage, via a signed URL",
1440
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",
1441
1837
  service_origin: deps.serviceOrigin
1442
1838
  },
1443
1839
  isError: false
@@ -1450,6 +1846,12 @@ async function callTool(id2, params, deps) {
1450
1846
  if (params["name"] === REPUBLISH_TOOL_NAME) {
1451
1847
  return callRepublish(id2, params, deps);
1452
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
+ }
1453
1855
  if (params["name"] !== TOOL_NAME) {
1454
1856
  return errorResponse(id2, ERROR_CODES.invalidParams, `unknown tool: ${String(params["name"])}`);
1455
1857
  }
@@ -1577,6 +1979,79 @@ async function callRepublish(id2, params, deps) {
1577
1979
  return { jsonrpc: "2.0", id: id2, result: toolError(error) };
1578
1980
  }
1579
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."
2028
+ }
2029
+ ],
2030
+ structuredContent: result,
2031
+ isError: false
2032
+ }
2033
+ };
2034
+ } catch (error) {
2035
+ return { jsonrpc: "2.0", id: id2, result: toolError(error) };
2036
+ }
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
+ }
1580
2055
  function parseTtlDays(raw) {
1581
2056
  if (raw === undefined || raw === null)
1582
2057
  return { ok: true, days: undefined };
@@ -1587,7 +2062,8 @@ function parseTtlDays(raw) {
1587
2062
  }
1588
2063
  var REFUSAL_GUIDANCE = {
1589
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.",
1590
- 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."
1591
2067
  };
1592
2068
  function toolError(error) {
1593
2069
  if (error instanceof PublishError) {
@@ -1612,7 +2088,7 @@ ${guidance}`)
1612
2088
  };
1613
2089
  }
1614
2090
  return {
1615
- content: [{ type: "text", text: `publish failed: ${String(error)}` }],
2091
+ content: [{ type: "text", text: `the call failed: ${String(error)}` }],
1616
2092
  structuredContent: { code: "unknown" },
1617
2093
  isError: true
1618
2094
  };
@@ -1650,6 +2126,17 @@ service ever holds, and neither secret is ever printed or logged. Deleting the
1650
2126
  file changes nothing for existing links; it only ends this machine's ability
1651
2127
  to update those relics.
1652
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
+
1653
2140
  What this does NOT protect against: the key is returned to your agent in the
1654
2141
  URL, so it enters the model's context and your session transcript. That is
1655
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.3",
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",