relic-mcp 0.1.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 ADDED
@@ -0,0 +1,406 @@
1
+ /**
2
+ * The MCP server: a local binary, never a remote surface.
3
+ *
4
+ * It holds the key and encrypts in process. It returns no script. That is
5
+ * locked in `docs/frame.md` and it is the single most load-bearing structural
6
+ * decision in the publish path.
7
+ *
8
+ * **Why this cannot be a hosted MCP server**, stated once because it is the
9
+ * question everybody asks: a remote server would have to receive the file to
10
+ * encrypt it, which destroys the product. Zero-knowledge is not a feature
11
+ * layered on top; it is a consequence of the encryption happening on the
12
+ * machine that already has the plaintext. The transport can be stdio or HTTP,
13
+ * but the process runs next to the file either way.
14
+ *
15
+ * Protocol revision `2026-07-28`, which is stateless: no handshake, no
16
+ * session, no `Mcp-Session-Id`. Nothing is retained between calls, so the
17
+ * server can be restarted or run one-shot without a client noticing. The
18
+ * legacy `initialize` handshake is answered too, which the spec calls a
19
+ * dual-era server, because a client that only speaks the newest revision is
20
+ * unusable in most of the agents this product exists to serve.
21
+ */
22
+
23
+ import {
24
+ ERROR_CODES,
25
+ errorResponse,
26
+ isSupportedVersion,
27
+ type JsonRpcRequest,
28
+ type JsonRpcResponse,
29
+ LEGACY_PROTOCOL_VERSIONS,
30
+ PROTOCOL_VERSION,
31
+ requestedProtocolVersion,
32
+ SUPPORTED_PROTOCOL_VERSIONS,
33
+ unsupportedVersionError,
34
+ } from './protocol.ts';
35
+ import {
36
+ type PublishDeps,
37
+ PublishError,
38
+ publish,
39
+ ServerRefusal,
40
+ } from './publish.ts';
41
+
42
+ export {
43
+ LEGACY_PROTOCOL_VERSIONS,
44
+ PROTOCOL_VERSION,
45
+ SUPPORTED_PROTOCOL_VERSIONS,
46
+ };
47
+ export type { JsonRpcRequest, JsonRpcResponse };
48
+
49
+ /**
50
+ * `relic_publish`, prefixed with the product name.
51
+ *
52
+ * The MCP spec names the hazard and its own remedy: clients aggregating tools
53
+ * from multiple servers may hit collisions and should prefix tool names with a
54
+ * server identifier. A bare `publish` collides with incumbent publishing
55
+ * servers, and the consequence is a security outcome produced by a naming
56
+ * decision: the model asks for `publish`, the client disambiguates to whichever
57
+ * server it prefers, and the file lands somewhere with different encryption or
58
+ * none.
59
+ */
60
+ export const TOOL_NAME = 'relic_publish';
61
+
62
+ /**
63
+ * The inspection tool.
64
+ *
65
+ * A local client is opaque to the agent driving it, and "trust the binary" is
66
+ * a real hand-wave. This closes that gap without reopening the one the frame
67
+ * locked: the agent can read exactly what the encryption path does, on
68
+ * demand, without any of it being code that arrives ready to execute.
69
+ *
70
+ * Inspection decoupled from execution beats inspect-then-run, because the
71
+ * reviewer is not under time pressure and the reviewed text cannot also be
72
+ * the attack.
73
+ */
74
+ export const DESCRIBE_TOOL_NAME = 'relic_describe_client';
75
+
76
+ export const TOOL_DEFINITION = {
77
+ name: TOOL_NAME,
78
+ title: 'Publish a relic',
79
+ description:
80
+ 'Encrypt a file on this machine and publish it as a relic, returning a ' +
81
+ 'shareable URL. The encryption key is generated locally and never sent ' +
82
+ 'to the service. Takes a filesystem path, never inline content.',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ path: {
87
+ type: 'string',
88
+ description: 'Filesystem path to the file to publish.',
89
+ },
90
+ filename: {
91
+ type: 'string',
92
+ description:
93
+ 'Optional. Overrides the name written into the encrypted envelope ' +
94
+ 'header. Defaults to the basename of `path`.',
95
+ },
96
+ },
97
+ required: ['path'],
98
+ additionalProperties: false,
99
+ },
100
+ outputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ url: { type: 'string' },
104
+ relic_id: { type: 'string' },
105
+ relic_expires_at: { type: 'string' },
106
+ renderer_class: { type: 'string' },
107
+ filename: { type: 'string' },
108
+ resolved_path: { type: 'string' },
109
+ report_url: { type: 'string' },
110
+ disclosure_url: { type: 'string' },
111
+ },
112
+ required: [
113
+ 'url',
114
+ 'relic_id',
115
+ 'relic_expires_at',
116
+ 'renderer_class',
117
+ 'filename',
118
+ 'resolved_path',
119
+ 'report_url',
120
+ 'disclosure_url',
121
+ ],
122
+ additionalProperties: false,
123
+ },
124
+ } as const;
125
+
126
+ export const DESCRIBE_TOOL_DEFINITION = {
127
+ name: DESCRIBE_TOOL_NAME,
128
+ title: 'Describe the Relic client',
129
+ description:
130
+ 'Return exactly what this client does with your file: the encryption ' +
131
+ 'path, what leaves the machine, and what the service can see. Reads ' +
132
+ 'nothing and sends nothing.',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {},
136
+ additionalProperties: false,
137
+ },
138
+ } as const;
139
+
140
+ export const SERVER_INFO = {
141
+ name: 'relic',
142
+ title: 'Relic',
143
+ version: '0.1.0',
144
+ } as const;
145
+
146
+ export const CAPABILITIES = { tools: {} } as const;
147
+
148
+ /**
149
+ * Handle one JSON-RPC message.
150
+ *
151
+ * Returns undefined for notifications, which carry no id and take no
152
+ * response. Nothing here reads or writes state that outlives the call.
153
+ */
154
+ export async function handleMessage(
155
+ message: JsonRpcRequest,
156
+ deps: PublishDeps
157
+ ): Promise<JsonRpcResponse | undefined> {
158
+ if (message.id === undefined) return undefined; // notification
159
+ const id = message.id ?? null;
160
+
161
+ // `server/discover` and `initialize` are the two probes a client uses to
162
+ // find out what this server speaks, so neither may be refused for declaring
163
+ // a version the server does not have.
164
+ const isProbe =
165
+ message.method === 'server/discover' || message.method === 'initialize';
166
+
167
+ const requested = requestedProtocolVersion(message);
168
+ if (!isProbe && requested !== undefined && !isSupportedVersion(requested)) {
169
+ return unsupportedVersionError(id, requested);
170
+ }
171
+
172
+ switch (message.method) {
173
+ case 'server/discover':
174
+ // Mandatory in this revision: supported versions, capabilities, and
175
+ // identity in a single request, with no handshake to precede it.
176
+ return {
177
+ jsonrpc: '2.0',
178
+ id,
179
+ result: {
180
+ protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
181
+ capabilities: CAPABILITIES,
182
+ serverInfo: SERVER_INFO,
183
+ },
184
+ };
185
+
186
+ case 'initialize': {
187
+ // The legacy era. A modern client never sends this.
188
+ const asked =
189
+ (message.params?.['protocolVersion'] as string | undefined) ??
190
+ PROTOCOL_VERSION;
191
+ return {
192
+ jsonrpc: '2.0',
193
+ id,
194
+ result: {
195
+ protocolVersion: isSupportedVersion(asked) ? asked : PROTOCOL_VERSION,
196
+ capabilities: CAPABILITIES,
197
+ serverInfo: SERVER_INFO,
198
+ },
199
+ };
200
+ }
201
+
202
+ case 'ping':
203
+ return { jsonrpc: '2.0', id, result: {} };
204
+
205
+ case 'tools/list':
206
+ return {
207
+ jsonrpc: '2.0',
208
+ id,
209
+ result: { tools: [TOOL_DEFINITION, DESCRIBE_TOOL_DEFINITION] },
210
+ };
211
+
212
+ case 'tools/call':
213
+ return callTool(id, message.params ?? {}, deps);
214
+
215
+ default:
216
+ return errorResponse(
217
+ id,
218
+ ERROR_CODES.methodNotFound,
219
+ `unknown method: ${message.method}`
220
+ );
221
+ }
222
+ }
223
+
224
+ async function callTool(
225
+ id: string | number | null,
226
+ params: Record<string, unknown>,
227
+ deps: PublishDeps
228
+ ): Promise<JsonRpcResponse> {
229
+ if (params['name'] === DESCRIBE_TOOL_NAME) {
230
+ return {
231
+ jsonrpc: '2.0',
232
+ id,
233
+ result: {
234
+ content: [{ type: 'text', text: describeClient(deps) }],
235
+ structuredContent: {
236
+ encryption: 'AES-128-GCM, RFC 8188 aes128gcm framing',
237
+ key_origin: 'crypto.getRandomValues on this machine',
238
+ key_transmitted_to_service: false,
239
+ plaintext_transmitted_to_service: false,
240
+ ciphertext_destination: 'object storage, via a signed URL',
241
+ service_origin: deps.serviceOrigin,
242
+ },
243
+ isError: false,
244
+ },
245
+ };
246
+ }
247
+
248
+ if (params['name'] !== TOOL_NAME) {
249
+ return errorResponse(
250
+ id,
251
+ ERROR_CODES.invalidParams,
252
+ `unknown tool: ${String(params['name'])}`
253
+ );
254
+ }
255
+
256
+ const args = (params['arguments'] ?? {}) as Record<string, unknown>;
257
+ const path = args['path'];
258
+ if (typeof path !== 'string' || path.length === 0) {
259
+ return errorResponse(
260
+ id,
261
+ ERROR_CODES.invalidParams,
262
+ '`path` is required and must be a string'
263
+ );
264
+ }
265
+
266
+ const filename =
267
+ typeof args['filename'] === 'string' ? args['filename'] : undefined;
268
+
269
+ try {
270
+ const result = await publish({ path, filename }, deps);
271
+ return {
272
+ jsonrpc: '2.0',
273
+ id,
274
+ result: {
275
+ // The full URL including the fragment, because relaying a usable link
276
+ // is the product. The consequence is disclosed rather than hidden:
277
+ // the key enters the model's context and the session transcript on
278
+ // every publish, and the disclosure statement says so.
279
+ content: [
280
+ {
281
+ type: 'text',
282
+ text:
283
+ `Published ${result.filename} as a relic.\n\n${result.url}\n\n` +
284
+ `Expires ${result.relic_expires_at}. Anyone with this link, ` +
285
+ 'including its fragment, can read the file. The key is in the ' +
286
+ 'fragment and it is now in this transcript.\n' +
287
+ `What Relic knows: ${result.disclosure_url}`,
288
+ },
289
+ ],
290
+ structuredContent: result,
291
+ isError: false,
292
+ },
293
+ };
294
+ } catch (error) {
295
+ return { jsonrpc: '2.0', id, result: toolError(error) };
296
+ }
297
+ }
298
+
299
+ /**
300
+ * A failed publish is a tool error, not a protocol error.
301
+ *
302
+ * The distinction is the spec's: a protocol error means the call could not be
303
+ * made, and a tool error means it was made and failed. Reporting a refused
304
+ * publish as a protocol error would hide it from the model, which then cannot
305
+ * tell the user what went wrong or act on it.
306
+ */
307
+ function toolError(error: unknown): Record<string, unknown> {
308
+ if (error instanceof PublishError) {
309
+ return {
310
+ content: [{ type: 'text', text: `${error.code}: ${error.message}` }],
311
+ structuredContent: { code: error.code, ...error.details },
312
+ isError: true,
313
+ };
314
+ }
315
+ if (error instanceof ServerRefusal) {
316
+ return {
317
+ content: [{ type: 'text', text: `${error.code}: ${error.message}` }],
318
+ structuredContent: { code: error.code, ...error.problem },
319
+ isError: true,
320
+ };
321
+ }
322
+ return {
323
+ content: [{ type: 'text', text: `publish failed: ${String(error)}` }],
324
+ structuredContent: { code: 'unknown' },
325
+ isError: true,
326
+ };
327
+ }
328
+
329
+ /**
330
+ * What this client does with a file, in the order it does it.
331
+ *
332
+ * Written out rather than pointing at a URL, because a description the agent
333
+ * has to go fetch is a description nobody reads.
334
+ */
335
+ export function describeClient(deps: PublishDeps): string {
336
+ return `Relic publishing client, running locally on this machine.
337
+
338
+ What happens when you publish a file:
339
+
340
+ 1. The file is read from disk by this process. It is never sent anywhere in
341
+ plaintext.
342
+ 2. A 128-bit key and a 26-character relic id are drawn independently from this
343
+ machine's CSPRNG (crypto.getRandomValues). Neither derives from the other.
344
+ 3. The file is encrypted here, in this process, with AES-128-GCM under RFC 8188
345
+ aes128gcm framing: an HKDF-derived content key, counter-derived per-record
346
+ nonces, and a per-record authentication tag.
347
+ 4. Only ciphertext is uploaded, straight to object storage under a signed URL.
348
+ It does not pass through ${deps.serviceOrigin}.
349
+ 5. The service is told three things and nothing more: a coarse renderer class
350
+ from a seven-value list, the name of this client, and the exact byte length
351
+ of the ciphertext. Not your filename, not the mimetype, not the contents.
352
+ 6. You get back a URL whose fragment carries the key. Fragments are never sent
353
+ to a server by a browser.
354
+
355
+ What the service operator can see: that a relic exists, roughly how big it is,
356
+ what coarse class it was declared as, the publishing IP, and when it was
357
+ fetched. Never the contents, and never the key.
358
+
359
+ What this does NOT protect against: the key is returned to your agent in the
360
+ URL, so it enters the model's context and your session transcript. That is
361
+ structural, not a defect. Anyone who can read this conversation can open the
362
+ relic.
363
+
364
+ The code doing all of this is on disk in this package and can be read. Nothing
365
+ is fetched from the network and executed.`;
366
+ }
367
+
368
+ /** Read newline-delimited JSON-RPC from a stream and write responses back. */
369
+ export async function serveStdio(
370
+ deps: PublishDeps,
371
+ input: ReadableStream<Uint8Array>,
372
+ write: (line: string) => void
373
+ ): Promise<void> {
374
+ const decoder = new TextDecoder();
375
+ const reader = input.getReader();
376
+ let buffer = '';
377
+
378
+ for (;;) {
379
+ const { done, value } = await reader.read();
380
+ if (done) break;
381
+ buffer += decoder.decode(value, { stream: true });
382
+
383
+ let newline = buffer.indexOf('\n');
384
+ while (newline >= 0) {
385
+ const line = buffer.slice(0, newline).trim();
386
+ buffer = buffer.slice(newline + 1);
387
+ newline = buffer.indexOf('\n');
388
+ if (line.length === 0) continue;
389
+
390
+ let message: JsonRpcRequest;
391
+ try {
392
+ message = JSON.parse(line) as JsonRpcRequest;
393
+ } catch {
394
+ write(
395
+ JSON.stringify(
396
+ errorResponse(null, ERROR_CODES.parseError, 'parse error')
397
+ )
398
+ );
399
+ continue;
400
+ }
401
+
402
+ const response = await handleMessage(message, deps);
403
+ if (response !== undefined) write(JSON.stringify(response));
404
+ }
405
+ }
406
+ }