relic-mcp 0.3.1 → 0.3.3

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/publish.ts CHANGED
@@ -21,7 +21,12 @@ import {
21
21
  type RendererClass,
22
22
  relicUrl,
23
23
  } from '@relic/format';
24
- import { savePublishState } from './state.ts';
24
+ import {
25
+ loadPublishedSource,
26
+ resolveSourceIdentity,
27
+ type SourceIdentity,
28
+ savePublishState,
29
+ } from './state.ts';
25
30
 
26
31
  /**
27
32
  * Codes for the legs the app server is not in.
@@ -37,6 +42,7 @@ export type ClientCode =
37
42
  | 'source_is_directory'
38
43
  | 'source_not_regular_file'
39
44
  | 'source_unreadable'
45
+ | 'source_already_published'
40
46
  | 'local_size_precheck_failed'
41
47
  | 'upload_failed'
42
48
  | 'service_unreachable'
@@ -96,6 +102,11 @@ export interface PublishInput {
96
102
  * to survive the wire.
97
103
  */
98
104
  readonly ttl_days?: number | undefined;
105
+ /**
106
+ * Bypass prior-publish refusal only when a second independent URL is
107
+ * intentional. False and undefined both preserve the existing URL.
108
+ */
109
+ readonly force_new?: boolean | undefined;
99
110
  }
100
111
 
