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/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "relic-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Publish a file as an encrypted relic. The key is generated on your machine and never sent to the service.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/relic-mcp.js",
8
+ "bin": {
9
+ "relic-mcp": "dist/relic-mcp.js"
10
+ },
11
+ "engines": {
12
+ "node": ">=18"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "model-context-protocol",
17
+ "encryption",
18
+ "zero-knowledge",
19
+ "file-sharing"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/TheBushidoCollective/artifacts.git",
24
+ "directory": "packages/relic-mcp"
25
+ },
26
+ "homepage": "https://github.com/TheBushidoCollective/artifacts#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/TheBushidoCollective/artifacts/issues"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "README.md"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "scripts": {
40
+ "typecheck": "tsc --noEmit",
41
+ "build": "bun run build.ts"
42
+ },
43
+ "devDependencies": {
44
+ "@relic/format": "workspace:*",
45
+ "@relic/server": "workspace:*"
46
+ }
47
+ }
package/src/files.ts ADDED
@@ -0,0 +1,30 @@
1
+ /** Filesystem access through Node, kept behind the FileReader interface. */
2
+
3
+ import { lstat, readFile } from 'node:fs/promises';
4
+ import { basename as pathBasename, resolve as pathResolve } from 'node:path';
5
+ import type { FileReader } from './publish.ts';
6
+
7
+ export const nodeFiles: FileReader = {
8
+ resolve(path) {
9
+ // A relative path resolves against the server process's working
10
+ // directory, and the result is echoed so a publish that picked up the
11
+ // wrong file is diagnosable from the result rather than a support thread.
12
+ return pathResolve(process.cwd(), path);
13
+ },
14
+
15
+ basename(path) {
16
+ return pathBasename(path);
17
+ },
18
+
19
+ async stat(path) {
20
+ const info = await lstat(path).catch(() => undefined);
21
+ if (info === undefined) return { kind: 'other' };
22
+ if (info.isDirectory()) return { kind: 'directory' };
23
+ if (!info.isFile()) return { kind: 'other' };
24
+ return { kind: 'file', size: info.size };
25
+ },
26
+
27
+ async read(path) {
28
+ return new Uint8Array(await readFile(path));
29
+ },
30
+ };
package/src/http.ts ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Streamable HTTP transport, revision `2026-07-28`.
3
+ *
4
+ * Stateless by construction: a single POST endpoint, one HTTP request per
5
+ * JSON-RPC message, no session, and no `Mcp-Session-Id`. Nothing is retained
6
+ * between calls, so a load balancer can round-robin across processes with no
7
+ * sticky routing and no shared session store.
8
+ *
9
+ * **This does not make Relic hostable.** The transport says how bytes reach
10
+ * the server, not where the server runs. The publishing client must sit next
11
+ * to the plaintext, because encrypting it anywhere else is the thing the
12
+ * product exists to avoid. HTTP is here so one server can be shared between
13
+ * local agents, run under a supervisor, or be probed by tooling that speaks
14
+ * only HTTP. The default bind is loopback, and that is deliberate.
15
+ */
16
+
17
+ import {
18
+ decodeHeaderValue,
19
+ ERROR_CODES,
20
+ errorResponse,
21
+ expectedMcpName,
22
+ isSupportedVersion,
23
+ type JsonRpcRequest,
24
+ requestedProtocolVersion,
25
+ unsupportedVersionError,
26
+ } from './protocol.ts';
27
+ import type { PublishDeps } from './publish.ts';
28
+ import { handleMessage } from './server.ts';
29
+
30
+ export interface HttpOptions {
31
+ /**
32
+ * Origins allowed to call the endpoint.
33
+ *
34
+ * The transport requires `Origin` validation to defeat DNS rebinding: a page
35
+ * on any website can otherwise reach a server bound to loopback. An empty
36
+ * list refuses every request that carries an `Origin` at all, which is the
37
+ * right default for a local server driven by a non-browser client.
38
+ */
39
+ readonly allowedOrigins?: readonly string[];
40
+ }
41
+
42
+ function jsonResponse(body: unknown, status = 200): Response {
43
+ return new Response(JSON.stringify(body), {
44
+ status,
45
+ headers: { 'content-type': 'application/json' },
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Build the fetch handler for the MCP endpoint.
51
+ *
52
+ * Every check below is a MUST in the transport spec, and each is a place a
53
+ * lenient implementation becomes a security problem rather than a
54
+ * compatibility one.
55
+ */
56
+ export function createHttpHandler(
57
+ deps: PublishDeps,
58
+ options: HttpOptions = {}
59
+ ): (request: Request) => Promise<Response> {
60
+ const allowed = new Set(options.allowedOrigins ?? []);
61
+
62
+ return async (request: Request): Promise<Response> => {
63
+ // DNS rebinding defence. A browser page cannot suppress `Origin`, so a
64
+ // present-and-unlisted origin is refused outright.
65
+ const origin = request.headers.get('origin');
66
+ if (origin !== null && !allowed.has(origin)) {
67
+ return jsonResponse(
68
+ errorResponse(null, ERROR_CODES.headerMismatch, 'origin not allowed'),
69
+ 403
70
+ );
71
+ }
72
+
73
+ // Sessions and standalone SSE streams are gone in this revision. An older
74
+ // client gets a clear refusal rather than a confusing 404.
75
+ if (request.method !== 'POST') {
76
+ return new Response('Method not allowed', {
77
+ status: 405,
78
+ headers: { allow: 'POST' },
79
+ });
80
+ }
81
+
82
+ let message: JsonRpcRequest;
83
+ try {
84
+ message = (await request.json()) as JsonRpcRequest;
85
+ } catch {
86
+ return jsonResponse(
87
+ errorResponse(null, ERROR_CODES.parseError, 'parse error'),
88
+ 400
89
+ );
90
+ }
91
+
92
+ const id = message.id ?? null;
93
+
94
+ // The version header and the `_meta` field must agree. They exist so an
95
+ // intermediary can route without parsing the body, and a mismatch is
96
+ // exactly the split-brain where a gateway and a server act on different
97
+ // values.
98
+ const headerVersion = request.headers.get('mcp-protocol-version');
99
+ const bodyVersion = requestedProtocolVersion(message);
100
+
101
+ if (headerVersion === null) {
102
+ return jsonResponse(
103
+ errorResponse(
104
+ id,
105
+ ERROR_CODES.headerMismatch,
106
+ 'MCP-Protocol-Version header is required'
107
+ ),
108
+ 400
109
+ );
110
+ }
111
+ if (bodyVersion !== undefined && headerVersion !== bodyVersion) {
112
+ return jsonResponse(
113
+ errorResponse(
114
+ id,
115
+ ERROR_CODES.headerMismatch,
116
+ `Header mismatch: MCP-Protocol-Version header value ` +
117
+ `'${headerVersion}' does not match body value '${bodyVersion}'`
118
+ ),
119
+ 400
120
+ );
121
+ }
122
+ if (!isSupportedVersion(headerVersion)) {
123
+ return jsonResponse(unsupportedVersionError(id, headerVersion), 400);
124
+ }
125
+
126
+ // `Mcp-Method` mirrors `method`, and `Mcp-Name` mirrors `params.name` or
127
+ // `params.uri`, so gateways can route and meter on headers alone.
128
+ const headerMethod = request.headers.get('mcp-method');
129
+ if (headerMethod === null) {
130
+ return jsonResponse(
131
+ errorResponse(
132
+ id,
133
+ ERROR_CODES.headerMismatch,
134
+ 'Mcp-Method header is required'
135
+ ),
136
+ 400
137
+ );
138
+ }
139
+ if (headerMethod !== message.method) {
140
+ return jsonResponse(
141
+ errorResponse(
142
+ id,
143
+ ERROR_CODES.headerMismatch,
144
+ `Header mismatch: Mcp-Method header value '${headerMethod}' does ` +
145
+ `not match body value '${message.method}'`
146
+ ),
147
+ 400
148
+ );
149
+ }
150
+
151
+ const wantsName = expectedMcpName(message);
152
+ if (wantsName !== undefined) {
153
+ const rawName = request.headers.get('mcp-name');
154
+ if (rawName === null) {
155
+ return jsonResponse(
156
+ errorResponse(
157
+ id,
158
+ ERROR_CODES.headerMismatch,
159
+ 'Mcp-Name header is required for this method'
160
+ ),
161
+ 400
162
+ );
163
+ }
164
+ // Decoded before comparison, or a non-ASCII name fails against its own
165
+ // request body.
166
+ if (decodeHeaderValue(rawName) !== wantsName) {
167
+ return jsonResponse(
168
+ errorResponse(
169
+ id,
170
+ ERROR_CODES.headerMismatch,
171
+ 'Header mismatch: Mcp-Name header value does not match body value'
172
+ ),
173
+ 400
174
+ );
175
+ }
176
+ }
177
+
178
+ const response = await handleMessage(message, deps);
179
+
180
+ // A notification is accepted with no body.
181
+ if (response === undefined) return new Response(null, { status: 202 });
182
+
183
+ // An unknown method is a 404 carrying a JSON-RPC error, which is what
184
+ // distinguishes a modern server from a legacy one that simply does not
185
+ // host this path.
186
+ if (response.error?.code === ERROR_CODES.methodNotFound) {
187
+ return jsonResponse(response, 404);
188
+ }
189
+
190
+ return jsonResponse(response);
191
+ };
192
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point for the local MCP server.
4
+ *
5
+ * Plain Node, no Bun APIs, because this ships to npm and runs under whatever
6
+ * runtime `npx` happens to have. Everything it needs (`fetch`, `crypto.subtle`,
7
+ * `TextEncoder`, web streams) is standard from Node 18 on.
8
+ *
9
+ * stdio by default, which is what every agent runner expects. Set
10
+ * `RELIC_MCP_HTTP=1` to serve the Streamable HTTP transport instead.
11
+ *
12
+ * Nothing is written to stdout except JSON-RPC. Diagnostics go to stderr,
13
+ * because a stray stdout write corrupts the protocol stream.
14
+ */
15
+
16
+ import { createServer } from 'node:http';
17
+ import { Readable } from 'node:stream';
18
+ import { nodeFiles } from './files.ts';
19
+ import { createHttpHandler } from './http.ts';
20
+ import type { PublishDeps } from './publish.ts';
21
+ import { serveStdio } from './server.ts';
22
+
23
+ const deps: PublishDeps = {
24
+ serviceOrigin: process.env['RELIC_SERVICE_ORIGIN'] ?? 'https://relic.example',
25
+ relicOrigin:
26
+ process.env['RELIC_ORIGIN'] ??
27
+ process.env['RELIC_SERVICE_ORIGIN'] ??
28
+ 'https://relic.example',
29
+ files: nodeFiles,
30
+ fetch: globalThis.fetch,
31
+ clientName: process.env['RELIC_CLIENT_NAME'] ?? 'relic-mcp/0.1.0',
32
+ };
33
+
34
+ if (process.env['RELIC_MCP_HTTP'] === '1') {
35
+ const port = Number(process.env['RELIC_MCP_PORT'] ?? 7333);
36
+ // Loopback, not 0.0.0.0. This process can read any file its user can, so
37
+ // binding it to a network interface hands that reach to the network.
38
+ const hostname = process.env['RELIC_MCP_HOST'] ?? '127.0.0.1';
39
+
40
+ const allowedOrigins = (process.env['RELIC_MCP_ALLOWED_ORIGINS'] ?? '')
41
+ .split(',')
42
+ .map((value) => value.trim())
43
+ .filter((value) => value.length > 0);
44
+
45
+ const handler = createHttpHandler(deps, { allowedOrigins });
46
+
47
+ createServer((incoming, outgoing) => {
48
+ const chunks: Buffer[] = [];
49
+ incoming.on('data', (chunk: Buffer) => chunks.push(chunk));
50
+ incoming.on('end', () => {
51
+ void (async () => {
52
+ const url = new URL(
53
+ incoming.url ?? '/',
54
+ `http://${incoming.headers.host ?? `${hostname}:${port}`}`
55
+ );
56
+
57
+ if (url.pathname !== '/mcp') {
58
+ outgoing.writeHead(404).end('Not found');
59
+ return;
60
+ }
61
+
62
+ const method = incoming.method ?? 'GET';
63
+ const request = new Request(url, {
64
+ method,
65
+ headers: incoming.headers as Record<string, string>,
66
+ ...(method === 'GET' || method === 'HEAD'
67
+ ? {}
68
+ : { body: Buffer.concat(chunks) }),
69
+ });
70
+
71
+ const response = await handler(request);
72
+ outgoing.writeHead(
73
+ response.status,
74
+ Object.fromEntries(response.headers.entries())
75
+ );
76
+ const body = await response.arrayBuffer();
77
+ outgoing.end(Buffer.from(body));
78
+ })();
79
+ });
80
+ }).listen(port, hostname, () => {
81
+ console.error(`relic-mcp listening on http://${hostname}:${port}/mcp`);
82
+ });
83
+ } else {
84
+ // Node's stdin is a classic Readable; the transport reads a web stream.
85
+ await serveStdio(
86
+ deps,
87
+ Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>,
88
+ (line) => {
89
+ process.stdout.write(`${line}\n`);
90
+ }
91
+ );
92
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * MCP protocol plumbing, shared by both transports.
3
+ *
4
+ * Revision `2026-07-28` is the pin, and it is the revision that made the
5
+ * protocol core stateless: there is no `initialize` handshake, no session, and
6
+ * no `Mcp-Session-Id`. Every request declares its own version in `_meta` and
7
+ * the server accepts or rejects each one independently.
8
+ *
9
+ * That suits Relic exactly. The server holds nothing between calls, so it can
10
+ * be restarted, load-balanced, or run one-shot without a client noticing.
11
+ *
12
+ * The legacy `initialize` handshake is still answered, which the spec calls a
13
+ * dual-era server. A publishing client that only speaks the newest revision is
14
+ * unusable in most of the agents this product exists to serve.
15
+ */
16
+
17
+ /** The revision this server prefers. */
18
+ export const PROTOCOL_VERSION = '2026-07-28';
19
+
20
+ /**
21
+ * Handshake-based revisions this server still answers.
22
+ *
23
+ * Legacy clients have no fall-forward mechanism, so dropping these would make
24
+ * Relic unreachable from them with no diagnostic they could act on.
25
+ */
26
+ export const LEGACY_PROTOCOL_VERSIONS = [
27
+ '2025-11-25',
28
+ '2025-06-18',
29
+ '2025-03-26',
30
+ ] as const;
31
+
32
+ export const SUPPORTED_PROTOCOL_VERSIONS = [
33
+ PROTOCOL_VERSION,
34
+ ...LEGACY_PROTOCOL_VERSIONS,
35
+ ] as const;
36
+
37
+ /** The `_meta` key carrying the per-request protocol version. */
38
+ export const PROTOCOL_VERSION_META_KEY =
39
+ 'io.modelcontextprotocol/protocolVersion';
40
+
41
+ /** Protocol-defined error codes used here. */
42
+ export const ERROR_CODES = {
43
+ /** Headers disagree with the body, or a required header is missing. */
44
+ headerMismatch: -32020,
45
+ /** The requested revision is one this server does not implement. */
46
+ unsupportedProtocolVersion: -32022,
47
+ methodNotFound: -32601,
48
+ invalidParams: -32602,
49
+ parseError: -32700,
50
+ } as const;
51
+
52
+ export interface JsonRpcRequest {
53
+ jsonrpc: '2.0';
54
+ id?: string | number | null;
55
+ method: string;
56
+ params?: Record<string, unknown>;
57
+ }
58
+
59
+ export interface JsonRpcResponse {
60
+ jsonrpc: '2.0';
61
+ id: string | number | null;
62
+ result?: unknown;
63
+ error?: { code: number; message: string; data?: unknown };
64
+ }
65
+
66
+ export function errorResponse(
67
+ id: string | number | null,
68
+ code: number,
69
+ message: string,
70
+ data?: unknown
71
+ ): JsonRpcResponse {
72
+ return {
73
+ jsonrpc: '2.0',
74
+ id,
75
+ error: data === undefined ? { code, message } : { code, message, data },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * The version a request declares, or undefined when it declares none.
81
+ *
82
+ * A missing version is not an error here: a legacy client sending
83
+ * `initialize` has no `_meta` to put one in, and that request is answered
84
+ * under legacy semantics.
85
+ */
86
+ export function requestedProtocolVersion(
87
+ message: JsonRpcRequest
88
+ ): string | undefined {
89
+ const meta = message.params?.['_meta'];
90
+ if (typeof meta !== 'object' || meta === null) return undefined;
91
+ const version = (meta as Record<string, unknown>)[PROTOCOL_VERSION_META_KEY];
92
+ return typeof version === 'string' ? version : undefined;
93
+ }
94
+
95
+ export function isSupportedVersion(version: string): boolean {
96
+ return (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(version);
97
+ }
98
+
99
+ /**
100
+ * `UnsupportedProtocolVersionError`, carrying what the server does support so
101
+ * the client can retry rather than guess.
102
+ */
103
+ export function unsupportedVersionError(
104
+ id: string | number | null,
105
+ requested: string
106
+ ): JsonRpcResponse {
107
+ return errorResponse(
108
+ id,
109
+ ERROR_CODES.unsupportedProtocolVersion,
110
+ 'Unsupported protocol version',
111
+ { supported: [...SUPPORTED_PROTOCOL_VERSIONS], requested }
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Decode the Base64 sentinel form the transport uses for header values that
117
+ * cannot be represented as plain ASCII.
118
+ *
119
+ * `=?base64?{value}?=`, markers lowercase and case-sensitive. Servers MUST
120
+ * decode before comparing a header to the body, or a tool with a non-ASCII
121
+ * name would fail validation against its own request.
122
+ */
123
+ export function decodeHeaderValue(raw: string): string {
124
+ if (!raw.startsWith('=?base64?') || !raw.endsWith('?=')) return raw;
125
+ const encoded = raw.slice('=?base64?'.length, -'?='.length);
126
+ try {
127
+ return new TextDecoder().decode(
128
+ Uint8Array.from(atob(encoded), (ch) => ch.charCodeAt(0))
129
+ );
130
+ } catch {
131
+ // An undecodable sentinel is returned as-is so the comparison fails and
132
+ // the request is rejected, rather than throwing here.
133
+ return raw;
134
+ }
135
+ }
136
+
137
+ /** The value `Mcp-Name` must carry for a given message, if any. */
138
+ export function expectedMcpName(message: JsonRpcRequest): string | undefined {
139
+ if (message.method === 'tools/call' || message.method === 'prompts/get') {
140
+ const name = message.params?.['name'];
141
+ return typeof name === 'string' ? name : undefined;
142
+ }
143
+ if (message.method === 'resources/read') {
144
+ const uri = message.params?.['uri'];
145
+ return typeof uri === 'string' ? uri : undefined;
146
+ }
147
+ return undefined;
148
+ }