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/README.md +90 -0
- package/dist/relic-mcp.js +1088 -0
- package/package.json +47 -0
- package/src/files.ts +30 -0
- package/src/http.ts +192 -0
- package/src/index.ts +92 -0
- package/src/protocol.ts +148 -0
- package/src/publish.ts +370 -0
- package/src/server.ts +406 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
|
|
7
|
+
// src/files.ts
|
|
8
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
9
|
+
import { basename as pathBasename, resolve as pathResolve } from "node:path";
|
|
10
|
+
var nodeFiles = {
|
|
11
|
+
resolve(path) {
|
|
12
|
+
return pathResolve(process.cwd(), path);
|
|
13
|
+
},
|
|
14
|
+
basename(path) {
|
|
15
|
+
return pathBasename(path);
|
|
16
|
+
},
|
|
17
|
+
async stat(path) {
|
|
18
|
+
const info = await lstat(path).catch(() => {
|
|
19
|
+
return;
|
|
20
|
+
});
|
|
21
|
+
if (info === undefined)
|
|
22
|
+
return { kind: "other" };
|
|
23
|
+
if (info.isDirectory())
|
|
24
|
+
return { kind: "directory" };
|
|
25
|
+
if (!info.isFile())
|
|
26
|
+
return { kind: "other" };
|
|
27
|
+
return { kind: "file", size: info.size };
|
|
28
|
+
},
|
|
29
|
+
async read(path) {
|
|
30
|
+
return new Uint8Array(await readFile(path));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/protocol.ts
|
|
35
|
+
var PROTOCOL_VERSION = "2026-07-28";
|
|
36
|
+
var LEGACY_PROTOCOL_VERSIONS = [
|
|
37
|
+
"2025-11-25",
|
|
38
|
+
"2025-06-18",
|
|
39
|
+
"2025-03-26"
|
|
40
|
+
];
|
|
41
|
+
var SUPPORTED_PROTOCOL_VERSIONS = [
|
|
42
|
+
PROTOCOL_VERSION,
|
|
43
|
+
...LEGACY_PROTOCOL_VERSIONS
|
|
44
|
+
];
|
|
45
|
+
var PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion";
|
|
46
|
+
var ERROR_CODES = {
|
|
47
|
+
headerMismatch: -32020,
|
|
48
|
+
unsupportedProtocolVersion: -32022,
|
|
49
|
+
methodNotFound: -32601,
|
|
50
|
+
invalidParams: -32602,
|
|
51
|
+
parseError: -32700
|
|
52
|
+
};
|
|
53
|
+
function errorResponse(id, code, message, data) {
|
|
54
|
+
return {
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
id,
|
|
57
|
+
error: data === undefined ? { code, message } : { code, message, data }
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function requestedProtocolVersion(message) {
|
|
61
|
+
const meta = message.params?.["_meta"];
|
|
62
|
+
if (typeof meta !== "object" || meta === null)
|
|
63
|
+
return;
|
|
64
|
+
const version = meta[PROTOCOL_VERSION_META_KEY];
|
|
65
|
+
return typeof version === "string" ? version : undefined;
|
|
66
|
+
}
|
|
67
|
+
function isSupportedVersion(version) {
|
|
68
|
+
return SUPPORTED_PROTOCOL_VERSIONS.includes(version);
|
|
69
|
+
}
|
|
70
|
+
function unsupportedVersionError(id, requested) {
|
|
71
|
+
return errorResponse(id, ERROR_CODES.unsupportedProtocolVersion, "Unsupported protocol version", { supported: [...SUPPORTED_PROTOCOL_VERSIONS], requested });
|
|
72
|
+
}
|
|
73
|
+
function decodeHeaderValue(raw) {
|
|
74
|
+
if (!raw.startsWith("=?base64?") || !raw.endsWith("?="))
|
|
75
|
+
return raw;
|
|
76
|
+
const encoded = raw.slice("=?base64?".length, -"?=".length);
|
|
77
|
+
try {
|
|
78
|
+
return new TextDecoder().decode(Uint8Array.from(atob(encoded), (ch) => ch.charCodeAt(0)));
|
|
79
|
+
} catch {
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function expectedMcpName(message) {
|
|
84
|
+
if (message.method === "tools/call" || message.method === "prompts/get") {
|
|
85
|
+
const name = message.params?.["name"];
|
|
86
|
+
return typeof name === "string" ? name : undefined;
|
|
87
|
+
}
|
|
88
|
+
if (message.method === "resources/read") {
|
|
89
|
+
const uri = message.params?.["uri"];
|
|
90
|
+
return typeof uri === "string" ? uri : undefined;
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ../relic-format/src/errors.ts
|
|
96
|
+
class RelicFormatError extends Error {
|
|
97
|
+
name = "RelicFormatError";
|
|
98
|
+
}
|
|
99
|
+
class MalformedFragmentError extends RelicFormatError {
|
|
100
|
+
name = "MalformedFragmentError";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class MalformedContainerError extends RelicFormatError {
|
|
104
|
+
name = "MalformedContainerError";
|
|
105
|
+
}
|
|
106
|
+
class StrictParseError extends MalformedContainerError {
|
|
107
|
+
name = "StrictParseError";
|
|
108
|
+
}
|
|
109
|
+
class ContentTooLargeError extends RelicFormatError {
|
|
110
|
+
declaredBytes;
|
|
111
|
+
limitBytes;
|
|
112
|
+
name = "ContentTooLargeError";
|
|
113
|
+
constructor(declaredBytes, limitBytes) {
|
|
114
|
+
super(`content is ${declaredBytes} bytes, over the ${limitBytes} cap`);
|
|
115
|
+
this.declaredBytes = declaredBytes;
|
|
116
|
+
this.limitBytes = limitBytes;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ../relic-format/src/envelope.ts
|
|
121
|
+
var MAX_FILENAME_BYTES = 1024;
|
|
122
|
+
var MAX_MIMETYPE_BYTES = 255;
|
|
123
|
+
var ENTRY_COUNT_V1 = 1;
|
|
124
|
+
var MAX_HEADER_BYTES = 2 + (2 + MAX_FILENAME_BYTES + 2 + MAX_MIMETYPE_BYTES + 8 + 8);
|
|
125
|
+
var encoder = new TextEncoder;
|
|
126
|
+
var decoder = new TextDecoder("utf-8", { fatal: false });
|
|
127
|
+
function encodeEnvelope(envelope) {
|
|
128
|
+
if (envelope.entries.length !== ENTRY_COUNT_V1) {
|
|
129
|
+
throw new StrictParseError(`version 1 carries exactly ${ENTRY_COUNT_V1} entry, got ` + `${envelope.entries.length}`);
|
|
130
|
+
}
|
|
131
|
+
const parts = [];
|
|
132
|
+
const prelude = new Uint8Array(2);
|
|
133
|
+
prelude[0] = envelope.version;
|
|
134
|
+
prelude[1] = envelope.entries.length;
|
|
135
|
+
parts.push(prelude);
|
|
136
|
+
for (const entry of envelope.entries) {
|
|
137
|
+
const filename = encoder.encode(entry.filename);
|
|
138
|
+
const mimetype = encoder.encode(entry.mimetype);
|
|
139
|
+
if (filename.length > MAX_FILENAME_BYTES) {
|
|
140
|
+
throw new StrictParseError(`filename is ${filename.length} bytes, over ${MAX_FILENAME_BYTES}`);
|
|
141
|
+
}
|
|
142
|
+
if (mimetype.length > MAX_MIMETYPE_BYTES) {
|
|
143
|
+
throw new StrictParseError(`mimetype is ${mimetype.length} bytes, over ${MAX_MIMETYPE_BYTES}`);
|
|
144
|
+
}
|
|
145
|
+
const fixed = new Uint8Array(2 + filename.length + 2 + mimetype.length + 16);
|
|
146
|
+
const view = new DataView(fixed.buffer);
|
|
147
|
+
let cursor = 0;
|
|
148
|
+
view.setUint16(cursor, filename.length, false);
|
|
149
|
+
cursor += 2;
|
|
150
|
+
fixed.set(filename, cursor);
|
|
151
|
+
cursor += filename.length;
|
|
152
|
+
view.setUint16(cursor, mimetype.length, false);
|
|
153
|
+
cursor += 2;
|
|
154
|
+
fixed.set(mimetype, cursor);
|
|
155
|
+
cursor += mimetype.length;
|
|
156
|
+
view.setBigUint64(cursor, BigInt(entry.offset), false);
|
|
157
|
+
cursor += 8;
|
|
158
|
+
view.setBigUint64(cursor, BigInt(entry.length), false);
|
|
159
|
+
parts.push(fixed);
|
|
160
|
+
}
|
|
161
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
162
|
+
const out = new Uint8Array(total);
|
|
163
|
+
let offset = 0;
|
|
164
|
+
for (const part of parts) {
|
|
165
|
+
out.set(part, offset);
|
|
166
|
+
offset += part.length;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ../relic-format/src/fragment.ts
|
|
172
|
+
var FORMAT_VERSION = 1;
|
|
173
|
+
var VERSION_MARKER = "r1";
|
|
174
|
+
var KEY_BYTES = 16;
|
|
175
|
+
var B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
176
|
+
function encodeKey(key) {
|
|
177
|
+
let out = "";
|
|
178
|
+
let accumulator = 0;
|
|
179
|
+
let bitsHeld = 0;
|
|
180
|
+
for (const byte of key) {
|
|
181
|
+
accumulator = accumulator << 8 | byte;
|
|
182
|
+
bitsHeld += 8;
|
|
183
|
+
while (bitsHeld >= 6) {
|
|
184
|
+
bitsHeld -= 6;
|
|
185
|
+
out += B64URL[accumulator >>> bitsHeld & 63];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (bitsHeld > 0) {
|
|
189
|
+
out += B64URL[accumulator << 6 - bitsHeld & 63];
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
function generateKey() {
|
|
194
|
+
const key = new Uint8Array(KEY_BYTES);
|
|
195
|
+
crypto.getRandomValues(key);
|
|
196
|
+
return key;
|
|
197
|
+
}
|
|
198
|
+
function encodeFragment(key) {
|
|
199
|
+
if (key.length !== KEY_BYTES) {
|
|
200
|
+
throw new MalformedFragmentError(`key must be ${KEY_BYTES} bytes`);
|
|
201
|
+
}
|
|
202
|
+
return VERSION_MARKER + encodeKey(key);
|
|
203
|
+
}
|
|
204
|
+
function relicUrl(origin, relicId, key) {
|
|
205
|
+
const base = origin.endsWith("/") ? origin.slice(0, -1) : origin;
|
|
206
|
+
return `${base}/${relicId}#${encodeFragment(key)}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ../relic-format/src/rfc8188.ts
|
|
210
|
+
var HEADER_BYTES = 21;
|
|
211
|
+
var TAG_BYTES = 16;
|
|
212
|
+
var DELIMITER_BYTES = 1;
|
|
213
|
+
var RECORD_OVERHEAD = TAG_BYTES + DELIMITER_BYTES;
|
|
214
|
+
var SALT_BYTES = 16;
|
|
215
|
+
var RECORD_SIZE = 65536;
|
|
216
|
+
function recordCapacity(rs) {
|
|
217
|
+
return rs - RECORD_OVERHEAD;
|
|
218
|
+
}
|
|
219
|
+
var CEK_INFO = new TextEncoder().encode("Content-Encoding: aes128gcm\x00");
|
|
220
|
+
var NONCE_INFO = new TextEncoder().encode("Content-Encoding: nonce\x00");
|
|
221
|
+
async function deriveKeys(ikm, salt) {
|
|
222
|
+
const material = await crypto.subtle.importKey("raw", toBufferSource(ikm), "HKDF", false, ["deriveBits"]);
|
|
223
|
+
const cekBits = await crypto.subtle.deriveBits({
|
|
224
|
+
name: "HKDF",
|
|
225
|
+
hash: "SHA-256",
|
|
226
|
+
salt: toBufferSource(salt),
|
|
227
|
+
info: CEK_INFO
|
|
228
|
+
}, material, 128);
|
|
229
|
+
const nonceBits = await crypto.subtle.deriveBits({
|
|
230
|
+
name: "HKDF",
|
|
231
|
+
hash: "SHA-256",
|
|
232
|
+
salt: toBufferSource(salt),
|
|
233
|
+
info: NONCE_INFO
|
|
234
|
+
}, material, 96);
|
|
235
|
+
const cek = await crypto.subtle.importKey("raw", cekBits, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
236
|
+
return { cek, nonceBase: new Uint8Array(nonceBits) };
|
|
237
|
+
}
|
|
238
|
+
function recordNonce(nonceBase, seq) {
|
|
239
|
+
if (!Number.isSafeInteger(seq) || seq < 0) {
|
|
240
|
+
throw new MalformedContainerError(`record sequence ${seq} is not valid`);
|
|
241
|
+
}
|
|
242
|
+
const nonce = new Uint8Array(nonceBase);
|
|
243
|
+
let remaining = seq;
|
|
244
|
+
for (let offset = nonce.length - 1;offset >= 0 && remaining > 0; offset--) {
|
|
245
|
+
nonce[offset] = nonce[offset] ^ remaining & 255;
|
|
246
|
+
remaining = Math.floor(remaining / 256);
|
|
247
|
+
}
|
|
248
|
+
return nonce;
|
|
249
|
+
}
|
|
250
|
+
function encodeHeader(salt, rs) {
|
|
251
|
+
if (salt.length !== SALT_BYTES) {
|
|
252
|
+
throw new MalformedContainerError(`salt must be ${SALT_BYTES} bytes`);
|
|
253
|
+
}
|
|
254
|
+
const header = new Uint8Array(HEADER_BYTES);
|
|
255
|
+
header.set(salt, 0);
|
|
256
|
+
new DataView(header.buffer).setUint32(SALT_BYTES, rs, false);
|
|
257
|
+
header[SALT_BYTES + 4] = 0;
|
|
258
|
+
return header;
|
|
259
|
+
}
|
|
260
|
+
async function encryptRecord(keys, seq, data, isLast, targetDataBytes = data.length) {
|
|
261
|
+
if (targetDataBytes < data.length) {
|
|
262
|
+
throw new MalformedContainerError("pad target is smaller than the data");
|
|
263
|
+
}
|
|
264
|
+
const plaintext = new Uint8Array(targetDataBytes + DELIMITER_BYTES);
|
|
265
|
+
plaintext.set(data, 0);
|
|
266
|
+
plaintext[data.length] = isLast ? 2 : 1;
|
|
267
|
+
const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv: toBufferSource(recordNonce(keys.nonceBase, seq)) }, keys.cek, toBufferSource(plaintext));
|
|
268
|
+
return new Uint8Array(sealed);
|
|
269
|
+
}
|
|
270
|
+
function toBufferSource(bytes) {
|
|
271
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ../relic-format/src/container.ts
|
|
275
|
+
var PLAINTEXT_CAP_BYTES = 100 * 1024 * 1024;
|
|
276
|
+
async function encryptRelic(input) {
|
|
277
|
+
const rs = input.rs ?? RECORD_SIZE;
|
|
278
|
+
const capacity = recordCapacity(rs);
|
|
279
|
+
if (input.content.length > PLAINTEXT_CAP_BYTES) {
|
|
280
|
+
throw new ContentTooLargeError(input.content.length, PLAINTEXT_CAP_BYTES);
|
|
281
|
+
}
|
|
282
|
+
const salt = input.salt ?? randomSalt();
|
|
283
|
+
const keys = await deriveKeys(input.key, salt);
|
|
284
|
+
const envelope = {
|
|
285
|
+
version: FORMAT_VERSION,
|
|
286
|
+
entries: [
|
|
287
|
+
{
|
|
288
|
+
filename: input.filename,
|
|
289
|
+
mimetype: input.mimetype,
|
|
290
|
+
offset: 0,
|
|
291
|
+
length: input.content.length
|
|
292
|
+
}
|
|
293
|
+
]
|
|
294
|
+
};
|
|
295
|
+
const envelopeBytes = encodeEnvelope(envelope);
|
|
296
|
+
if (envelopeBytes.length > capacity) {
|
|
297
|
+
throw new MalformedContainerError(`envelope header is ${envelopeBytes.length} bytes, over the ` + `${capacity} a record can carry`);
|
|
298
|
+
}
|
|
299
|
+
const parts = [encodeHeader(salt, rs)];
|
|
300
|
+
if (input.content.length === 0) {
|
|
301
|
+
parts.push(await encryptRecord(keys, 0, envelopeBytes, true));
|
|
302
|
+
return concat(parts);
|
|
303
|
+
}
|
|
304
|
+
parts.push(await encryptRecord(keys, 0, envelopeBytes, false, capacity));
|
|
305
|
+
const recordCount = Math.ceil(input.content.length / capacity);
|
|
306
|
+
for (let index = 0;index < recordCount; index++) {
|
|
307
|
+
const start = index * capacity;
|
|
308
|
+
const chunk = input.content.slice(start, start + capacity);
|
|
309
|
+
const isLast = index === recordCount - 1;
|
|
310
|
+
parts.push(await encryptRecord(keys, index + 1, chunk, isLast));
|
|
311
|
+
}
|
|
312
|
+
return concat(parts);
|
|
313
|
+
}
|
|
314
|
+
function randomSalt() {
|
|
315
|
+
const salt = new Uint8Array(SALT_BYTES);
|
|
316
|
+
crypto.getRandomValues(salt);
|
|
317
|
+
return salt;
|
|
318
|
+
}
|
|
319
|
+
function concat(parts) {
|
|
320
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
321
|
+
const out = new Uint8Array(total);
|
|
322
|
+
let offset = 0;
|
|
323
|
+
for (const part of parts) {
|
|
324
|
+
out.set(part, offset);
|
|
325
|
+
offset += part.length;
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
// ../relic-format/src/id.ts
|
|
330
|
+
var ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
331
|
+
var ID_ENTROPY_BYTES = 16;
|
|
332
|
+
var RESERVED_SEGMENTS = [
|
|
333
|
+
"abuse",
|
|
334
|
+
"policy",
|
|
335
|
+
"robots.txt",
|
|
336
|
+
"favicon.ico",
|
|
337
|
+
"sitemap.xml",
|
|
338
|
+
"manifest.webmanifest",
|
|
339
|
+
"sw.js",
|
|
340
|
+
"assets",
|
|
341
|
+
"api",
|
|
342
|
+
"health",
|
|
343
|
+
".well-known",
|
|
344
|
+
"sandbox.html"
|
|
345
|
+
];
|
|
346
|
+
var RESERVED_NORMALIZED = new Set(RESERVED_SEGMENTS.map((word) => foldAliases(word.toLowerCase())));
|
|
347
|
+
|
|
348
|
+
class InvalidRelicIdError extends Error {
|
|
349
|
+
failure;
|
|
350
|
+
name = "InvalidRelicIdError";
|
|
351
|
+
constructor(failure, message) {
|
|
352
|
+
super(message);
|
|
353
|
+
this.failure = failure;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function foldAliases(lowered) {
|
|
357
|
+
let out = "";
|
|
358
|
+
for (const ch of lowered) {
|
|
359
|
+
if (ch === "i" || ch === "l")
|
|
360
|
+
out += "1";
|
|
361
|
+
else if (ch === "o")
|
|
362
|
+
out += "0";
|
|
363
|
+
else
|
|
364
|
+
out += ch;
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
function encodeRelicId(entropy) {
|
|
369
|
+
if (entropy.length !== ID_ENTROPY_BYTES) {
|
|
370
|
+
throw new InvalidRelicIdError("length", `relic id entropy must be ${ID_ENTROPY_BYTES} bytes`);
|
|
371
|
+
}
|
|
372
|
+
let out = "";
|
|
373
|
+
let accumulator = 0;
|
|
374
|
+
let bitsHeld = 0;
|
|
375
|
+
for (const byte of entropy) {
|
|
376
|
+
accumulator = accumulator << 8 | byte;
|
|
377
|
+
bitsHeld += 8;
|
|
378
|
+
while (bitsHeld >= 5) {
|
|
379
|
+
bitsHeld -= 5;
|
|
380
|
+
const index = accumulator >>> bitsHeld & 31;
|
|
381
|
+
out += ALPHABET[index];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (bitsHeld > 0) {
|
|
385
|
+
const index = accumulator << 5 - bitsHeld & 31;
|
|
386
|
+
out += ALPHABET[index];
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
function generateRelicId() {
|
|
391
|
+
const entropy = new Uint8Array(ID_ENTROPY_BYTES);
|
|
392
|
+
crypto.getRandomValues(entropy);
|
|
393
|
+
return encodeRelicId(entropy);
|
|
394
|
+
}
|
|
395
|
+
// ../relic-format/src/renderer-class.ts
|
|
396
|
+
var MAGIC = [
|
|
397
|
+
{ bytes: [137, 80, 78, 71], offset: 0, cls: "image" },
|
|
398
|
+
{ bytes: [255, 216, 255], offset: 0, cls: "image" },
|
|
399
|
+
{ bytes: [71, 73, 70, 56], offset: 0, cls: "image" },
|
|
400
|
+
{ bytes: [66, 77], offset: 0, cls: "image" },
|
|
401
|
+
{ bytes: [80, 75, 3, 4], offset: 0, cls: "archive" },
|
|
402
|
+
{ bytes: [80, 75, 5, 6], offset: 0, cls: "archive" },
|
|
403
|
+
{ bytes: [31, 139], offset: 0, cls: "archive" },
|
|
404
|
+
{ bytes: [66, 90, 104], offset: 0, cls: "archive" },
|
|
405
|
+
{ bytes: [253, 55, 122, 88, 90], offset: 0, cls: "archive" },
|
|
406
|
+
{ bytes: [40, 181, 47, 253], offset: 0, cls: "archive" },
|
|
407
|
+
{ bytes: [117, 115, 116, 97, 114], offset: 257, cls: "archive" },
|
|
408
|
+
{ bytes: [37, 80, 68, 70], offset: 0, cls: "binary" },
|
|
409
|
+
{ bytes: [73, 68, 51], offset: 0, cls: "media" },
|
|
410
|
+
{ bytes: [79, 103, 103, 83], offset: 0, cls: "media" },
|
|
411
|
+
{ bytes: [102, 116, 121, 112], offset: 4, cls: "media" }
|
|
412
|
+
];
|
|
413
|
+
var EXTENSIONS = {
|
|
414
|
+
md: "markdown",
|
|
415
|
+
markdown: "markdown",
|
|
416
|
+
mdown: "markdown",
|
|
417
|
+
mkd: "markdown",
|
|
418
|
+
html: "html",
|
|
419
|
+
htm: "html",
|
|
420
|
+
xhtml: "html",
|
|
421
|
+
svg: "image",
|
|
422
|
+
png: "image",
|
|
423
|
+
jpg: "image",
|
|
424
|
+
jpeg: "image",
|
|
425
|
+
gif: "image",
|
|
426
|
+
webp: "image",
|
|
427
|
+
avif: "image",
|
|
428
|
+
bmp: "image",
|
|
429
|
+
ico: "image",
|
|
430
|
+
mp4: "media",
|
|
431
|
+
webm: "media",
|
|
432
|
+
mov: "media",
|
|
433
|
+
mkv: "media",
|
|
434
|
+
mp3: "media",
|
|
435
|
+
wav: "media",
|
|
436
|
+
ogg: "media",
|
|
437
|
+
flac: "media",
|
|
438
|
+
m4a: "media",
|
|
439
|
+
zip: "archive",
|
|
440
|
+
tar: "archive",
|
|
441
|
+
gz: "archive",
|
|
442
|
+
tgz: "archive",
|
|
443
|
+
bz2: "archive",
|
|
444
|
+
xz: "archive",
|
|
445
|
+
zst: "archive",
|
|
446
|
+
"7z": "archive",
|
|
447
|
+
rar: "archive",
|
|
448
|
+
ts: "code",
|
|
449
|
+
tsx: "code",
|
|
450
|
+
js: "code",
|
|
451
|
+
jsx: "code",
|
|
452
|
+
mjs: "code",
|
|
453
|
+
cjs: "code",
|
|
454
|
+
json: "code",
|
|
455
|
+
jsonc: "code",
|
|
456
|
+
yaml: "code",
|
|
457
|
+
yml: "code",
|
|
458
|
+
toml: "code",
|
|
459
|
+
ini: "code",
|
|
460
|
+
xml: "code",
|
|
461
|
+
py: "code",
|
|
462
|
+
rb: "code",
|
|
463
|
+
go: "code",
|
|
464
|
+
rs: "code",
|
|
465
|
+
java: "code",
|
|
466
|
+
kt: "code",
|
|
467
|
+
swift: "code",
|
|
468
|
+
c: "code",
|
|
469
|
+
h: "code",
|
|
470
|
+
cc: "code",
|
|
471
|
+
cpp: "code",
|
|
472
|
+
hpp: "code",
|
|
473
|
+
cs: "code",
|
|
474
|
+
php: "code",
|
|
475
|
+
ex: "code",
|
|
476
|
+
exs: "code",
|
|
477
|
+
erl: "code",
|
|
478
|
+
scala: "code",
|
|
479
|
+
clj: "code",
|
|
480
|
+
hs: "code",
|
|
481
|
+
lua: "code",
|
|
482
|
+
pl: "code",
|
|
483
|
+
r: "code",
|
|
484
|
+
sql: "code",
|
|
485
|
+
sh: "code",
|
|
486
|
+
bash: "code",
|
|
487
|
+
zsh: "code",
|
|
488
|
+
fish: "code",
|
|
489
|
+
ps1: "code",
|
|
490
|
+
dockerfile: "code",
|
|
491
|
+
tf: "code",
|
|
492
|
+
hcl: "code",
|
|
493
|
+
proto: "code",
|
|
494
|
+
graphql: "code",
|
|
495
|
+
gql: "code",
|
|
496
|
+
css: "code",
|
|
497
|
+
scss: "code",
|
|
498
|
+
sass: "code",
|
|
499
|
+
less: "code",
|
|
500
|
+
diff: "code",
|
|
501
|
+
patch: "code",
|
|
502
|
+
csv: "code",
|
|
503
|
+
tsv: "code",
|
|
504
|
+
txt: "code",
|
|
505
|
+
log: "code",
|
|
506
|
+
text: "code"
|
|
507
|
+
};
|
|
508
|
+
function extensionOf(filename) {
|
|
509
|
+
const base = filename.slice(filename.lastIndexOf("/") + 1).toLowerCase();
|
|
510
|
+
if (base === "dockerfile")
|
|
511
|
+
return "dockerfile";
|
|
512
|
+
const dot = base.lastIndexOf(".");
|
|
513
|
+
if (dot <= 0 || dot === base.length - 1)
|
|
514
|
+
return;
|
|
515
|
+
return base.slice(dot + 1);
|
|
516
|
+
}
|
|
517
|
+
function matchesMagic(content, signature) {
|
|
518
|
+
const end = signature.offset + signature.bytes.length;
|
|
519
|
+
if (content.length < end)
|
|
520
|
+
return false;
|
|
521
|
+
for (let index = 0;index < signature.bytes.length; index++) {
|
|
522
|
+
if (content[signature.offset + index] !== signature.bytes[index]) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
function looksTextual(content) {
|
|
529
|
+
const limit = Math.min(content.length, 8192);
|
|
530
|
+
if (limit === 0)
|
|
531
|
+
return false;
|
|
532
|
+
let suspicious = 0;
|
|
533
|
+
for (let index = 0;index < limit; index++) {
|
|
534
|
+
const byte = content[index];
|
|
535
|
+
if (byte === 0)
|
|
536
|
+
return false;
|
|
537
|
+
const isPlainControl = byte < 9 || byte > 13 && byte < 32 || byte === 127;
|
|
538
|
+
if (isPlainControl)
|
|
539
|
+
suspicious++;
|
|
540
|
+
}
|
|
541
|
+
return suspicious / limit < 0.01;
|
|
542
|
+
}
|
|
543
|
+
var HTML_PREFIXES = ["<!doctype html", "<html", "<!-- ", "<svg"];
|
|
544
|
+
function deriveRendererClass(content, filename) {
|
|
545
|
+
if (content.length === 0)
|
|
546
|
+
return "binary";
|
|
547
|
+
for (const signature of MAGIC) {
|
|
548
|
+
if (matchesMagic(content, signature))
|
|
549
|
+
return signature.cls;
|
|
550
|
+
}
|
|
551
|
+
const extension = extensionOf(filename);
|
|
552
|
+
if (extension !== undefined) {
|
|
553
|
+
const fromExtension = EXTENSIONS[extension];
|
|
554
|
+
if (fromExtension !== undefined)
|
|
555
|
+
return fromExtension;
|
|
556
|
+
}
|
|
557
|
+
if (looksTextual(content)) {
|
|
558
|
+
const head = new TextDecoder("utf-8", { fatal: false }).decode(content.slice(0, 256)).trimStart().toLowerCase();
|
|
559
|
+
if (HTML_PREFIXES.some((prefix) => head.startsWith(prefix)))
|
|
560
|
+
return "html";
|
|
561
|
+
return "code";
|
|
562
|
+
}
|
|
563
|
+
return "binary";
|
|
564
|
+
}
|
|
565
|
+
// src/publish.ts
|
|
566
|
+
class PublishError extends Error {
|
|
567
|
+
code;
|
|
568
|
+
details;
|
|
569
|
+
name = "PublishError";
|
|
570
|
+
constructor(code, message, details = {}) {
|
|
571
|
+
super(message);
|
|
572
|
+
this.code = code;
|
|
573
|
+
this.details = details;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
class ServerRefusal extends Error {
|
|
578
|
+
code;
|
|
579
|
+
status;
|
|
580
|
+
problem;
|
|
581
|
+
name = "ServerRefusal";
|
|
582
|
+
constructor(code, status, problem) {
|
|
583
|
+
super(String(problem["detail"] ?? code));
|
|
584
|
+
this.code = code;
|
|
585
|
+
this.status = status;
|
|
586
|
+
this.problem = problem;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async function publish(input, deps) {
|
|
590
|
+
const source = await readSource(input.path, deps.files);
|
|
591
|
+
const challenge = await postJson(deps, `${deps.serviceOrigin}/api/challenge`, {});
|
|
592
|
+
const sizeLimit = Number(challenge["size_limit_bytes"]);
|
|
593
|
+
const sizeBasis = String(challenge["size_basis"]);
|
|
594
|
+
if (source.bytes.length > sizeLimit) {
|
|
595
|
+
throw new PublishError("local_size_precheck_failed", `${source.basename} is ${source.bytes.length} bytes, over the ` + `${sizeLimit}-byte cap`, {
|
|
596
|
+
size_limit_bytes: sizeLimit,
|
|
597
|
+
declared_size_bytes: source.bytes.length,
|
|
598
|
+
size_basis: sizeBasis
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const filename = input.filename ?? source.basename;
|
|
602
|
+
const rendererClass = deriveRendererClass(source.bytes, filename);
|
|
603
|
+
const retries = deps.maxCollisionRetries ?? 3;
|
|
604
|
+
let lastCollision;
|
|
605
|
+
for (let attempt = 0;attempt <= retries; attempt++) {
|
|
606
|
+
const relicId = generateRelicId();
|
|
607
|
+
const key = generateKey();
|
|
608
|
+
const container2 = await encryptRelic({
|
|
609
|
+
content: source.bytes,
|
|
610
|
+
filename,
|
|
611
|
+
mimetype: guessMimetype(filename, rendererClass),
|
|
612
|
+
key
|
|
613
|
+
});
|
|
614
|
+
let grant;
|
|
615
|
+
try {
|
|
616
|
+
grant = await postJson(deps, `${deps.serviceOrigin}/api/grant`, {
|
|
617
|
+
challenge_nonce: challenge["challenge_nonce"],
|
|
618
|
+
relic_id: relicId,
|
|
619
|
+
renderer_class: rendererClass,
|
|
620
|
+
publishing_client: deps.clientName,
|
|
621
|
+
declared_size_bytes: source.bytes.length,
|
|
622
|
+
declared_ciphertext_bytes: container2.length
|
|
623
|
+
});
|
|
624
|
+
} catch (error) {
|
|
625
|
+
if (error instanceof ServerRefusal && error.code === "relic_id_collision") {
|
|
626
|
+
lastCollision = error;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
const upload = await deps.fetch(String(grant["upload_url"]), {
|
|
632
|
+
method: "PUT",
|
|
633
|
+
headers: { "content-length": String(container2.length) },
|
|
634
|
+
body: container2
|
|
635
|
+
});
|
|
636
|
+
if (!upload.ok) {
|
|
637
|
+
throw new PublishError("upload_failed", `upload returned ${upload.status}`, { relic_id: relicId, status: upload.status });
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
await postJson(deps, `${deps.serviceOrigin}/api/relics/${relicId}/complete`, {});
|
|
641
|
+
} catch {}
|
|
642
|
+
return {
|
|
643
|
+
url: relicUrl(deps.relicOrigin, relicId, key),
|
|
644
|
+
relic_id: relicId,
|
|
645
|
+
relic_expires_at: String(grant["relic_expires_at"]),
|
|
646
|
+
renderer_class: rendererClass,
|
|
647
|
+
filename,
|
|
648
|
+
resolved_path: source.resolvedPath,
|
|
649
|
+
report_url: String(grant["report_url"]),
|
|
650
|
+
disclosure_url: String(grant["disclosure_url"])
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
throw lastCollision ?? new PublishError("service_unreachable", "could not obtain a grant");
|
|
654
|
+
}
|
|
655
|
+
async function readSource(path, files) {
|
|
656
|
+
const resolvedPath = files.resolve(path);
|
|
657
|
+
const stat = await files.stat(resolvedPath);
|
|
658
|
+
if (stat.kind === "directory") {
|
|
659
|
+
throw new PublishError("source_is_directory", `${resolvedPath} is a directory. Create an archive yourself and ` + "publish that, so the download-only outcome is yours to choose.");
|
|
660
|
+
}
|
|
661
|
+
if (stat.kind === "other") {
|
|
662
|
+
throw new PublishError("source_not_regular_file", `${resolvedPath} is not a regular file`);
|
|
663
|
+
}
|
|
664
|
+
let bytes;
|
|
665
|
+
try {
|
|
666
|
+
bytes = await files.read(resolvedPath);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
throw new PublishError("source_unreadable", `could not read ${resolvedPath}: ${error.message}`);
|
|
669
|
+
}
|
|
670
|
+
return { bytes, basename: files.basename(resolvedPath), resolvedPath };
|
|
671
|
+
}
|
|
672
|
+
async function postJson(deps, url, body) {
|
|
673
|
+
let response;
|
|
674
|
+
try {
|
|
675
|
+
response = await deps.fetch(url, {
|
|
676
|
+
method: "POST",
|
|
677
|
+
headers: { "content-type": "application/json" },
|
|
678
|
+
body: JSON.stringify(body)
|
|
679
|
+
});
|
|
680
|
+
} catch (error) {
|
|
681
|
+
throw new PublishError("service_unreachable", `could not reach ${url}: ${error.message}`);
|
|
682
|
+
}
|
|
683
|
+
const parsed = await response.json().catch(() => ({}));
|
|
684
|
+
if (!response.ok) {
|
|
685
|
+
throw new ServerRefusal(String(parsed["code"] ?? "unknown"), response.status, parsed);
|
|
686
|
+
}
|
|
687
|
+
return parsed;
|
|
688
|
+
}
|
|
689
|
+
var MIMETYPES = {
|
|
690
|
+
md: "text/markdown",
|
|
691
|
+
markdown: "text/markdown",
|
|
692
|
+
html: "text/html",
|
|
693
|
+
htm: "text/html",
|
|
694
|
+
txt: "text/plain",
|
|
695
|
+
json: "application/json",
|
|
696
|
+
csv: "text/csv",
|
|
697
|
+
svg: "image/svg+xml",
|
|
698
|
+
png: "image/png",
|
|
699
|
+
jpg: "image/jpeg",
|
|
700
|
+
jpeg: "image/jpeg",
|
|
701
|
+
gif: "image/gif",
|
|
702
|
+
webp: "image/webp",
|
|
703
|
+
avif: "image/avif",
|
|
704
|
+
mp4: "video/mp4",
|
|
705
|
+
webm: "video/webm",
|
|
706
|
+
mp3: "audio/mpeg",
|
|
707
|
+
wav: "audio/wav",
|
|
708
|
+
ogg: "audio/ogg",
|
|
709
|
+
zip: "application/zip",
|
|
710
|
+
gz: "application/gzip",
|
|
711
|
+
pdf: "application/pdf"
|
|
712
|
+
};
|
|
713
|
+
var CLASS_FALLBACK = {
|
|
714
|
+
markdown: "text/markdown",
|
|
715
|
+
code: "text/plain",
|
|
716
|
+
html: "text/html",
|
|
717
|
+
image: "application/octet-stream",
|
|
718
|
+
media: "application/octet-stream",
|
|
719
|
+
archive: "application/octet-stream",
|
|
720
|
+
binary: "application/octet-stream"
|
|
721
|
+
};
|
|
722
|
+
function guessMimetype(filename, rendererClass) {
|
|
723
|
+
const base = filename.slice(filename.lastIndexOf("/") + 1).toLowerCase();
|
|
724
|
+
const dot = base.lastIndexOf(".");
|
|
725
|
+
if (dot > 0 && dot < base.length - 1) {
|
|
726
|
+
const found = MIMETYPES[base.slice(dot + 1)];
|
|
727
|
+
if (found !== undefined)
|
|
728
|
+
return found;
|
|
729
|
+
}
|
|
730
|
+
return CLASS_FALLBACK[rendererClass];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/server.ts
|
|
734
|
+
var TOOL_NAME = "relic_publish";
|
|
735
|
+
var DESCRIBE_TOOL_NAME = "relic_describe_client";
|
|
736
|
+
var TOOL_DEFINITION = {
|
|
737
|
+
name: TOOL_NAME,
|
|
738
|
+
title: "Publish a relic",
|
|
739
|
+
description: "Encrypt a file on this machine and publish it as a relic, returning a " + "shareable URL. The encryption key is generated locally and never sent " + "to the service. Takes a filesystem path, never inline content.",
|
|
740
|
+
inputSchema: {
|
|
741
|
+
type: "object",
|
|
742
|
+
properties: {
|
|
743
|
+
path: {
|
|
744
|
+
type: "string",
|
|
745
|
+
description: "Filesystem path to the file to publish."
|
|
746
|
+
},
|
|
747
|
+
filename: {
|
|
748
|
+
type: "string",
|
|
749
|
+
description: "Optional. Overrides the name written into the encrypted envelope " + "header. Defaults to the basename of `path`."
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
required: ["path"],
|
|
753
|
+
additionalProperties: false
|
|
754
|
+
},
|
|
755
|
+
outputSchema: {
|
|
756
|
+
type: "object",
|
|
757
|
+
properties: {
|
|
758
|
+
url: { type: "string" },
|
|
759
|
+
relic_id: { type: "string" },
|
|
760
|
+
relic_expires_at: { type: "string" },
|
|
761
|
+
renderer_class: { type: "string" },
|
|
762
|
+
filename: { type: "string" },
|
|
763
|
+
resolved_path: { type: "string" },
|
|
764
|
+
report_url: { type: "string" },
|
|
765
|
+
disclosure_url: { type: "string" }
|
|
766
|
+
},
|
|
767
|
+
required: [
|
|
768
|
+
"url",
|
|
769
|
+
"relic_id",
|
|
770
|
+
"relic_expires_at",
|
|
771
|
+
"renderer_class",
|
|
772
|
+
"filename",
|
|
773
|
+
"resolved_path",
|
|
774
|
+
"report_url",
|
|
775
|
+
"disclosure_url"
|
|
776
|
+
],
|
|
777
|
+
additionalProperties: false
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
var DESCRIBE_TOOL_DEFINITION = {
|
|
781
|
+
name: DESCRIBE_TOOL_NAME,
|
|
782
|
+
title: "Describe the Relic client",
|
|
783
|
+
description: "Return exactly what this client does with your file: the encryption " + "path, what leaves the machine, and what the service can see. Reads " + "nothing and sends nothing.",
|
|
784
|
+
inputSchema: {
|
|
785
|
+
type: "object",
|
|
786
|
+
properties: {},
|
|
787
|
+
additionalProperties: false
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
var SERVER_INFO = {
|
|
791
|
+
name: "relic",
|
|
792
|
+
title: "Relic",
|
|
793
|
+
version: "0.1.0"
|
|
794
|
+
};
|
|
795
|
+
var CAPABILITIES = { tools: {} };
|
|
796
|
+
async function handleMessage(message, deps) {
|
|
797
|
+
if (message.id === undefined)
|
|
798
|
+
return;
|
|
799
|
+
const id2 = message.id ?? null;
|
|
800
|
+
const isProbe = message.method === "server/discover" || message.method === "initialize";
|
|
801
|
+
const requested = requestedProtocolVersion(message);
|
|
802
|
+
if (!isProbe && requested !== undefined && !isSupportedVersion(requested)) {
|
|
803
|
+
return unsupportedVersionError(id2, requested);
|
|
804
|
+
}
|
|
805
|
+
switch (message.method) {
|
|
806
|
+
case "server/discover":
|
|
807
|
+
return {
|
|
808
|
+
jsonrpc: "2.0",
|
|
809
|
+
id: id2,
|
|
810
|
+
result: {
|
|
811
|
+
protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
|
|
812
|
+
capabilities: CAPABILITIES,
|
|
813
|
+
serverInfo: SERVER_INFO
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
case "initialize": {
|
|
817
|
+
const asked = message.params?.["protocolVersion"] ?? PROTOCOL_VERSION;
|
|
818
|
+
return {
|
|
819
|
+
jsonrpc: "2.0",
|
|
820
|
+
id: id2,
|
|
821
|
+
result: {
|
|
822
|
+
protocolVersion: isSupportedVersion(asked) ? asked : PROTOCOL_VERSION,
|
|
823
|
+
capabilities: CAPABILITIES,
|
|
824
|
+
serverInfo: SERVER_INFO
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
case "ping":
|
|
829
|
+
return { jsonrpc: "2.0", id: id2, result: {} };
|
|
830
|
+
case "tools/list":
|
|
831
|
+
return {
|
|
832
|
+
jsonrpc: "2.0",
|
|
833
|
+
id: id2,
|
|
834
|
+
result: { tools: [TOOL_DEFINITION, DESCRIBE_TOOL_DEFINITION] }
|
|
835
|
+
};
|
|
836
|
+
case "tools/call":
|
|
837
|
+
return callTool(id2, message.params ?? {}, deps);
|
|
838
|
+
default:
|
|
839
|
+
return errorResponse(id2, ERROR_CODES.methodNotFound, `unknown method: ${message.method}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async function callTool(id2, params, deps) {
|
|
843
|
+
if (params["name"] === DESCRIBE_TOOL_NAME) {
|
|
844
|
+
return {
|
|
845
|
+
jsonrpc: "2.0",
|
|
846
|
+
id: id2,
|
|
847
|
+
result: {
|
|
848
|
+
content: [{ type: "text", text: describeClient(deps) }],
|
|
849
|
+
structuredContent: {
|
|
850
|
+
encryption: "AES-128-GCM, RFC 8188 aes128gcm framing",
|
|
851
|
+
key_origin: "crypto.getRandomValues on this machine",
|
|
852
|
+
key_transmitted_to_service: false,
|
|
853
|
+
plaintext_transmitted_to_service: false,
|
|
854
|
+
ciphertext_destination: "object storage, via a signed URL",
|
|
855
|
+
service_origin: deps.serviceOrigin
|
|
856
|
+
},
|
|
857
|
+
isError: false
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (params["name"] !== TOOL_NAME) {
|
|
862
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, `unknown tool: ${String(params["name"])}`);
|
|
863
|
+
}
|
|
864
|
+
const args = params["arguments"] ?? {};
|
|
865
|
+
const path = args["path"];
|
|
866
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
867
|
+
return errorResponse(id2, ERROR_CODES.invalidParams, "`path` is required and must be a string");
|
|
868
|
+
}
|
|
869
|
+
const filename = typeof args["filename"] === "string" ? args["filename"] : undefined;
|
|
870
|
+
try {
|
|
871
|
+
const result = await publish({ path, filename }, deps);
|
|
872
|
+
return {
|
|
873
|
+
jsonrpc: "2.0",
|
|
874
|
+
id: id2,
|
|
875
|
+
result: {
|
|
876
|
+
content: [
|
|
877
|
+
{
|
|
878
|
+
type: "text",
|
|
879
|
+
text: `Published ${result.filename} as a relic.
|
|
880
|
+
|
|
881
|
+
${result.url}
|
|
882
|
+
|
|
883
|
+
` + `Expires ${result.relic_expires_at}. Anyone with this link, ` + "including its fragment, can read the file. The key is in the " + `fragment and it is now in this transcript.
|
|
884
|
+
` + `What Relic knows: ${result.disclosure_url}`
|
|
885
|
+
}
|
|
886
|
+
],
|
|
887
|
+
structuredContent: result,
|
|
888
|
+
isError: false
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
} catch (error) {
|
|
892
|
+
return { jsonrpc: "2.0", id: id2, result: toolError(error) };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function toolError(error) {
|
|
896
|
+
if (error instanceof PublishError) {
|
|
897
|
+
return {
|
|
898
|
+
content: [{ type: "text", text: `${error.code}: ${error.message}` }],
|
|
899
|
+
structuredContent: { code: error.code, ...error.details },
|
|
900
|
+
isError: true
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
if (error instanceof ServerRefusal) {
|
|
904
|
+
return {
|
|
905
|
+
content: [{ type: "text", text: `${error.code}: ${error.message}` }],
|
|
906
|
+
structuredContent: { code: error.code, ...error.problem },
|
|
907
|
+
isError: true
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
content: [{ type: "text", text: `publish failed: ${String(error)}` }],
|
|
912
|
+
structuredContent: { code: "unknown" },
|
|
913
|
+
isError: true
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
function describeClient(deps) {
|
|
917
|
+
return `Relic publishing client, running locally on this machine.
|
|
918
|
+
|
|
919
|
+
What happens when you publish a file:
|
|
920
|
+
|
|
921
|
+
1. The file is read from disk by this process. It is never sent anywhere in
|
|
922
|
+
plaintext.
|
|
923
|
+
2. A 128-bit key and a 26-character relic id are drawn independently from this
|
|
924
|
+
machine's CSPRNG (crypto.getRandomValues). Neither derives from the other.
|
|
925
|
+
3. The file is encrypted here, in this process, with AES-128-GCM under RFC 8188
|
|
926
|
+
aes128gcm framing: an HKDF-derived content key, counter-derived per-record
|
|
927
|
+
nonces, and a per-record authentication tag.
|
|
928
|
+
4. Only ciphertext is uploaded, straight to object storage under a signed URL.
|
|
929
|
+
It does not pass through ${deps.serviceOrigin}.
|
|
930
|
+
5. The service is told three things and nothing more: a coarse renderer class
|
|
931
|
+
from a seven-value list, the name of this client, and the exact byte length
|
|
932
|
+
of the ciphertext. Not your filename, not the mimetype, not the contents.
|
|
933
|
+
6. You get back a URL whose fragment carries the key. Fragments are never sent
|
|
934
|
+
to a server by a browser.
|
|
935
|
+
|
|
936
|
+
What the service operator can see: that a relic exists, roughly how big it is,
|
|
937
|
+
what coarse class it was declared as, the publishing IP, and when it was
|
|
938
|
+
fetched. Never the contents, and never the key.
|
|
939
|
+
|
|
940
|
+
What this does NOT protect against: the key is returned to your agent in the
|
|
941
|
+
URL, so it enters the model's context and your session transcript. That is
|
|
942
|
+
structural, not a defect. Anyone who can read this conversation can open the
|
|
943
|
+
relic.
|
|
944
|
+
|
|
945
|
+
The code doing all of this is on disk in this package and can be read. Nothing
|
|
946
|
+
is fetched from the network and executed.`;
|
|
947
|
+
}
|
|
948
|
+
async function serveStdio(deps, input, write) {
|
|
949
|
+
const decoder2 = new TextDecoder;
|
|
950
|
+
const reader = input.getReader();
|
|
951
|
+
let buffer = "";
|
|
952
|
+
for (;; ) {
|
|
953
|
+
const { done, value } = await reader.read();
|
|
954
|
+
if (done)
|
|
955
|
+
break;
|
|
956
|
+
buffer += decoder2.decode(value, { stream: true });
|
|
957
|
+
let newline = buffer.indexOf(`
|
|
958
|
+
`);
|
|
959
|
+
while (newline >= 0) {
|
|
960
|
+
const line = buffer.slice(0, newline).trim();
|
|
961
|
+
buffer = buffer.slice(newline + 1);
|
|
962
|
+
newline = buffer.indexOf(`
|
|
963
|
+
`);
|
|
964
|
+
if (line.length === 0)
|
|
965
|
+
continue;
|
|
966
|
+
let message;
|
|
967
|
+
try {
|
|
968
|
+
message = JSON.parse(line);
|
|
969
|
+
} catch {
|
|
970
|
+
write(JSON.stringify(errorResponse(null, ERROR_CODES.parseError, "parse error")));
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const response = await handleMessage(message, deps);
|
|
974
|
+
if (response !== undefined)
|
|
975
|
+
write(JSON.stringify(response));
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/http.ts
|
|
981
|
+
function jsonResponse(body, status = 200) {
|
|
982
|
+
return new Response(JSON.stringify(body), {
|
|
983
|
+
status,
|
|
984
|
+
headers: { "content-type": "application/json" }
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
function createHttpHandler(deps, options = {}) {
|
|
988
|
+
const allowed = new Set(options.allowedOrigins ?? []);
|
|
989
|
+
return async (request) => {
|
|
990
|
+
const origin = request.headers.get("origin");
|
|
991
|
+
if (origin !== null && !allowed.has(origin)) {
|
|
992
|
+
return jsonResponse(errorResponse(null, ERROR_CODES.headerMismatch, "origin not allowed"), 403);
|
|
993
|
+
}
|
|
994
|
+
if (request.method !== "POST") {
|
|
995
|
+
return new Response("Method not allowed", {
|
|
996
|
+
status: 405,
|
|
997
|
+
headers: { allow: "POST" }
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
let message;
|
|
1001
|
+
try {
|
|
1002
|
+
message = await request.json();
|
|
1003
|
+
} catch {
|
|
1004
|
+
return jsonResponse(errorResponse(null, ERROR_CODES.parseError, "parse error"), 400);
|
|
1005
|
+
}
|
|
1006
|
+
const id2 = message.id ?? null;
|
|
1007
|
+
const headerVersion = request.headers.get("mcp-protocol-version");
|
|
1008
|
+
const bodyVersion = requestedProtocolVersion(message);
|
|
1009
|
+
if (headerVersion === null) {
|
|
1010
|
+
return jsonResponse(errorResponse(id2, ERROR_CODES.headerMismatch, "MCP-Protocol-Version header is required"), 400);
|
|
1011
|
+
}
|
|
1012
|
+
if (bodyVersion !== undefined && headerVersion !== bodyVersion) {
|
|
1013
|
+
return jsonResponse(errorResponse(id2, ERROR_CODES.headerMismatch, `Header mismatch: MCP-Protocol-Version header value ` + `'${headerVersion}' does not match body value '${bodyVersion}'`), 400);
|
|
1014
|
+
}
|
|
1015
|
+
if (!isSupportedVersion(headerVersion)) {
|
|
1016
|
+
return jsonResponse(unsupportedVersionError(id2, headerVersion), 400);
|
|
1017
|
+
}
|
|
1018
|
+
const headerMethod = request.headers.get("mcp-method");
|
|
1019
|
+
if (headerMethod === null) {
|
|
1020
|
+
return jsonResponse(errorResponse(id2, ERROR_CODES.headerMismatch, "Mcp-Method header is required"), 400);
|
|
1021
|
+
}
|
|
1022
|
+
if (headerMethod !== message.method) {
|
|
1023
|
+
return jsonResponse(errorResponse(id2, ERROR_CODES.headerMismatch, `Header mismatch: Mcp-Method header value '${headerMethod}' does ` + `not match body value '${message.method}'`), 400);
|
|
1024
|
+
}
|
|
1025
|
+
const wantsName = expectedMcpName(message);
|
|
1026
|
+
if (wantsName !== undefined) {
|
|
1027
|
+
const rawName = request.headers.get("mcp-name");
|
|
1028
|
+
if (rawName === null) {
|
|
1029
|
+
return jsonResponse(errorResponse(id2, ERROR_CODES.headerMismatch, "Mcp-Name header is required for this method"), 400);
|
|
1030
|
+
}
|
|
1031
|
+
if (decodeHeaderValue(rawName) !== wantsName) {
|
|
1032
|
+
return jsonResponse(errorResponse(id2, ERROR_CODES.headerMismatch, "Header mismatch: Mcp-Name header value does not match body value"), 400);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const response = await handleMessage(message, deps);
|
|
1036
|
+
if (response === undefined)
|
|
1037
|
+
return new Response(null, { status: 202 });
|
|
1038
|
+
if (response.error?.code === ERROR_CODES.methodNotFound) {
|
|
1039
|
+
return jsonResponse(response, 404);
|
|
1040
|
+
}
|
|
1041
|
+
return jsonResponse(response);
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// src/index.ts
|
|
1046
|
+
var deps = {
|
|
1047
|
+
serviceOrigin: process.env["RELIC_SERVICE_ORIGIN"] ?? "https://relic.example",
|
|
1048
|
+
relicOrigin: process.env["RELIC_ORIGIN"] ?? process.env["RELIC_SERVICE_ORIGIN"] ?? "https://relic.example",
|
|
1049
|
+
files: nodeFiles,
|
|
1050
|
+
fetch: globalThis.fetch,
|
|
1051
|
+
clientName: process.env["RELIC_CLIENT_NAME"] ?? "relic-mcp/0.1.0"
|
|
1052
|
+
};
|
|
1053
|
+
if (process.env["RELIC_MCP_HTTP"] === "1") {
|
|
1054
|
+
const port = Number(process.env["RELIC_MCP_PORT"] ?? 7333);
|
|
1055
|
+
const hostname = process.env["RELIC_MCP_HOST"] ?? "127.0.0.1";
|
|
1056
|
+
const allowedOrigins = (process.env["RELIC_MCP_ALLOWED_ORIGINS"] ?? "").split(",").map((value) => value.trim()).filter((value) => value.length > 0);
|
|
1057
|
+
const handler = createHttpHandler(deps, { allowedOrigins });
|
|
1058
|
+
createServer((incoming, outgoing) => {
|
|
1059
|
+
const chunks = [];
|
|
1060
|
+
incoming.on("data", (chunk) => chunks.push(chunk));
|
|
1061
|
+
incoming.on("end", () => {
|
|
1062
|
+
(async () => {
|
|
1063
|
+
const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? `${hostname}:${port}`}`);
|
|
1064
|
+
if (url.pathname !== "/mcp") {
|
|
1065
|
+
outgoing.writeHead(404).end("Not found");
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const method = incoming.method ?? "GET";
|
|
1069
|
+
const request = new Request(url, {
|
|
1070
|
+
method,
|
|
1071
|
+
headers: incoming.headers,
|
|
1072
|
+
...method === "GET" || method === "HEAD" ? {} : { body: Buffer.concat(chunks) }
|
|
1073
|
+
});
|
|
1074
|
+
const response = await handler(request);
|
|
1075
|
+
outgoing.writeHead(response.status, Object.fromEntries(response.headers.entries()));
|
|
1076
|
+
const body = await response.arrayBuffer();
|
|
1077
|
+
outgoing.end(Buffer.from(body));
|
|
1078
|
+
})();
|
|
1079
|
+
});
|
|
1080
|
+
}).listen(port, hostname, () => {
|
|
1081
|
+
console.error(`relic-mcp listening on http://${hostname}:${port}/mcp`);
|
|
1082
|
+
});
|
|
1083
|
+
} else {
|
|
1084
|
+
await serveStdio(deps, Readable.toWeb(process.stdin), (line) => {
|
|
1085
|
+
process.stdout.write(`${line}
|
|
1086
|
+
`);
|
|
1087
|
+
});
|
|
1088
|
+
}
|