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
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
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|