101
112
  export interface PublishResult {
@@ -121,15 +132,117 @@ export interface PublishDeps {
121
132
  readonly files: FileReader;
122
133
  readonly fetch: typeof globalThis.fetch;
123
134
  readonly clientName: string;
135
+ /** Test seam for a filesystem that does not exist outside memory. */
136
+ readonly identifySource?: (path: string) => Promise<SourceIdentity>;
124
137
  /** Retries on a colliding ID, which format.md 1.4 obliges the client to do. */
125
138
  readonly maxCollisionRetries?: number;
126
139
  }
127
140
 
141
+ export interface RepublishToolCall {
142
+ readonly name: 'relic_republish';
143
+ readonly arguments: {
144
+ readonly relic_id: string;
145
+ readonly path: string;
146
+ };
147
+ }
148
+
149
+ export interface PriorPublish {
150
+ readonly relic_id: string;
151
+ readonly version: number;
152
+ readonly source: SourceIdentity;
153
+ }
154
+
155
+ export interface PublishedSourceLookup {
156
+ readonly resolved_path: string;
157
+ readonly source: SourceIdentity;
158
+ readonly match?: PriorPublish | undefined;
159
+ }
160
+
161
+ /** The complete call an agent can make without retaining an earlier session. */
162
+ export function republishToolCall(
163
+ relicId: string,
164
+ path: string
165
+ ): RepublishToolCall {
166
+ return {
167
+ name: 'relic_republish',
168
+ arguments: { relic_id: relicId, path },
169
+ };
170
+ }
171
+
172
+ async function lookupResolvedSource(
173
+ resolvedPath: string,
174
+ deps: PublishDeps
175
+ ): Promise<PublishedSourceLookup> {
176
+ let source: SourceIdentity;
177
+ try {
178
+ source = await (deps.identifySource ?? resolveSourceIdentity)(resolvedPath);
179
+ } catch (error) {
180
+ const code = (error as NodeJS.ErrnoException).code;
181
+ throw new PublishError(
182
+ code === 'ENOENT' ? 'source_not_found' : 'source_unreadable',
183
+ `could not resolve source identity for ${resolvedPath}: ` +
184
+ (error as Error).message,
185
+ { path: resolvedPath }
186
+ );
187
+ }
188
+
189
+ try {
190
+ const published = await loadPublishedSource(source);
191
+ return {
192
+ resolved_path: resolvedPath,
193
+ source,
194
+ ...(published === undefined
195
+ ? {}
196
+ : {
197
+ match: {
198
+ relic_id: published.relic_id,
199
+ version: published.state.version,
200
+ source: published.source,
201
+ },
202
+ }),
203
+ };
204
+ } catch (error) {
205
+ throw new PublishError('local_state_unreadable', (error as Error).message);
206
+ }
207
+ }
208
+
209
+ /** Look up a path in local state without reading its bytes or calling a server. */
210
+ export async function lookupPublishedSource(
211
+ path: string,
212
+ deps: PublishDeps
213
+ ): Promise<PublishedSourceLookup> {
214
+ return lookupResolvedSource(deps.files.resolve(path), deps);
215
+ }
216
+
128
217
  export async function publish(
129
218
  input: PublishInput,
130
219
  deps: PublishDeps
131
220
  ): Promise<PublishResult> {
132
221
  const source = await readSource(input.path, deps.files);
222
+ const sourceLookup = await lookupResolvedSource(source.resolvedPath, deps);
223
+ if (sourceLookup.match !== undefined && input.force_new !== true) {
224
+ const match = sourceLookup.match;
225
+ const republishCall = republishToolCall(
226
+ match.relic_id,
227
+ source.resolvedPath
228
+ );
229
+ const cost = 'a second URL that nobody holding the first one will ever see';
230
+ throw new PublishError(
231
+ 'source_already_published',
232
+ `${match.source.description} is already version ${match.version} of ` +
233
+ `relic ${match.relic_id}. Publishing it as new would cost ${cost}. ` +
234
+ `Call relic_republish(${JSON.stringify(republishCall.arguments)}) ` +
235
+ 'instead. Set force_new to true only when you intend a separate relic.',
236
+ {
237
+ relic_id: match.relic_id,
238
+ version: match.version,
239
+ source_identity: match.source.identity,
240
+ source_description: match.source.description,
241
+ cost,
242
+ republish_call: republishCall,
243
+ }
244
+ );
245
+ }
133
246
 
134
247
  // 1. Challenge. This returns the cap before a grant is requested, so the
135
248
  // precheck below uses a number that came from the server moments ago
@@ -243,6 +356,7 @@ export async function publish(
243
356
  key: encodeKey(key),
244
357
  publish_token: publishToken,
245
358
  version: 1,
359
+ source: sourceLookup.source,
246
360
  });
247
361
  } catch (error) {
248
362
  // The relic itself is live and the URL works, so both are handed over
package/src/server.ts CHANGED
@@ -33,9 +33,11 @@ import {
33
33
  unsupportedVersionError,
34
34
  } from './protocol.ts';
35
35
  import {
36
+ lookupPublishedSource,
36
37
  type PublishDeps,
37
38
  PublishError,
38
39
  publish,
40
+ republishToolCall,
39
41
  ServerRefusal,
40
42
  } from './publish.ts';
41
43
  import { republish } from './republish.ts';
@@ -86,6 +88,15 @@ export const DESCRIBE_TOOL_NAME = 'relic_describe_client';
86
88
  */
87
89
  export const REPUBLISH_TOOL_NAME = 'relic_republish';
88
90
 
91
+ /**
92
+ * Source lookup is a separate read-only tool.
93
+ *
94
+ * An agent needs the id before it chooses publish or republish, and a lookup
95
+ * hidden inside either write tool would only be observable after choosing the
96
+ * wrong one. This call reads local state and never contacts the service.
97
+ */
98
+ export const LOOKUP_TOOL_NAME = 'relic_lookup_source';
99
+
89
100
  /**
90
101
  * The ceiling on a publisher-supplied lifetime, matching the grant
91
102
  * contract's `maxTtlDays`. Refusing here keeps a typo like 36500 from
@@ -94,13 +105,22 @@ export const REPUBLISH_TOOL_NAME = 'relic_republish';
94
105
  */
95
106
  const MAX_TTL_DAYS = 3650;
96
107
 
108
+ const VERSION_HISTORY_DISCLOSURE =
109
+ "Anyone holding a relic's link can fetch every version it has ever held, " +
110
+ 'so republishing does not withdraw earlier content.';
111
+
97
112
  export const TOOL_DEFINITION = {
98
113
  name: TOOL_NAME,
99
114
  title: 'Publish a relic',
100
115
  description:
101
- 'Encrypt a file on this machine and publish it as a relic, returning a ' +
102
- 'shareable URL. The encryption key is generated locally and never sent ' +
103
- 'to the service. Takes a filesystem path, never inline content.',
116
+ 'Encrypt a file on this machine and publish it as a new relic, returning ' +
117
+ 'a shareable URL. Publishing an update this way costs a second URL that ' +
118
+ 'nobody holding the first one will ever see; use relic_republish instead ' +
119
+ 'so the existing URL keeps working. ' +
120
+ VERSION_HISTORY_DISCLOSURE +
121
+ ' The encryption key is generated locally and never sent to the service. ' +
122
+ 'Takes a filesystem path, never ' +
123
+ 'inline content.',
104
124
  inputSchema: {
105
125
  type: 'object',
106
126
  properties: {
@@ -123,6 +143,14 @@ export const TOOL_DEFINITION = {
123
143
  'relic is kept until it is deleted. Shorter is better for ' +
124
144
  'sensitive content.',
125
145
  },
146
+ force_new: {
147
+ type: 'boolean',
148
+ default: false,
149
+ description:
150
+ 'Optional. Publish a deliberately separate relic even when this ' +
151
+ 'machine already published the same source. Defaults to false. Use ' +
152
+ 'only when you want two independent URLs for one file.',
153
+ },
126
154
  },
127
155
  required: ['path'],
128
156
  additionalProperties: false,
@@ -165,7 +193,8 @@ export const REPUBLISH_TOOL_DEFINITION = {
165
193
  description:
166
194
  'Publish a new version of a relic this machine originally published, ' +
167
195
  'encrypting under the same key so the existing share URL keeps working. ' +
168
- "Only possible from the machine that holds the relic's key and publish " +
196
+ VERSION_HISTORY_DISCLOSURE +
197
+ " Only possible from the machine that holds the relic's key and publish " +
169
198
  'token; a relic that was taken down can never be revived.',
170
199
  inputSchema: {
171
200
  type: 'object',
@@ -228,6 +257,64 @@ export const REPUBLISH_TOOL_DEFINITION = {
228
257
  },
229
258
  } as const;
230
259
 
260
+ export const LOOKUP_TOOL_DEFINITION = {
261
+ name: LOOKUP_TOOL_NAME,
262
+ title: 'Look up a published source',
263
+ description:
264
+ 'Look up whether this machine already published a file and return the ' +
265
+ 'relic id needed by relic_republish. Reads local publish state only and ' +
266
+ 'never calls the service.',
267
+ inputSchema: {
268
+ type: 'object',
269
+ properties: {
270
+ path: {
271
+ type: 'string',
272
+ description: 'Filesystem path to the source to look up.',
273
+ },
274
+ },
275
+ required: ['path'],
276
+ additionalProperties: false,
277
+ },
278
+ outputSchema: {
279
+ type: 'object',
280
+ properties: {
281
+ found: { type: 'boolean' },
282
+ relic_id: { type: ['string', 'null'] },
283
+ version: { type: ['integer', 'null'], minimum: 1 },
284
+ resolved_path: { type: 'string' },
285
+ source_identity: { type: 'string' },
286
+ source_description: { type: 'string' },
287
+ republish_call: {
288
+ type: ['object', 'null'],
289
+ properties: {
290
+ name: { type: 'string', const: REPUBLISH_TOOL_NAME },
291
+ arguments: {
292
+ type: 'object',
293
+ properties: {
294
+ relic_id: { type: 'string' },
295
+ path: { type: 'string' },
296
+ },
297
+ required: ['relic_id', 'path'],
298
+ additionalProperties: false,
299
+ },
300
+ },
301
+ required: ['name', 'arguments'],
302
+ additionalProperties: false,
303
+ },
304
+ },
305
+ required: [
306
+ 'found',
307
+ 'relic_id',
308
+ 'version',
309
+ 'resolved_path',
310
+ 'source_identity',
311
+ 'source_description',
312
+ 'republish_call',
313
+ ],
314
+ additionalProperties: false,
315
+ },
316
+ } as const;
317
+
231
318
  export const DESCRIBE_TOOL_DEFINITION = {
232
319
  name: DESCRIBE_TOOL_NAME,
233
320
  title: 'Describe the Relic client',
@@ -269,9 +356,9 @@ export const CAPABILITIES = { tools: {} } as const;
269
356
  * The plugin ships a skill with the same facts, but a skill only reaches
270
357
  * Claude Code, and only when somebody installs the plugin rather than wiring
271
358
  * this server directly. Every other client saw tool descriptions and nothing
272
- * else, which left four things an agent cannot read off a schema.
359
+ * else, which left five things an agent cannot read off a schema.
273
360
  *
274
- * Item four is the reason this exists at all rather than living only in the
361
+ * Item five is the reason this exists at all rather than living only in the
275
362
  * publish result. The result is returned after the file is written, which is
276
363
  * too late for an agent that already linked a stylesheet from a CDN. This
277
364
  * lands before generation, which is the only moment the advice can be taken.
@@ -279,20 +366,23 @@ export const CAPABILITIES = { tools: {} } as const;
279
366
  * It costs context on every session, so it stays short and it stays true.
280
367
  * Anything that needs a paragraph belongs in the skill or the disclosure.
281
368
  */
282
- export const INSTRUCTIONS = `Relic turns a file on this machine into an \
283
- encrypted link. Encryption happens locally, only ciphertext is uploaded, and \
284
- the key lives in the URL fragment, which browsers never send to a server.
369
+ export const INSTRUCTIONS = `Relic encrypts a file on this machine and uploads \
370
+ only ciphertext. The key lives in the URL fragment, which browsers never send \
371
+ to a server.
285
372
 
286
- Four things that change how you should act:
373
+ Five things that change how you should act:
287
374
 
288
375
  1. The link is the credential. Anyone holding it, fragment included, can read \
289
376
  the file. Do not paste it into a tracker, a log, or a public channel.
290
377
  2. Publishing puts the key in this transcript. That is structural rather than \
291
378
  a defect, and worth saying plainly when you hand the link over.
292
- 3. A relic can be republished only from the machine that published it, which \
379
+ 3. Check existing sources with relic_lookup_source. Use relic_republish when \
380
+ found; relic_publish otherwise costs a second URL. \
381
+ ${VERSION_HISTORY_DISCLOSURE}
382
+ 4. A relic can be republished only from the machine that published it, which \
293
383
  is where its key and publish token are stored. Anywhere else it refuses, and \
294
384
  no retry changes that.
295
- 4. Rendered HTML and JSX run in an isolated frame with no network access. \
385
+ 5. Rendered HTML and JSX run in an isolated frame with no network access. \
296
386
  Inline the styles, scripts, fonts, and images a page needs, because a CDN \
297
387
  reference renders as nothing. Decide that before you write the file.`;
298
388
 
@@ -362,6 +452,7 @@ export async function handleMessage(
362
452
  result: {
363
453
  tools: [
364
454
  TOOL_DEFINITION,
455
+ LOOKUP_TOOL_DEFINITION,
365
456
  REPUBLISH_TOOL_DEFINITION,
366
457
  DESCRIBE_TOOL_DEFINITION,
367
458
  ],
@@ -418,8 +509,9 @@ async function callTool(
418
509
  plaintext_transmitted_to_service: false,
419
510
  ciphertext_destination: 'object storage, via a signed URL',
420
511
  local_publish_state:
421
- 'relic id, key, and publish token per relic, written 0600 under ' +
422
- 'the user config directory; never printed, never sent',
512
+ 'relic id, source identity, key, and publish token per relic, ' +
513
+ 'written 0600 under the user config directory; key and token ' +
514
+ 'are never printed or sent',
423
515
  service_origin: deps.serviceOrigin,
424
516
  },
425
517
  isError: false,
@@ -427,6 +519,10 @@ async function callTool(
427
519
  };
428
520
  }
429
521
 
522
+ if (params['name'] === LOOKUP_TOOL_NAME) {
523
+ return callLookup(id, params, deps);
524
+ }
525
+
430
526
  if (params['name'] === REPUBLISH_TOOL_NAME) {
431
527
  return callRepublish(id, params, deps);
432
528
  }
@@ -462,9 +558,23 @@ async function callTool(
462
558
  );
463
559
  }
464
560
 
561
+ const forceNew = args['force_new'];
562
+ if (forceNew !== undefined && typeof forceNew !== 'boolean') {
563
+ return errorResponse(
564
+ id,
565
+ ERROR_CODES.invalidParams,
566
+ '`force_new` must be a boolean or omitted'
567
+ );
568
+ }
569
+
465
570
  try {
466
571
  const result = await publish(
467
- { path, filename, ttl_days: ttlDays.days },
572
+ {
573
+ path,
574
+ filename,
575
+ ttl_days: ttlDays.days,
576
+ force_new: forceNew === true,
577
+ },
468
578
  deps
469
579
  );
470
580
  return {
@@ -497,6 +607,7 @@ async function callTool(
497
607
  isolationNote(result.renderer_class) +
498
608
  `What Relic knows: ${result.disclosure_url}`,
499
609
  },
610
+ { type: 'text', text: VERSION_HISTORY_DISCLOSURE },
500
611
  ],
501
612
  structuredContent: result,
502
613
  isError: false,
@@ -507,6 +618,63 @@ async function callTool(
507
618
  }
508
619
  }
509
620
 
621
+ async function callLookup(
622
+ id: string | number | null,
623
+ params: Record<string, unknown>,
624
+ deps: PublishDeps
625
+ ): Promise<JsonRpcResponse> {
626
+ const args = (params['arguments'] ?? {}) as Record<string, unknown>;
627
+ const path = args['path'];
628
+ if (typeof path !== 'string' || path.length === 0) {
629
+ return errorResponse(
630
+ id,
631
+ ERROR_CODES.invalidParams,
632
+ '`path` is required and must be a string'
633
+ );
634
+ }
635
+
636
+ try {
637
+ const lookup = await lookupPublishedSource(path, deps);
638
+ const match = lookup.match;
639
+ const republishCall =
640
+ match === undefined
641
+ ? null
642
+ : republishToolCall(match.relic_id, lookup.resolved_path);
643
+ const structuredContent = {
644
+ found: match !== undefined,
645
+ relic_id: match?.relic_id ?? null,
646
+ version: match?.version ?? null,
647
+ resolved_path: lookup.resolved_path,
648
+ source_identity: lookup.source.identity,
649
+ source_description: lookup.source.description,
650
+ republish_call: republishCall,
651
+ };
652
+ return {
653
+ jsonrpc: '2.0',
654
+ id,
655
+ result: {
656
+ content: [
657
+ {
658
+ type: 'text',
659
+ text:
660
+ match === undefined
661
+ ? `No prior relic is recorded for ${lookup.source.description}.`
662
+ : `Found ${lookup.source.description} as version ` +
663
+ `${match.version} of relic ${match.relic_id}.\n` +
664
+ `Call relic_republish(${JSON.stringify(
665
+ republishCall?.arguments
666
+ )}).`,
667
+ },
668
+ ],
669
+ structuredContent,
670
+ isError: false,
671
+ },
672
+ };
673
+ } catch (error) {
674
+ return { jsonrpc: '2.0', id, result: toolError(error) };
675
+ }
676
+ }
677
+
510
678
  async function callRepublish(
511
679
  id: string | number | null,
512
680
  params: Record<string, unknown>,
@@ -571,6 +739,7 @@ async function callRepublish(
571
739
  '\n' +
572
740
  `What Relic knows: ${result.disclosure_url}`,
573
741
  },
742
+ { type: 'text', text: VERSION_HISTORY_DISCLOSURE },
574
743
  ],
575
744
  structuredContent: result,
576
745
  isError: false,
@@ -691,13 +860,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
691
860
  what coarse class it was declared as, the publishing IP, and when it was
692
861
  fetched. Never the contents, and never the key.
693
862
 
694
- What this keeps on disk: for each relic you publish, its id, its key, and a
695
- publish token, in a 0600 file under your user config directory. That record
696
- is what lets a relic be republished later, and it is why republishing works
863
+ What this keeps on disk: for each relic you publish, its id, source identity,
864
+ key, and publish token, in a 0600 file under your user config directory. The
865
+ source index lets a fresh session find the id for relic_republish. The key and
866
+ token let that republish keep the same URL, and they are why republishing works
697
867
  only on the machine that published. The token's SHA-256 is the only copy the
698
- service ever holds, and neither secret is ever printed or logged. Deleting
699
- the file changes nothing for existing links; it only ends this machine's
700
- ability to update those relics.
868
+ service ever holds, and neither secret is ever printed or logged. Deleting the
869
+ file changes nothing for existing links; it only ends this machine's ability
870
+ to update those relics.
701
871
 
702
872
  What this does NOT protect against: the key is returned to your agent in the
703
873
  URL, so it enters the model's context and your session transcript. That is