@prereason/mcp 0.3.2 → 0.5.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/CHANGELOG.md +36 -0
- package/README.md +64 -48
- package/bin/cli.js +97 -49
- package/lib/claim.js +233 -0
- package/lib/credentials.js +152 -0
- package/lib/jsonrpc.js +60 -0
- package/lib/sse.js +170 -0
- package/lib/stdio.js +149 -0
- package/lib/streamable-http.js +302 -0
- package/package.json +7 -8
package/lib/claim.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The claim flow, from the bridge's side.
|
|
3
|
+
*
|
|
4
|
+
* With no key configured the bridge asks PreReason for access on the person's
|
|
5
|
+
* behalf: it creates a claim, prints one link to stderr, and polls until the
|
|
6
|
+
* person has approved it in a browser. The first poll after approval returns
|
|
7
|
+
* the key once; the bridge saves it and attaches it to the running transport.
|
|
8
|
+
* Meanwhile the free tools keep working, and any AUTH_REQUIRED tool result is
|
|
9
|
+
* prefixed with the approve link so the model can relay it, because a person
|
|
10
|
+
* inside Claude Desktop never sees this process's stderr.
|
|
11
|
+
*
|
|
12
|
+
* Everything that touches the network or the clock is injectable
|
|
13
|
+
* (fetchImpl, sleep, now) so the flow is tested without a server.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { writeCredentials } from './credentials.js';
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
19
|
+
/** Consecutive network failures before the flow gives up for this process. */
|
|
20
|
+
export const MAX_CONSECUTIVE_ERRORS = 10;
|
|
21
|
+
|
|
22
|
+
/** The claims endpoint on the same origin as the MCP endpoint (so PREREASON_URL overrides both). */
|
|
23
|
+
export function claimEndpoint(mcpUrl) {
|
|
24
|
+
return new URL('/api/agent/claims', mcpUrl).toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseRetryAfter(headers, fallbackMs) {
|
|
28
|
+
const raw = headers && typeof headers.get === 'function' ? headers.get('retry-after') : null;
|
|
29
|
+
const seconds = Number(raw);
|
|
30
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : fallbackMs;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readJson(res) {
|
|
34
|
+
try {
|
|
35
|
+
return await res.json();
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* POST a claim. Resolves { ok: true, claim } or { ok: false, status, retryAfterMs, message }.
|
|
43
|
+
*/
|
|
44
|
+
export async function createClaim({ mcpUrl, clientName, purpose, requestHeaders = {}, fetchImpl = fetch }) {
|
|
45
|
+
const res = await fetchImpl(claimEndpoint(mcpUrl), {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', ...requestHeaders },
|
|
48
|
+
body: JSON.stringify({ client_name: clientName, purpose }),
|
|
49
|
+
});
|
|
50
|
+
const body = await readJson(res);
|
|
51
|
+
if (res.status === 201 && body && typeof body.claim_code === 'string' && typeof body.claim_token === 'string') {
|
|
52
|
+
return { ok: true, claim: body };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
status: res.status,
|
|
57
|
+
retryAfterMs: parseRetryAfter(res.headers, 60_000),
|
|
58
|
+
message: (body && body.message) || `claim request answered ${res.status}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Poll one claim until it settles. Outcomes:
|
|
64
|
+
* approved -> { outcome, apiKey, key, account, how_to_use }
|
|
65
|
+
* delivered -> the key was collected by another poll of the same token
|
|
66
|
+
* denied, expired, not_found, gave_up
|
|
67
|
+
*/
|
|
68
|
+
export async function pollUntilSettled({ claim, requestHeaders = {}, fetchImpl = fetch, sleep = defaultSleep, now = () => Date.now(), onPending = () => {} }) {
|
|
69
|
+
let expiresAt = Date.parse(claim.expires_at);
|
|
70
|
+
const intervalMs = Math.max(1000, (claim.poll?.interval_seconds ?? 5) * 1000);
|
|
71
|
+
const pollUrl = claim.poll?.url ?? `${claimEndpoint(claim.approve_url)}/${claim.claim_code}`;
|
|
72
|
+
let consecutiveErrors = 0;
|
|
73
|
+
|
|
74
|
+
while (true) {
|
|
75
|
+
if (Number.isFinite(expiresAt) && now() >= expiresAt) return { outcome: 'expired' };
|
|
76
|
+
|
|
77
|
+
let res;
|
|
78
|
+
try {
|
|
79
|
+
res = await fetchImpl(pollUrl, {
|
|
80
|
+
headers: { accept: 'application/json', authorization: `Bearer ${claim.claim_token}`, ...requestHeaders },
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return { outcome: 'gave_up' };
|
|
84
|
+
await sleep(intervalMs);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
consecutiveErrors = 0;
|
|
88
|
+
|
|
89
|
+
if (res.status === 404) return { outcome: 'not_found' };
|
|
90
|
+
if (res.status === 429) {
|
|
91
|
+
await sleep(parseRetryAfter(res.headers, intervalMs));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (res.status !== 200) {
|
|
95
|
+
if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return { outcome: 'gave_up' };
|
|
96
|
+
await sleep(intervalMs);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const body = await readJson(res);
|
|
101
|
+
switch (body.status) {
|
|
102
|
+
case 'pending': {
|
|
103
|
+
// The approval page can extend the claim; honour the server's clock.
|
|
104
|
+
const fresh = Date.parse(body.expires_at);
|
|
105
|
+
if (Number.isFinite(fresh)) expiresAt = fresh;
|
|
106
|
+
onPending(body);
|
|
107
|
+
await sleep(Math.max(1000, (body.poll_interval ?? intervalMs / 1000) * 1000));
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
case 'approved': {
|
|
111
|
+
if (typeof body.api_key === 'string') {
|
|
112
|
+
return { outcome: 'approved', apiKey: body.api_key, key: body.key, account: body.account, how_to_use: body.how_to_use };
|
|
113
|
+
}
|
|
114
|
+
// KEY_ISSUE_FAILED: the approval stands, the mint is retried on the next poll.
|
|
115
|
+
await sleep(Math.max(1000, (body.retry_after ?? 5) * 1000));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
case 'delivered':
|
|
119
|
+
return { outcome: 'delivered' };
|
|
120
|
+
case 'denied':
|
|
121
|
+
return { outcome: 'denied' };
|
|
122
|
+
case 'expired':
|
|
123
|
+
return { outcome: 'expired' };
|
|
124
|
+
default:
|
|
125
|
+
if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return { outcome: 'gave_up' };
|
|
126
|
+
await sleep(intervalMs);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The one line a person is meant to read. */
|
|
132
|
+
export function approvalLine(claim) {
|
|
133
|
+
return `PreReason: no API key found. Open ${claim.approve_url} to approve access (link expires in 15 min).`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* While a claim is pending, put the approve link in front of any AUTH_REQUIRED
|
|
138
|
+
* tool result so the model relays it. Leaves every other message untouched.
|
|
139
|
+
*/
|
|
140
|
+
export function decorateAuthRequired(message, approveUrl) {
|
|
141
|
+
if (!approveUrl || !message || typeof message !== 'object') return message;
|
|
142
|
+
const result = message.result;
|
|
143
|
+
if (!result || result.isError !== true || !Array.isArray(result.content)) return message;
|
|
144
|
+
const first = result.content[0];
|
|
145
|
+
if (!first || first.type !== 'text' || typeof first.text !== 'string') return message;
|
|
146
|
+
let payload;
|
|
147
|
+
try {
|
|
148
|
+
payload = JSON.parse(first.text);
|
|
149
|
+
} catch {
|
|
150
|
+
return message;
|
|
151
|
+
}
|
|
152
|
+
if (!payload || payload.error !== 'AUTH_REQUIRED') return message;
|
|
153
|
+
const prefix = `Approve at ${approveUrl} (the human who owns this agent must open it and click Approve; the key arrives here on its own afterwards). `;
|
|
154
|
+
return {
|
|
155
|
+
...message,
|
|
156
|
+
result: {
|
|
157
|
+
...result,
|
|
158
|
+
content: [{ ...first, text: prefix + first.text }, ...result.content.slice(1)],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The whole flow: create, announce, poll, save, attach. `state.approveUrl` is
|
|
165
|
+
* set while the claim is pending so the message decorator can read it.
|
|
166
|
+
* Resolves the poll outcome (or a create failure) and never throws.
|
|
167
|
+
*/
|
|
168
|
+
export async function runClaimFlow({
|
|
169
|
+
mcpUrl,
|
|
170
|
+
headers,
|
|
171
|
+
clientName,
|
|
172
|
+
purpose,
|
|
173
|
+
credentialsFile,
|
|
174
|
+
requestHeaders = {},
|
|
175
|
+
fetchImpl = fetch,
|
|
176
|
+
sleep = defaultSleep,
|
|
177
|
+
now = () => Date.now(),
|
|
178
|
+
log = (line) => process.stderr.write(`${line}\n`),
|
|
179
|
+
state = {},
|
|
180
|
+
}) {
|
|
181
|
+
let created;
|
|
182
|
+
try {
|
|
183
|
+
created = await createClaim({ mcpUrl, clientName, purpose, requestHeaders, fetchImpl });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
log(`PreReason: could not reach ${claimEndpoint(mcpUrl)} to request access (${error?.message ?? 'network error'}). Free tools still work; set PREREASON_API_KEY to skip this step.`);
|
|
186
|
+
return { outcome: 'create_failed' };
|
|
187
|
+
}
|
|
188
|
+
if (!created.ok) {
|
|
189
|
+
const wait = Math.ceil(created.retryAfterMs / 60_000);
|
|
190
|
+
log(`PreReason: access request refused (${created.status}: ${created.message}). Try again in about ${wait} minute${wait === 1 ? '' : 's'}, or set PREREASON_API_KEY. Free tools still work.`);
|
|
191
|
+
return { outcome: 'create_refused', status: created.status };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const claim = created.claim;
|
|
195
|
+
state.approveUrl = claim.approve_url;
|
|
196
|
+
state.claimCode = claim.claim_code;
|
|
197
|
+
log(approvalLine(claim));
|
|
198
|
+
|
|
199
|
+
const settled = await pollUntilSettled({ claim, requestHeaders, fetchImpl, sleep, now });
|
|
200
|
+
state.approveUrl = null;
|
|
201
|
+
|
|
202
|
+
if (settled.outcome === 'approved') {
|
|
203
|
+
try {
|
|
204
|
+
writeCredentials(credentialsFile, {
|
|
205
|
+
apiKey: settled.apiKey,
|
|
206
|
+
claimCode: claim.claim_code,
|
|
207
|
+
clientName,
|
|
208
|
+
keyName: settled.key?.name ?? null,
|
|
209
|
+
now: new Date(now()),
|
|
210
|
+
});
|
|
211
|
+
headers.Authorization = `Bearer ${settled.apiKey}`;
|
|
212
|
+
log(`PreReason: access approved. Key "${settled.key?.name ?? 'Agent key'}" saved to ${credentialsFile}; get_context and get_metric work from the next call.`);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
headers.Authorization = `Bearer ${settled.apiKey}`;
|
|
215
|
+
log(`PreReason: access approved and attached for this session, but the key could not be saved to ${credentialsFile} (${error?.message ?? 'write failed'}). Set PREREASON_API_KEY to keep it.`);
|
|
216
|
+
}
|
|
217
|
+
return settled;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const why = {
|
|
221
|
+
expired: 'the link expired before it was approved',
|
|
222
|
+
denied: 'the request was denied',
|
|
223
|
+
delivered: 'the key was collected elsewhere',
|
|
224
|
+
not_found: 'the claim is no longer known to the server',
|
|
225
|
+
gave_up: 'the server could not be reached',
|
|
226
|
+
}[settled.outcome] ?? settled.outcome;
|
|
227
|
+
log(`PreReason: access was not granted (${why}). Restart the bridge to ask again, or set PREREASON_API_KEY. Free tools still work.`);
|
|
228
|
+
return settled;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function defaultSleep(ms) {
|
|
232
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
233
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the bridge's API key comes from, and where a claimed key is kept.
|
|
3
|
+
*
|
|
4
|
+
* Precedence, highest first:
|
|
5
|
+
* 1. PREREASON_API_KEY in the environment (the documented config path)
|
|
6
|
+
* 2. an Authorization or X-API-Key value passed with --header
|
|
7
|
+
* 3. the credentials file, written by the claim flow (~/.prereason/credentials.json,
|
|
8
|
+
* or PREREASON_CREDENTIALS_FILE, or --credentials-file)
|
|
9
|
+
*
|
|
10
|
+
* The file holds the key and a little provenance (which claim, which client
|
|
11
|
+
* name, when). It never holds a claim token: a token is a fifteen minute
|
|
12
|
+
* secret for one poll loop and dies with the process. On POSIX the directory
|
|
13
|
+
* is 0700 and the file 0600. On Windows chmod is a no-op (the mode bits do
|
|
14
|
+
* not exist), so the file relies on the profile directory's own permissions,
|
|
15
|
+
* which is what every other credential store in %USERPROFILE% does.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
export const KEY_PATTERN = /^pr_(?:live|test)_[A-Za-z0-9_-]{16,}$/;
|
|
23
|
+
export const CREDENTIALS_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/** The credentials file path: the flag, then the env override, then the default under the home directory. */
|
|
26
|
+
export function credentialsPath({ env = process.env, home = homedir(), flag = null } = {}) {
|
|
27
|
+
if (flag) return flag;
|
|
28
|
+
if (env.PREREASON_CREDENTIALS_FILE) return env.PREREASON_CREDENTIALS_FILE;
|
|
29
|
+
return join(home, '.prereason', 'credentials.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse the CLI arguments the bridge understands.
|
|
34
|
+
* --header Key:Value (repeatable)
|
|
35
|
+
* --credentials-file <path>
|
|
36
|
+
* --login --logout --help/-h --version/-v
|
|
37
|
+
* <url> a bare argument overrides the endpoint
|
|
38
|
+
*/
|
|
39
|
+
export function parseArgs(argv) {
|
|
40
|
+
const out = { headers: {}, url: null, credentialsFile: null, login: false, logout: false, help: false, version: false };
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
if (arg === '--header' && argv[i + 1] !== undefined) {
|
|
44
|
+
const value = argv[++i];
|
|
45
|
+
const colon = value.indexOf(':');
|
|
46
|
+
if (colon > 0) out.headers[value.slice(0, colon).trim()] = value.slice(colon + 1).trim();
|
|
47
|
+
} else if (arg === '--credentials-file' && argv[i + 1] !== undefined) {
|
|
48
|
+
out.credentialsFile = argv[++i];
|
|
49
|
+
} else if (arg === '--login') {
|
|
50
|
+
out.login = true;
|
|
51
|
+
} else if (arg === '--logout') {
|
|
52
|
+
out.logout = true;
|
|
53
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
54
|
+
out.help = true;
|
|
55
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
56
|
+
out.version = true;
|
|
57
|
+
} else if (!arg.startsWith('-')) {
|
|
58
|
+
out.url = arg;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The API key carried by a --header value, if any. Accepts Authorization: Bearer and X-API-Key, any case. */
|
|
65
|
+
export function keyFromHeaders(headers) {
|
|
66
|
+
for (const [name, value] of Object.entries(headers || {})) {
|
|
67
|
+
const lower = name.toLowerCase();
|
|
68
|
+
if (lower === 'authorization') {
|
|
69
|
+
const m = /^Bearer\s+(\S+)$/i.exec(String(value).trim());
|
|
70
|
+
if (m && KEY_PATTERN.test(m[1])) return m[1];
|
|
71
|
+
} else if (lower === 'x-api-key') {
|
|
72
|
+
const v = String(value).trim();
|
|
73
|
+
if (KEY_PATTERN.test(v)) return v;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read the credentials file. Anything that is not a well formed key reads as null. */
|
|
80
|
+
export function readCredentials(path) {
|
|
81
|
+
if (!existsSync(path)) return null;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
84
|
+
if (!parsed || typeof parsed.api_key !== 'string' || !KEY_PATTERN.test(parsed.api_key)) return null;
|
|
85
|
+
return {
|
|
86
|
+
apiKey: parsed.api_key,
|
|
87
|
+
savedAt: typeof parsed.saved_at === 'string' ? parsed.saved_at : null,
|
|
88
|
+
claimCode: typeof parsed.claim_code === 'string' ? parsed.claim_code : null,
|
|
89
|
+
clientName: typeof parsed.client_name === 'string' ? parsed.client_name : null,
|
|
90
|
+
keyName: typeof parsed.key_name === 'string' ? parsed.key_name : null,
|
|
91
|
+
};
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Write the credentials file atomically (temp file, then rename) with the
|
|
99
|
+
* tightest modes the platform offers. Only these fields are ever written.
|
|
100
|
+
*/
|
|
101
|
+
export function writeCredentials(path, { apiKey, claimCode = null, clientName = null, keyName = null, now = new Date() }) {
|
|
102
|
+
if (!KEY_PATTERN.test(apiKey)) throw new Error('refusing to save something that is not a PreReason API key');
|
|
103
|
+
const dir = dirname(path);
|
|
104
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
tighten(dir, 0o700);
|
|
106
|
+
const body = JSON.stringify(
|
|
107
|
+
{
|
|
108
|
+
version: CREDENTIALS_VERSION,
|
|
109
|
+
api_key: apiKey,
|
|
110
|
+
saved_at: now.toISOString(),
|
|
111
|
+
claim_code: claimCode,
|
|
112
|
+
client_name: clientName,
|
|
113
|
+
key_name: keyName,
|
|
114
|
+
},
|
|
115
|
+
null,
|
|
116
|
+
2
|
|
117
|
+
);
|
|
118
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
119
|
+
writeFileSync(tmp, body + '\n', { mode: 0o600 });
|
|
120
|
+
tighten(tmp, 0o600);
|
|
121
|
+
renameSync(tmp, path);
|
|
122
|
+
tighten(path, 0o600);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function deleteCredentials(path) {
|
|
126
|
+
if (!existsSync(path)) return false;
|
|
127
|
+
unlinkSync(path);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function tighten(target, mode) {
|
|
132
|
+
if (process.platform === 'win32') return; // no mode bits to set
|
|
133
|
+
try {
|
|
134
|
+
chmodSync(target, mode);
|
|
135
|
+
} catch {
|
|
136
|
+
// a filesystem that refuses chmod (some mounts) still gets the file; nothing else to do
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Which key the bridge should use, and where it came from.
|
|
142
|
+
* Returns { key, source } with source one of 'env', 'header', 'file', or null.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveApiKey({ env = process.env, headers = {}, credentialsFile }) {
|
|
145
|
+
const fromEnv = env.PREREASON_API_KEY;
|
|
146
|
+
if (fromEnv && KEY_PATTERN.test(fromEnv.trim())) return { key: fromEnv.trim(), source: 'env' };
|
|
147
|
+
const fromHeader = keyFromHeaders(headers);
|
|
148
|
+
if (fromHeader) return { key: fromHeader, source: 'header' };
|
|
149
|
+
const stored = credentialsFile ? readCredentials(credentialsFile) : null;
|
|
150
|
+
if (stored) return { key: stored.apiKey, source: 'file' };
|
|
151
|
+
return { key: null, source: null };
|
|
152
|
+
}
|
package/lib/jsonrpc.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The smallest shape check that keeps garbage out of the relay.
|
|
3
|
+
*
|
|
4
|
+
* The bridge forwards JSON-RPC frames between a stdio host and PreReason's
|
|
5
|
+
* HTTP endpoint without reading them, so it has no reason to validate methods
|
|
6
|
+
* or params, and no reason to carry a schema library to do it. It only has to
|
|
7
|
+
* be sure that what it forwards is a JSON-RPC object: a host that receives a
|
|
8
|
+
* bare string or a number on stdout treats the stream as corrupt and drops the
|
|
9
|
+
* connection, which is the one failure the relay must not cause itself.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single JSON-RPC 2.0 frame: an object, not an array, tagged "2.0". */
|
|
13
|
+
export function isJsonRpcMessage(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && value.jsonrpc === '2.0';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Throws on anything that is not one JSON-RPC message.
|
|
19
|
+
*
|
|
20
|
+
* Used on the stdio side, which is framed one message per line and has never
|
|
21
|
+
* carried a batch.
|
|
22
|
+
*/
|
|
23
|
+
export function assertJsonRpcMessage(value) {
|
|
24
|
+
if (!isJsonRpcMessage(value)) {
|
|
25
|
+
throw new Error('not a JSON-RPC 2.0 message');
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Throws on anything that is not a JSON-RPC message or an array of them.
|
|
32
|
+
*
|
|
33
|
+
* Used on the HTTP side. Batching left the MCP spec in 2025-06-18, but a
|
|
34
|
+
* server is still free to answer with an array and the relay should pass it
|
|
35
|
+
* on rather than decide the stream is broken.
|
|
36
|
+
*/
|
|
37
|
+
export function assertJsonRpcPayload(value) {
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
if (value.length === 0) {
|
|
40
|
+
throw new Error('empty JSON-RPC batch');
|
|
41
|
+
}
|
|
42
|
+
for (const entry of value) {
|
|
43
|
+
if (!isJsonRpcMessage(entry)) {
|
|
44
|
+
throw new Error('batch entry is not a JSON-RPC 2.0 message');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
return assertJsonRpcMessage(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when the frame expects an answer, which is what decides whether a POST
|
|
54
|
+
* reads a response body or just releases the connection. A notification has a
|
|
55
|
+
* method and no id; a response has an id and no method.
|
|
56
|
+
*/
|
|
57
|
+
export function expectsResponse(message) {
|
|
58
|
+
const frames = Array.isArray(message) ? message : [message];
|
|
59
|
+
return frames.some((frame) => typeof frame === 'object' && frame !== null && 'method' in frame && 'id' in frame && frame.id !== undefined);
|
|
60
|
+
}
|
package/lib/sse.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Server-Sent Events decoder, sized for this bridge.
|
|
3
|
+
*
|
|
4
|
+
* PreReason's own endpoint answers POST with application/json and 405s a GET,
|
|
5
|
+
* so nothing here runs against api.prereason.com today. It exists because
|
|
6
|
+
* PREREASON_URL can point the bridge at any Streamable HTTP server, and the
|
|
7
|
+
* transport half of that spec is allowed to answer a POST with an event
|
|
8
|
+
* stream. Keeping the decoder costs forty lines and keeps the bridge honest
|
|
9
|
+
* against a server that does.
|
|
10
|
+
*
|
|
11
|
+
* The field rules are the WHATWG ones, and the buffering rules follow
|
|
12
|
+
* eventsource-parser, which is what the MCP SDK used before this file
|
|
13
|
+
* replaced it:
|
|
14
|
+
*
|
|
15
|
+
* a blank line dispatches, and only if data was collected
|
|
16
|
+
* data lines join with a newline, and one trailing newline is dropped
|
|
17
|
+
* one space after the colon is part of the separator, not the value
|
|
18
|
+
* a line with no colon is a field with an empty value
|
|
19
|
+
* an id holding a NUL is discarded, and id does not survive a dispatch
|
|
20
|
+
* a line starting with a colon is a comment, which is how servers keep alive
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Split text into complete lines plus the tail that has not ended yet. */
|
|
24
|
+
function splitLines(chunk) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
let index = 0;
|
|
27
|
+
|
|
28
|
+
while (index < chunk.length) {
|
|
29
|
+
const cr = chunk.indexOf('\r', index);
|
|
30
|
+
const lf = chunk.indexOf('\n', index);
|
|
31
|
+
let end = -1;
|
|
32
|
+
|
|
33
|
+
if (cr !== -1 && lf !== -1) {
|
|
34
|
+
end = Math.min(cr, lf);
|
|
35
|
+
} else if (cr !== -1) {
|
|
36
|
+
// A lone carriage return at the very end may be the first half of a
|
|
37
|
+
// CRLF that the next chunk completes, so hold the line back.
|
|
38
|
+
end = cr === chunk.length - 1 ? -1 : cr;
|
|
39
|
+
} else if (lf !== -1) {
|
|
40
|
+
end = lf;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (end === -1) {
|
|
44
|
+
return [lines, chunk.slice(index)];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lines.push(chunk.slice(index, end));
|
|
48
|
+
index = end + 1;
|
|
49
|
+
if (chunk[index - 1] === '\r' && chunk[index] === '\n') {
|
|
50
|
+
index += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return [lines, ''];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class SseDecoder {
|
|
58
|
+
#incomplete = '';
|
|
59
|
+
#data = '';
|
|
60
|
+
#eventType = '';
|
|
61
|
+
#id;
|
|
62
|
+
#onRetry;
|
|
63
|
+
|
|
64
|
+
constructor({ onRetry } = {}) {
|
|
65
|
+
this.#onRetry = onRetry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Feed one decoded text chunk and get back the events that completed inside
|
|
70
|
+
* it. A chunk may end mid line, so the tail is carried to the next call.
|
|
71
|
+
*/
|
|
72
|
+
push(text) {
|
|
73
|
+
const [lines, incomplete] = splitLines(this.#incomplete + text);
|
|
74
|
+
this.#incomplete = incomplete;
|
|
75
|
+
|
|
76
|
+
const events = [];
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const event = this.#readLine(line);
|
|
79
|
+
if (event) {
|
|
80
|
+
events.push(event);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return events;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#readLine(line) {
|
|
87
|
+
if (line === '') {
|
|
88
|
+
return this.#dispatch();
|
|
89
|
+
}
|
|
90
|
+
if (line.startsWith(':')) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const separator = line.indexOf(':');
|
|
95
|
+
if (separator === -1) {
|
|
96
|
+
this.#setField(line, '');
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const offset = line[separator + 1] === ' ' ? 2 : 1;
|
|
101
|
+
this.#setField(line.slice(0, separator), line.slice(separator + offset));
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#setField(field, value) {
|
|
106
|
+
if (field === 'data') {
|
|
107
|
+
this.#data += `${value}\n`;
|
|
108
|
+
} else if (field === 'event') {
|
|
109
|
+
this.#eventType = value;
|
|
110
|
+
} else if (field === 'id') {
|
|
111
|
+
this.#id = value.includes('\u0000') ? undefined : value;
|
|
112
|
+
} else if (field === 'retry' && /^\d+$/.test(value)) {
|
|
113
|
+
this.#onRetry?.(Number.parseInt(value, 10));
|
|
114
|
+
}
|
|
115
|
+
// Any other field is ignored, which is what the spec asks for.
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#dispatch() {
|
|
119
|
+
const data = this.#data;
|
|
120
|
+
const event = this.#eventType;
|
|
121
|
+
const id = this.#id;
|
|
122
|
+
|
|
123
|
+
this.#data = '';
|
|
124
|
+
this.#eventType = '';
|
|
125
|
+
this.#id = undefined;
|
|
126
|
+
|
|
127
|
+
if (data.length === 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
event: event || undefined,
|
|
133
|
+
data: data.endsWith('\n') ? data.slice(0, -1) : data,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read an event stream body to the end, handing each event to onEvent.
|
|
140
|
+
*
|
|
141
|
+
* Takes the reader rather than the stream so the caller keeps the handle it
|
|
142
|
+
* needs to cancel, and decodes with TextDecoder rather than piping through a
|
|
143
|
+
* TextDecoderStream, because a multi byte character may straddle two chunks
|
|
144
|
+
* and { stream: true } is what carries the partial one across.
|
|
145
|
+
*/
|
|
146
|
+
export async function readEventStream(body, { onEvent, onRetry } = {}) {
|
|
147
|
+
const reader = body.getReader();
|
|
148
|
+
const decoder = new TextDecoder();
|
|
149
|
+
const sse = new SseDecoder({ onRetry });
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
while (true) {
|
|
153
|
+
const { value, done } = await reader.read();
|
|
154
|
+
if (done) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
for (const event of sse.push(decoder.decode(value, { stream: true }))) {
|
|
158
|
+
onEvent?.(event);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Flush any half decoded character. An event the server never terminated
|
|
162
|
+
// with a blank line is dropped on purpose: its data is a truncated frame,
|
|
163
|
+
// and forwarding half a JSON-RPC message is worse than losing it.
|
|
164
|
+
for (const event of sse.push(decoder.decode())) {
|
|
165
|
+
onEvent?.(event);
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
reader.releaseLock();
|
|
169
|
+
}
|
|
170
|
+
}
|