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/src/server.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * unusable in most of the agents this product exists to serve.
21
21
  */
22
22
 
23
+ import { type CommentRecord, postComment, readComments } from './comments.ts';
23
24
  import {
24
25
  ERROR_CODES,
25
26
  errorResponse,
@@ -97,6 +98,32 @@ export const REPUBLISH_TOOL_NAME = 'relic_republish';
97
98
  */
98
99
  export const LOOKUP_TOOL_NAME = 'relic_lookup_source';
99
100
 
101
+ /**
102
+ * The comment tools, and why they are two rather than one.
103
+ *
104
+ * Reading is the half that makes comments worth having for an agent: a person
105
+ * leaves a comment, the agent reads it back, and it acts on it. Writing is
106
+ * the half that lets the agent answer. They are separated for the same reason
107
+ * lookup is separate from republish: the read needs no credential and the
108
+ * write spends the publish token, so folding them together would make an
109
+ * agent that only wants to read present a write credential to find out.
110
+ *
111
+ * Both take a relic id, never the share URL. The fragment is the key.
112
+ */
113
+ export const READ_COMMENTS_TOOL_NAME = 'relic_read_comments';
114
+
115
+ export const COMMENT_TOOL_NAME = 'relic_comment';
116
+
117
+ /**
118
+ * The one sentence about comments an agent has to have before it reads any,
119
+ * carried on both comment tools and in the handshake instructions the way the
120
+ * version-history disclosure is.
121
+ */
122
+ const COMMENT_MACHINE_BOUNDARY =
123
+ 'Only works for a relic this machine published: the comment key is derived ' +
124
+ "from that relic's key, which lives in local publish state and nowhere the " +
125
+ 'service can reach.';
126
+
100
127
  /**
101
128
  * The ceiling on a publisher-supplied lifetime, matching the grant
102
129
  * contract's `maxTtlDays`. Refusing here keeps a typo like 36500 from
@@ -105,6 +132,10 @@ export const LOOKUP_TOOL_NAME = 'relic_lookup_source';
105
132
  */
106
133
  const MAX_TTL_DAYS = 3650;
107
134
 
135
+ const VERSION_HISTORY_DISCLOSURE =
136
+ "Anyone holding a relic's link can fetch every version it has ever held, " +
137
+ 'so republishing does not withdraw earlier content.';
138
+
108
139
  export const TOOL_DEFINITION = {
109
140
  name: TOOL_NAME,
110
141
  title: 'Publish a relic',
@@ -112,8 +143,10 @@ export const TOOL_DEFINITION = {
112
143
  'Encrypt a file on this machine and publish it as a new relic, returning ' +
113
144
  'a shareable URL. Publishing an update this way costs a second URL that ' +
114
145
  'nobody holding the first one will ever see; use relic_republish instead ' +
115
- 'so the existing URL keeps working. The encryption key is generated ' +
116
- 'locally and never sent to the service. Takes a filesystem path, never ' +
146
+ 'so the existing URL keeps working. ' +
147
+ VERSION_HISTORY_DISCLOSURE +
148
+ ' The encryption key is generated locally and never sent to the service. ' +
149
+ 'Takes a filesystem path, never ' +
117
150
  'inline content.',
118
151
  inputSchema: {
119
152
  type: 'object',
@@ -187,7 +220,8 @@ export const REPUBLISH_TOOL_DEFINITION = {
187
220
  description:
188
221
  'Publish a new version of a relic this machine originally published, ' +
189
222
  'encrypting under the same key so the existing share URL keeps working. ' +
190
- "Only possible from the machine that holds the relic's key and publish " +
223
+ VERSION_HISTORY_DISCLOSURE +
224
+ " Only possible from the machine that holds the relic's key and publish " +
191
225
  'token; a relic that was taken down can never be revived.',
192
226
  inputSchema: {
193
227
  type: 'object',
@@ -308,6 +342,127 @@ export const LOOKUP_TOOL_DEFINITION = {
308
342
  },
309
343
  } as const;
310
344
 
345
+ export const READ_COMMENTS_TOOL_DEFINITION = {
346
+ name: READ_COMMENTS_TOOL_NAME,
347
+ title: "Read a relic's comments",
348
+ description:
349
+ 'Read the comments people have left on a relic, oldest first, decrypted ' +
350
+ 'on this machine. Use it before changing content somebody was asked to ' +
351
+ 'review, and after sharing a link, because a comment is the only way a ' +
352
+ 'reader can answer back. ' +
353
+ COMMENT_MACHINE_BOUNDARY +
354
+ ' Takes the relic id, never the share URL: the URL carries the key in ' +
355
+ 'its fragment. A comment that will not decrypt is returned marked ' +
356
+ 'unreadable rather than dropped, so a shortened list never reads as ' +
357
+ 'agreement.',
358
+ inputSchema: {
359
+ type: 'object',
360
+ properties: {
361
+ relic_id: {
362
+ type: 'string',
363
+ description: 'The 26-character relic id the original publish returned.',
364
+ },
365
+ },
366
+ required: ['relic_id'],
367
+ additionalProperties: false,
368
+ },
369
+ outputSchema: {
370
+ type: 'object',
371
+ properties: {
372
+ relic_id: { type: 'string' },
373
+ count: { type: 'integer', minimum: 0 },
374
+ unreadable_count: {
375
+ type: 'integer',
376
+ minimum: 0,
377
+ description:
378
+ 'How many of `count` did not decrypt. Above zero means part of the ' +
379
+ 'conversation is unread, not absent.',
380
+ },
381
+ comments: {
382
+ type: 'array',
383
+ items: {
384
+ type: 'object',
385
+ properties: {
386
+ comment_id: { type: 'string' },
387
+ author: {
388
+ type: 'string',
389
+ description:
390
+ 'The commenter\u2019s verified email address, or "publisher" ' +
391
+ 'for a comment written with a publish token.',
392
+ },
393
+ created_at: { type: 'string' },
394
+ display_name: { type: ['string', 'null'] },
395
+ body: { type: ['string', 'null'] },
396
+ readable: { type: 'boolean' },
397
+ unreadable_reason: { type: ['string', 'null'] },
398
+ },
399
+ required: [
400
+ 'comment_id',
401
+ 'author',
402
+ 'created_at',
403
+ 'display_name',
404
+ 'body',
405
+ 'readable',
406
+ 'unreadable_reason',
407
+ ],
408
+ additionalProperties: false,
409
+ },
410
+ },
411
+ },
412
+ required: ['relic_id', 'count', 'unreadable_count', 'comments'],
413
+ additionalProperties: false,
414
+ },
415
+ } as const;
416
+
417
+ export const COMMENT_TOOL_DEFINITION = {
418
+ name: COMMENT_TOOL_NAME,
419
+ title: 'Comment on a relic',
420
+ description:
421
+ 'Leave a comment on a relic this machine published, encrypted here so ' +
422
+ 'the service stores ciphertext it cannot read. Everyone holding the ' +
423
+ 'link sees it. Attribution is the publish token, so the comment is ' +
424
+ 'attributed to "publisher" rather than to an email address: an agent has ' +
425
+ 'no mailbox and cannot verify one. That is attribution and not ' +
426
+ 'authorization. ' +
427
+ COMMENT_MACHINE_BOUNDARY +
428
+ ' Takes the relic id, never the share URL.',
429
+ inputSchema: {
430
+ type: 'object',
431
+ properties: {
432
+ relic_id: {
433
+ type: 'string',
434
+ description: 'The 26-character relic id the original publish returned.',
435
+ },
436
+ body: {
437
+ type: 'string',
438
+ description:
439
+ 'The comment text, up to 4096 bytes of UTF-8. It is encrypted ' +
440
+ 'before it leaves this machine.',
441
+ },
442
+ display_name: {
443
+ type: 'string',
444
+ description:
445
+ 'Optional. A name shown beside the comment, up to 64 bytes of ' +
446
+ 'UTF-8. It aliases the attribution for presentation and never ' +
447
+ 'replaces it.',
448
+ },
449
+ },
450
+ required: ['relic_id', 'body'],
451
+ additionalProperties: false,
452
+ },
453
+ outputSchema: {
454
+ type: 'object',
455
+ properties: {
456
+ relic_id: { type: 'string' },
457
+ comment_id: { type: 'string' },
458
+ author: { type: 'string' },
459
+ created_at: { type: 'string' },
460
+ },
461
+ required: ['relic_id', 'comment_id', 'author', 'created_at'],
462
+ additionalProperties: false,
463
+ },
464
+ } as const;
465
+
311
466
  export const DESCRIBE_TOOL_DEFINITION = {
312
467
  name: DESCRIBE_TOOL_NAME,
313
468
  title: 'Describe the Relic client',
@@ -349,12 +504,14 @@ export const CAPABILITIES = { tools: {} } as const;
349
504
  * The plugin ships a skill with the same facts, but a skill only reaches
350
505
  * Claude Code, and only when somebody installs the plugin rather than wiring
351
506
  * this server directly. Every other client saw tool descriptions and nothing
352
- * else, which left five things an agent cannot read off a schema.
507
+ * else, which left six things an agent cannot read off a schema.
353
508
  *
354
- * Item five is the reason this exists at all rather than living only in the
355
- * publish result. The result is returned after the file is written, which is
356
- * too late for an agent that already linked a stylesheet from a CDN. This
357
- * lands before generation, which is the only moment the advice can be taken.
509
+ * Item five is one of the two reasons this exists at all rather than living
510
+ * only in a tool result. The publish result arrives after the file is
511
+ * written, which is too late for an agent that already linked a stylesheet
512
+ * from a CDN. Item six is the other: an agent that never learns comments
513
+ * exist never reads one. Both land before the work, which is the only moment
514
+ * either can be acted on.
358
515
  *
359
516
  * It costs context on every session, so it stays short and it stays true.
360
517
  * Anything that needs a paragraph belongs in the skill or the disclosure.
@@ -363,22 +520,25 @@ export const INSTRUCTIONS = `Relic encrypts a file on this machine and uploads \
363
520
  only ciphertext. The key lives in the URL fragment, which browsers never send \
364
521
  to a server.
365
522
 
366
- Five things that change how you should act:
523
+ Six things that change how you should act:
367
524
 
368
525
  1. The link is the credential. Anyone holding it, fragment included, can read \
369
526
  the file. Do not paste it into a tracker, a log, or a public channel.
370
527
  2. Publishing puts the key in this transcript. That is structural rather than \
371
528
  a defect, and worth saying plainly when you hand the link over.
372
- 3. If a source was published before, use relic_republish so its URL keeps \
373
- working. Publishing it as new costs a second URL that nobody holding the first \
374
- one will ever see. relic_publish refuses by default, relic_lookup_source \
375
- recovers the id, and force_new is only for a deliberate second link.
529
+ 3. Check existing sources with relic_lookup_source. Use relic_republish when \
530
+ found; relic_publish otherwise costs a second URL. \
531
+ ${VERSION_HISTORY_DISCLOSURE}
376
532
  4. A relic can be republished only from the machine that published it, which \
377
533
  is where its key and publish token are stored. Anywhere else it refuses, and \
378
534
  no retry changes that.
379
535
  5. Rendered HTML and JSX run in an isolated frame with no network access. \
380
536
  Inline the styles, scripts, fonts, and images a page needs, because a CDN \
381
- reference renders as nothing. Decide that before you write the file.`;
537
+ reference renders as nothing. Decide that before you write the file.
538
+ 6. People can comment on a relic. Read them with relic_read_comments before \
539
+ you change reviewed content, and answer with relic_comment. Both take the \
540
+ relic id, work only on the machine that published, and attribute you as the \
541
+ publisher.`;
382
542
 
383
543
  /**
384
544
  * Handle one JSON-RPC message.
@@ -448,6 +608,8 @@ export async function handleMessage(
448
608
  TOOL_DEFINITION,
449
609
  LOOKUP_TOOL_DEFINITION,
450
610
  REPUBLISH_TOOL_DEFINITION,
611
+ READ_COMMENTS_TOOL_DEFINITION,
612
+ COMMENT_TOOL_DEFINITION,
451
613
  DESCRIBE_TOOL_DEFINITION,
452
614
  ],
453
615
  },
@@ -506,6 +668,13 @@ async function callTool(
506
668
  'relic id, source identity, key, and publish token per relic, ' +
507
669
  'written 0600 under the user config directory; key and token ' +
508
670
  'are never printed or sent',
671
+ comment_encryption:
672
+ 'AES-128-GCM under a key derived from the relic key with a ' +
673
+ 'distinct HKDF label, so comment bodies reach the service as ' +
674
+ 'ciphertext and the URL fragment is unchanged',
675
+ comment_attribution:
676
+ 'the publish token, reported by the service as "publisher"; the ' +
677
+ 'operator learns which identity commented on which relic and when',
509
678
  service_origin: deps.serviceOrigin,
510
679
  },
511
680
  isError: false,
@@ -521,6 +690,14 @@ async function callTool(
521
690
  return callRepublish(id, params, deps);
522
691
  }
523
692
 
693
+ if (params['name'] === READ_COMMENTS_TOOL_NAME) {
694
+ return callReadComments(id, params, deps);
695
+ }
696
+
697
+ if (params['name'] === COMMENT_TOOL_NAME) {
698
+ return callComment(id, params, deps);
699
+ }
700
+
524
701
  if (params['name'] !== TOOL_NAME) {
525
702
  return errorResponse(
526
703
  id,
@@ -601,6 +778,7 @@ async function callTool(
601
778
  isolationNote(result.renderer_class) +
602
779
  `What Relic knows: ${result.disclosure_url}`,
603
780
  },
781
+ { type: 'text', text: VERSION_HISTORY_DISCLOSURE },
604
782
  ],
605
783
  structuredContent: result,
606
784
  isError: false,
@@ -732,6 +910,99 @@ async function callRepublish(
732
910
  '\n' +
733
911
  `What Relic knows: ${result.disclosure_url}`,
734
912
  },
913
+ { type: 'text', text: VERSION_HISTORY_DISCLOSURE },
914
+ ],
915
+ structuredContent: result,
916
+ isError: false,
917
+ },
918
+ };
919
+ } catch (error) {
920
+ return { jsonrpc: '2.0', id, result: toolError(error) };
921
+ }
922
+ }
923
+
924
+ async function callReadComments(
925
+ id: string | number | null,
926
+ params: Record<string, unknown>,
927
+ deps: PublishDeps
928
+ ): Promise<JsonRpcResponse> {
929
+ const args = (params['arguments'] ?? {}) as Record<string, unknown>;
930
+ const relicId = args['relic_id'];
931
+ if (typeof relicId !== 'string' || relicId.length === 0) {
932
+ return errorResponse(
933
+ id,
934
+ ERROR_CODES.invalidParams,
935
+ '`relic_id` is required and must be a string'
936
+ );
937
+ }
938
+
939
+ try {
940
+ const result = await readComments(relicId, deps);
941
+ return {
942
+ jsonrpc: '2.0',
943
+ id,
944
+ result: {
945
+ content: [{ type: 'text', text: commentTranscript(result) }],
946
+ structuredContent: result,
947
+ isError: false,
948
+ },
949
+ };
950
+ } catch (error) {
951
+ return { jsonrpc: '2.0', id, result: toolError(error) };
952
+ }
953
+ }
954
+
955
+ async function callComment(
956
+ id: string | number | null,
957
+ params: Record<string, unknown>,
958
+ deps: PublishDeps
959
+ ): Promise<JsonRpcResponse> {
960
+ const args = (params['arguments'] ?? {}) as Record<string, unknown>;
961
+ const relicId = args['relic_id'];
962
+ if (typeof relicId !== 'string' || relicId.length === 0) {
963
+ return errorResponse(
964
+ id,
965
+ ERROR_CODES.invalidParams,
966
+ '`relic_id` is required and must be a string'
967
+ );
968
+ }
969
+
970
+ const body = args['body'];
971
+ if (typeof body !== 'string') {
972
+ return errorResponse(
973
+ id,
974
+ ERROR_CODES.invalidParams,
975
+ '`body` is required and must be a string'
976
+ );
977
+ }
978
+
979
+ const displayName = args['display_name'];
980
+ if (displayName !== undefined && typeof displayName !== 'string') {
981
+ return errorResponse(
982
+ id,
983
+ ERROR_CODES.invalidParams,
984
+ '`display_name` must be a string or omitted'
985
+ );
986
+ }
987
+
988
+ try {
989
+ const result = await postComment(
990
+ { relic_id: relicId, body, display_name: displayName },
991
+ deps
992
+ );
993
+ return {
994
+ jsonrpc: '2.0',
995
+ id,
996
+ result: {
997
+ content: [
998
+ {
999
+ type: 'text',
1000
+ text:
1001
+ `Commented on relic ${result.relic_id} as ${result.author}.\n` +
1002
+ 'Everyone holding the link sees it. The service stored ' +
1003
+ 'ciphertext it cannot read, and it knows that this address ' +
1004
+ 'commented on this relic at this time.',
1005
+ },
735
1006
  ],
736
1007
  structuredContent: result,
737
1008
  isError: false,
@@ -742,6 +1013,46 @@ async function callRepublish(
742
1013
  }
743
1014
  }
744
1015
 
1016
+ /**
1017
+ * The comments as a person would read them, because a JSON array of rows is
1018
+ * not a conversation.
1019
+ *
1020
+ * Unreadable comments are printed in place, in order, with their reason. A
1021
+ * list that quietly closed over a gap would read as the whole conversation,
1022
+ * and an agent acting on "nobody objected" when somebody did is exactly the
1023
+ * failure the count exists to prevent.
1024
+ */
1025
+ function commentTranscript(result: {
1026
+ readonly relic_id: string;
1027
+ readonly count: number;
1028
+ readonly unreadable_count: number;
1029
+ readonly comments: readonly CommentRecord[];
1030
+ }): string {
1031
+ if (result.count === 0) {
1032
+ return `No comments on relic ${result.relic_id} yet.`;
1033
+ }
1034
+
1035
+ const lines = result.comments.map((comment) => {
1036
+ const who =
1037
+ comment.display_name === null
1038
+ ? comment.author
1039
+ : `${comment.display_name} (${comment.author})`;
1040
+ return comment.readable
1041
+ ? `${comment.created_at} ${who}:\n${comment.body}`
1042
+ : `${comment.created_at} ${who}:\n[unreadable: ${comment.unreadable_reason}]`;
1043
+ });
1044
+
1045
+ const header =
1046
+ result.unreadable_count === 0
1047
+ ? `${result.count} comment(s) on relic ${result.relic_id}, oldest first.`
1048
+ : `${result.count} comment(s) on relic ${result.relic_id}, oldest ` +
1049
+ `first. ${result.unreadable_count} did not decrypt and are shown as ` +
1050
+ 'unreadable rather than dropped, so treat this conversation as ' +
1051
+ 'partially unread.';
1052
+
1053
+ return `${header}\n\n${lines.join('\n\n')}`;
1054
+ }
1055
+
745
1056
  /**
746
1057
  * A lifetime is opt-in: absent or null means no change from the default. A
747
1058
  * value that fails the contract is refused rather than dropped, because
@@ -764,11 +1075,11 @@ function parseTtlDays(
764
1075
  }
765
1076
 
766
1077
  /**
767
- * Republish refusals a publisher must understand on their own terms.
1078
+ * Refusals a publisher must understand on their own terms.
768
1079
  *
769
1080
  * The server's problem document carries the code; these sentences carry
770
1081
  * what the publisher can still do, because "403" and "410" answer nothing
771
- * a human would ask. The two must stay distinct: one means this machine
1082
+ * a human would ask. The first two must stay distinct: one means this machine
772
1083
  * lost its standing, the other means nobody has any, ever again.
773
1084
  */
774
1085
  const REFUSAL_GUIDANCE: Readonly<Record<string, string>> = {
@@ -782,6 +1093,10 @@ const REFUSAL_GUIDANCE: Readonly<Record<string, string>> = {
782
1093
  'That relic was taken down. A takedown is permanent: republishing ' +
783
1094
  'cannot revive it, whatever token is presented. Publish the content as ' +
784
1095
  'a new relic instead.',
1096
+ comment_rate_limited:
1097
+ 'The service is rate limiting comments on that relic. The refusal ' +
1098
+ 'carries retry_after_seconds; wait it out rather than retrying in a ' +
1099
+ 'loop, which only extends the limit.',
785
1100
  };
786
1101
 
787
1102
  /**
@@ -816,7 +1131,7 @@ function toolError(error: unknown): Record<string, unknown> {
816
1131
  };
817
1132
  }
818
1133
  return {
819
- content: [{ type: 'text', text: `publish failed: ${String(error)}` }],
1134
+ content: [{ type: 'text', text: `the call failed: ${String(error)}` }],
820
1135
  structuredContent: { code: 'unknown' },
821
1136
  isError: true,
822
1137
  };
@@ -861,6 +1176,17 @@ service ever holds, and neither secret is ever printed or logged. Deleting the
861
1176
  file changes nothing for existing links; it only ends this machine's ability
862
1177
  to update those relics.
863
1178
 
1179
+ What happens with comments: a comment body is encrypted here too, under a key
1180
+ derived from that relic's key with a distinct HKDF label, so the service
1181
+ stores comment ciphertext it cannot read and the URL fragment does not change.
1182
+ Reading comments needs that key, and writing one is authorized by the publish
1183
+ token, so both work only on the machine that published. A comment this client
1184
+ writes is attributed to the publisher rather than an email address, because
1185
+ an agent has no mailbox to verify. What the operator does learn is who
1186
+ commented on which relic and when: for a person that is a verified email
1187
+ address, and that association is a real cost the content's encryption does
1188
+ not cover.
1189
+
864
1190
  What this does NOT protect against: the key is returned to your agent in the
865
1191
  URL, so it enters the model's context and your session transcript. That is
866
1192
  structural, not a defect. Anyone who can read this conversation can open the