relic-mcp 0.3.0 → 0.3.2

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
@@ -98,9 +109,12 @@ export const TOOL_DEFINITION = {
98
109
  name: TOOL_NAME,
99
110
  title: 'Publish a relic',
100
111
  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.',
112
+ 'Encrypt a file on this machine and publish it as a new relic, returning ' +
113
+ 'a shareable URL. Publishing an update this way costs a second URL that ' +
114
+ '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 ' +
117
+ 'inline content.',
104
118
  inputSchema: {
105
119
  type: 'object',
106
120
  properties: {
@@ -123,6 +137,14 @@ export const TOOL_DEFINITION = {
123
137
  'relic is kept until it is deleted. Shorter is better for ' +
124
138
  'sensitive content.',
125
139
  },
140
+ force_new: {
141
+ type: 'boolean',
142
+ default: false,
143
+ description:
144
+ 'Optional. Publish a deliberately separate relic even when this ' +
145
+ 'machine already published the same source. Defaults to false. Use ' +
146
+ 'only when you want two independent URLs for one file.',
147
+ },
126
148
  },
127
149
  required: ['path'],
128
150
  additionalProperties: false,
@@ -228,6 +250,64 @@ export const REPUBLISH_TOOL_DEFINITION = {
228
250
  },
229
251
  } as const;
230
252
 
253
+ export const LOOKUP_TOOL_DEFINITION = {
254
+ name: LOOKUP_TOOL_NAME,
255
+ title: 'Look up a published source',
256
+ description:
257
+ 'Look up whether this machine already published a file and return the ' +
258
+ 'relic id needed by relic_republish. Reads local publish state only and ' +
259
+ 'never calls the service.',
260
+ inputSchema: {
261
+ type: 'object',
262
+ properties: {
263
+ path: {
264
+ type: 'string',
265
+ description: 'Filesystem path to the source to look up.',
266
+ },
267
+ },
268
+ required: ['path'],
269
+ additionalProperties: false,
270
+ },
271
+ outputSchema: {
272
+ type: 'object',
273
+ properties: {
274
+ found: { type: 'boolean' },
275
+ relic_id: { type: ['string', 'null'] },
276
+ version: { type: ['integer', 'null'], minimum: 1 },
277
+ resolved_path: { type: 'string' },
278
+ source_identity: { type: 'string' },
279
+ source_description: { type: 'string' },
280
+ republish_call: {
281
+ type: ['object', 'null'],
282
+ properties: {
283
+ name: { type: 'string', const: REPUBLISH_TOOL_NAME },
284
+ arguments: {
285
+ type: 'object',
286
+ properties: {
287
+ relic_id: { type: 'string' },
288
+ path: { type: 'string' },
289
+ },
290
+ required: ['relic_id', 'path'],
291
+ additionalProperties: false,
292
+ },
293
+ },
294
+ required: ['name', 'arguments'],
295
+ additionalProperties: false,
296
+ },
297
+ },
298
+ required: [
299
+ 'found',
300
+ 'relic_id',
301
+ 'version',
302
+ 'resolved_path',
303
+ 'source_identity',
304
+ 'source_description',
305
+ 'republish_call',
306
+ ],
307
+ additionalProperties: false,
308
+ },
309
+ } as const;
310
+
231
311
  export const DESCRIBE_TOOL_DEFINITION = {
232
312
  name: DESCRIBE_TOOL_NAME,
233
313
  title: 'Describe the Relic client',
@@ -262,6 +342,44 @@ export const SERVER_INFO = {
262
342
 
263
343
  export const CAPABILITIES = { tools: {} } as const;
264
344
 
345
+ /**
346
+ * Server-level guidance, returned on the handshake so a client can put it in
347
+ * the model's context before any tool is called.
348
+ *
349
+ * The plugin ships a skill with the same facts, but a skill only reaches
350
+ * Claude Code, and only when somebody installs the plugin rather than wiring
351
+ * this server directly. Every other client saw tool descriptions and nothing
352
+ * else, which left five things an agent cannot read off a schema.
353
+ *
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.
358
+ *
359
+ * It costs context on every session, so it stays short and it stays true.
360
+ * Anything that needs a paragraph belongs in the skill or the disclosure.
361
+ */
362
+ export const INSTRUCTIONS = `Relic encrypts a file on this machine and uploads \
363
+ only ciphertext. The key lives in the URL fragment, which browsers never send \
364
+ to a server.
365
+
366
+ Five things that change how you should act:
367
+
368
+ 1. The link is the credential. Anyone holding it, fragment included, can read \
369
+ the file. Do not paste it into a tracker, a log, or a public channel.
370
+ 2. Publishing puts the key in this transcript. That is structural rather than \
371
+ 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.
376
+ 4. A relic can be republished only from the machine that published it, which \
377
+ is where its key and publish token are stored. Anywhere else it refuses, and \
378
+ no retry changes that.
379
+ 5. Rendered HTML and JSX run in an isolated frame with no network access. \
380
+ 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.`;
382
+
265
383
  /**
266
384
  * Handle one JSON-RPC message.
267
385
  *
@@ -297,6 +415,7 @@ export async function handleMessage(
297
415
  protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
298
416
  capabilities: CAPABILITIES,
299
417
  serverInfo: SERVER_INFO,
418
+ instructions: INSTRUCTIONS,
300
419
  },
301
420
  };
302
421
 
@@ -312,6 +431,7 @@ export async function handleMessage(
312
431
  protocolVersion: isSupportedVersion(asked) ? asked : PROTOCOL_VERSION,
313
432
  capabilities: CAPABILITIES,
314
433
  serverInfo: SERVER_INFO,
434
+ instructions: INSTRUCTIONS,
315
435
  },
316
436
  };
317
437
  }
@@ -326,6 +446,7 @@ export async function handleMessage(
326
446
  result: {
327
447
  tools: [
328
448
  TOOL_DEFINITION,
449
+ LOOKUP_TOOL_DEFINITION,
329
450
  REPUBLISH_TOOL_DEFINITION,
330
451
  DESCRIBE_TOOL_DEFINITION,
331
452
  ],
@@ -382,8 +503,9 @@ async function callTool(
382
503
  plaintext_transmitted_to_service: false,
383
504
  ciphertext_destination: 'object storage, via a signed URL',
384
505
  local_publish_state:
385
- 'relic id, key, and publish token per relic, written 0600 under ' +
386
- 'the user config directory; never printed, never sent',
506
+ 'relic id, source identity, key, and publish token per relic, ' +
507
+ 'written 0600 under the user config directory; key and token ' +
508
+ 'are never printed or sent',
387
509
  service_origin: deps.serviceOrigin,
388
510
  },
389
511
  isError: false,
@@ -391,6 +513,10 @@ async function callTool(
391
513
  };
392
514
  }
393
515
 
516
+ if (params['name'] === LOOKUP_TOOL_NAME) {
517
+ return callLookup(id, params, deps);
518
+ }
519
+
394
520
  if (params['name'] === REPUBLISH_TOOL_NAME) {
395
521
  return callRepublish(id, params, deps);
396
522
  }
@@ -426,9 +552,23 @@ async function callTool(
426
552
  );
427
553
  }
428
554
 
555
+ const forceNew = args['force_new'];
556
+ if (forceNew !== undefined && typeof forceNew !== 'boolean') {
557
+ return errorResponse(
558
+ id,
559
+ ERROR_CODES.invalidParams,
560
+ '`force_new` must be a boolean or omitted'
561
+ );
562
+ }
563
+
429
564
  try {
430
565
  const result = await publish(
431
- { path, filename, ttl_days: ttlDays.days },
566
+ {
567
+ path,
568
+ filename,
569
+ ttl_days: ttlDays.days,
570
+ force_new: forceNew === true,
571
+ },
432
572
  deps
433
573
  );
434
574
  return {
@@ -471,6 +611,63 @@ async function callTool(
471
611
  }
472
612
  }
473
613
 
614
+ async function callLookup(
615
+ id: string | number | null,
616
+ params: Record<string, unknown>,
617
+ deps: PublishDeps
618
+ ): Promise<JsonRpcResponse> {
619
+ const args = (params['arguments'] ?? {}) as Record<string, unknown>;
620
+ const path = args['path'];
621
+ if (typeof path !== 'string' || path.length === 0) {
622
+ return errorResponse(
623
+ id,
624
+ ERROR_CODES.invalidParams,
625
+ '`path` is required and must be a string'
626
+ );
627
+ }
628
+
629
+ try {
630
+ const lookup = await lookupPublishedSource(path, deps);
631
+ const match = lookup.match;
632
+ const republishCall =
633
+ match === undefined
634
+ ? null
635
+ : republishToolCall(match.relic_id, lookup.resolved_path);
636
+ const structuredContent = {
637
+ found: match !== undefined,
638
+ relic_id: match?.relic_id ?? null,
639
+ version: match?.version ?? null,
640
+ resolved_path: lookup.resolved_path,
641
+ source_identity: lookup.source.identity,
642
+ source_description: lookup.source.description,
643
+ republish_call: republishCall,
644
+ };
645
+ return {
646
+ jsonrpc: '2.0',
647
+ id,
648
+ result: {
649
+ content: [
650
+ {
651
+ type: 'text',
652
+ text:
653
+ match === undefined
654
+ ? `No prior relic is recorded for ${lookup.source.description}.`
655
+ : `Found ${lookup.source.description} as version ` +
656
+ `${match.version} of relic ${match.relic_id}.\n` +
657
+ `Call relic_republish(${JSON.stringify(
658
+ republishCall?.arguments
659
+ )}).`,
660
+ },
661
+ ],
662
+ structuredContent,
663
+ isError: false,
664
+ },
665
+ };
666
+ } catch (error) {
667
+ return { jsonrpc: '2.0', id, result: toolError(error) };
668
+ }
669
+ }
670
+
474
671
  async function callRepublish(
475
672
  id: string | number | null,
476
673
  params: Record<string, unknown>,
@@ -655,13 +852,14 @@ What the service operator can see: that a relic exists, roughly how big it is,
655
852
  what coarse class it was declared as, the publishing IP, and when it was
656
853
  fetched. Never the contents, and never the key.
657
854
 
658
- What this keeps on disk: for each relic you publish, its id, its key, and a
659
- publish token, in a 0600 file under your user config directory. That record
660
- is what lets a relic be republished later, and it is why republishing works
855
+ What this keeps on disk: for each relic you publish, its id, source identity,
856
+ key, and publish token, in a 0600 file under your user config directory. The
857
+ source index lets a fresh session find the id for relic_republish. The key and
858
+ token let that republish keep the same URL, and they are why republishing works
661
859
  only on the machine that published. The token's SHA-256 is the only copy the
662
- service ever holds, and neither secret is ever printed or logged. Deleting
663
- the file changes nothing for existing links; it only ends this machine's
664
- ability to update those relics.
860
+ service ever holds, and neither secret is ever printed or logged. Deleting the
861
+ file changes nothing for existing links; it only ends this machine's ability
862
+ to update those relics.
665
863
 
666
864
  What this does NOT protect against: the key is returned to your agent in the
667
865
  URL, so it enters the model's context and your session transcript. That is