@prereason/mcp 0.4.0 → 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 +18 -1
- package/README.md +9 -2
- package/bin/cli.js +4 -4
- 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 +2 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.5.0 (2026-09-21)
|
|
4
|
+
|
|
5
|
+
### Removed
|
|
6
|
+
- `@modelcontextprotocol/sdk`, the only dependency. The bridge now installs nothing. It used the SDK for two classes, `StdioServerTransport` and `StreamableHTTPClientTransport`, and paid 91 packages and 25 MB on disk for them: Express, Hono, CORS, `express-rate-limit`, `jose`, `pkce-challenge`, `ajv`, `zod` and the rest are the SDK server and OAuth halves, and a relay calls none of them. The published tarball is 23 KB, `npm ls` is one line, and the code a security review has to read is the code in this repository.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- `lib/stdio.js`: newline delimited JSON-RPC over stdin and stdout, with the SDK framing kept exactly, including the 10 MB line ceiling, the carriage return before the newline, and the rule that a line which will not parse is reported and skipped rather than ending the session.
|
|
10
|
+
- `lib/streamable-http.js`: the Streamable HTTP client. POST a frame, read the answer as JSON or as an event stream, carry `Mcp-Session-Id` onto later requests, open the optional GET stream once the session is initialized, and treat a 405 there as the server saying it has none, which is what PreReason answers. Headers are read from `requestInit` on every request rather than copied once, because the claim flow attaches `Authorization` to that same object after the transport has started.
|
|
11
|
+
- `lib/sse.js`: a Server-Sent Events decoder following the same buffering rules as `eventsource-parser`, including the carriage return split across two chunks that would otherwise turn one event into two.
|
|
12
|
+
- `lib/jsonrpc.js`: the shape check that replaces the SDK zod schema. A relay has no reason to validate methods or params, only to be sure it is not writing a bare string to a host that would treat the stream as corrupt.
|
|
13
|
+
- `test/` grows to 71 cases across six files, among them `test/bridge.test.js`, which spawns `bin/cli.js` against a stub server and asserts on the headers that reach the wire. The suite runs on an empty `node_modules`, which is the proof there is nothing left to install.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `socket.yml` describes this package own code and nothing else, and says in the file what it is for: it configures the Socket GitHub app and CLI for this repository, and has no effect on the public package score.
|
|
17
|
+
- `server.json` registry version 0.8.2, npm package 0.5.0.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## 0.4.0 (2026-09-21)
|
|
4
21
|
|
|
5
22
|
### Added
|
|
6
23
|
- The bridge gets its own key. With no `PREREASON_API_KEY` and no saved key it asks PreReason for access, prints one link to stderr (`Open https://www.prereason.com/claim/PR-XXXX-XXXX to approve access`), keeps serving the free tools, and polls until the person approves. The key arrives once, is saved to `~/.prereason/credentials.json` (0700 directory, 0600 file on POSIX; Windows has no mode bits), and is attached to the running connection without a restart. While the link is pending, any `AUTH_REQUIRED` tool result starts with `Approve at <link>` so the assistant can relay it, because a person inside Claude Desktop never sees this process's stderr.
|
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
9
9
|
[](https://glama.ai/mcp/servers/PreReason/mcp)
|
|
10
|
+
[](https://smithery.ai/servers/prereason/briefings)
|
|
10
11
|
|
|
11
12
|
**MCP server for [PreReason](https://www.prereason.com).**
|
|
12
13
|
|
|
@@ -14,13 +15,13 @@ Bitcoin and macro market briefings for AI agents: trend signals, regimes, liquid
|
|
|
14
15
|
|
|
15
16
|
</div>
|
|
16
17
|
|
|
17
|
-
PreReason gives an AI agent market context it can reason with, in place of raw numbers. One call returns a briefing
|
|
18
|
+
PreReason gives an AI agent market context it can reason with, in place of raw numbers. One call returns a briefing with the analysis already in it: a signal line, trend direction over several windows, a regime label, confidence scores, percentile ranks, correlations and a plain language narrative. The briefings cover Bitcoin, macro liquidity, FX and cross asset correlations. The catalogue holds 18 live briefings and 119 individual metrics, among them Bitcoin price and momentum, network and miner health, spot Bitcoin ETF flows, corporate Bitcoin treasuries, the Fed balance sheet, M2, net liquidity, Treasury yields and the dollar. It is served over MCP (a remote server and an npm bridge) and over REST, as Markdown or JSON. The catalogue tools need no key, and an agent can get a free key from inside the session: it shows one link, a person approves it, and the key arrives.
|
|
18
19
|
|
|
19
20
|
## Quick Start
|
|
20
21
|
|
|
21
22
|
### Option 1: Claude Desktop, no key needed
|
|
22
23
|
|
|
23
|
-
**Requires [Node.js 18+](https://nodejs.org)
|
|
24
|
+
**Requires [Node.js 18+](https://nodejs.org), and nothing else: the bridge has no dependencies.**
|
|
24
25
|
|
|
25
26
|
Add this to `claude_desktop_config.json` and restart Claude Desktop:
|
|
26
27
|
|
|
@@ -212,6 +213,12 @@ Key precedence: `PREREASON_API_KEY`, then `--header`, then the credentials file
|
|
|
212
213
|
|
|
213
214
|
`mcpb/manifest.json` describes the same bridge as a single click Claude Desktop extension, key optional. To build the bundle: `npm install --omit=dev`, then `npx @anthropic-ai/mcpb pack .` from the package directory, and install the resulting `.mcpb` by double clicking it. Submission to the Claude directory goes through the desktop extension form and is a publisher decision.
|
|
214
215
|
|
|
216
|
+
## No dependencies
|
|
217
|
+
|
|
218
|
+
The bridge ships its own transports and installs nothing. `npm ls` on it is one line, `npx @prereason/mcp` fetches one 23 KB tarball and starts, and the code a security review has to read is the code in this repository.
|
|
219
|
+
|
|
220
|
+
It used to depend on `@modelcontextprotocol/sdk` for two classes, a stdio transport and a Streamable HTTP client. That pulled in 91 packages and 25 MB on disk, nearly all of it the SDK server half: Express, Hono, CORS, a rate limiter, an OAuth client and a schema validator, none of which a relay ever calls. `lib/stdio.js` and `lib/streamable-http.js` replace the two classes the bridge used, keep their framing and their callbacks, and are covered by the suite under `test/`.
|
|
221
|
+
|
|
215
222
|
## Links
|
|
216
223
|
|
|
217
224
|
- [Documentation](https://www.prereason.com/docs#mcp)
|
package/bin/cli.js
CHANGED
|
@@ -27,14 +27,14 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { platform } from 'node:os';
|
|
30
|
-
import { StdioServerTransport } from '
|
|
31
|
-
import {
|
|
30
|
+
import { StdioServerTransport } from '../lib/stdio.js';
|
|
31
|
+
import { StreamableHttpClientTransport } from '../lib/streamable-http.js';
|
|
32
32
|
import { credentialsPath, deleteCredentials, parseArgs, resolveApiKey } from '../lib/credentials.js';
|
|
33
33
|
import { decorateAuthRequired, runClaimFlow } from '../lib/claim.js';
|
|
34
34
|
|
|
35
35
|
// Keep in sync with package.json on each release
|
|
36
36
|
const PKG_NAME = '@prereason/mcp';
|
|
37
|
-
const PKG_VERSION = '0.
|
|
37
|
+
const PKG_VERSION = '0.5.0';
|
|
38
38
|
const DEFAULT_URL = 'https://api.prereason.com/api/mcp';
|
|
39
39
|
const USER_AGENT = `prereason-mcp/${PKG_VERSION} node/${process.versions.node} (${platform()})`;
|
|
40
40
|
|
|
@@ -113,7 +113,7 @@ if (args.login) {
|
|
|
113
113
|
|
|
114
114
|
// --- Transports: stdio to the host, Streamable HTTP to PreReason ---
|
|
115
115
|
const stdio = new StdioServerTransport();
|
|
116
|
-
const http = new
|
|
116
|
+
const http = new StreamableHttpClientTransport(url, { requestInit: { headers } });
|
|
117
117
|
|
|
118
118
|
/** While a claim is pending, the approve link the tool results carry. */
|
|
119
119
|
const pending = { approveUrl: null, claimCode: null };
|
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
|
+
}
|
package/lib/stdio.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stdio half of the bridge: newline delimited JSON-RPC over this process's
|
|
3
|
+
* stdin and stdout, which is the transport Claude Desktop and every other
|
|
4
|
+
* stdio only host speaks.
|
|
5
|
+
*
|
|
6
|
+
* This is a direct replacement for StdioServerTransport in
|
|
7
|
+
* @modelcontextprotocol/sdk, kept to the same framing and the same callback
|
|
8
|
+
* surface (onmessage, onerror, onclose) so bin/cli.js wires it up unchanged.
|
|
9
|
+
* It is here rather than imported because the SDK cannot ship that class on
|
|
10
|
+
* its own: taking it pulled in 90 packages and 22 MB, every one of them for
|
|
11
|
+
* the server and OAuth code a relay never reaches.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import process from 'node:process';
|
|
15
|
+
import { assertJsonRpcMessage } from './jsonrpc.js';
|
|
16
|
+
|
|
17
|
+
/** One line in, one frame out, throwing on anything that is not a frame. */
|
|
18
|
+
export function deserializeLine(line) {
|
|
19
|
+
return assertJsonRpcMessage(JSON.parse(line));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Ten megabytes, the SDK's ceiling. A line longer than this is not a frame. */
|
|
23
|
+
export const MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** Buffers a byte stream into whole lines, each of which is one JSON-RPC frame. */
|
|
26
|
+
export class ReadBuffer {
|
|
27
|
+
#buffer;
|
|
28
|
+
#maxBufferSize;
|
|
29
|
+
|
|
30
|
+
constructor({ maxBufferSize = MAX_BUFFER_SIZE } = {}) {
|
|
31
|
+
this.#maxBufferSize = maxBufferSize;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
append(chunk) {
|
|
35
|
+
const size = (this.#buffer?.length ?? 0) + chunk.length;
|
|
36
|
+
if (size > this.#maxBufferSize) {
|
|
37
|
+
this.clear();
|
|
38
|
+
throw new Error(`ReadBuffer exceeded maximum size of ${this.#maxBufferSize} bytes`);
|
|
39
|
+
}
|
|
40
|
+
this.#buffer = this.#buffer ? Buffer.concat([this.#buffer, chunk]) : chunk;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The next whole line as a parsed frame, or null while one is still arriving. */
|
|
44
|
+
readMessage(deserialize) {
|
|
45
|
+
if (!this.#buffer) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const newline = this.#buffer.indexOf('\n');
|
|
49
|
+
if (newline === -1) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const line = this.#buffer.toString('utf8', 0, newline).replace(/\r$/, '');
|
|
53
|
+
this.#buffer = this.#buffer.subarray(newline + 1);
|
|
54
|
+
return deserialize(line);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
clear() {
|
|
58
|
+
this.#buffer = undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class StdioServerTransport {
|
|
63
|
+
#stdin;
|
|
64
|
+
#stdout;
|
|
65
|
+
#readBuffer;
|
|
66
|
+
#started = false;
|
|
67
|
+
#deserialize;
|
|
68
|
+
|
|
69
|
+
onmessage;
|
|
70
|
+
onerror;
|
|
71
|
+
onclose;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param deserialize turns one line into a frame and throws on anything
|
|
75
|
+
* that is not one. Injected so the shape check lives in one place and the
|
|
76
|
+
* tests can drive the framing without it.
|
|
77
|
+
*/
|
|
78
|
+
constructor({ stdin = process.stdin, stdout = process.stdout, maxBufferSize, deserialize = deserializeLine } = {}) {
|
|
79
|
+
this.#stdin = stdin;
|
|
80
|
+
this.#stdout = stdout;
|
|
81
|
+
this.#readBuffer = new ReadBuffer({ maxBufferSize });
|
|
82
|
+
this.#deserialize = deserialize;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Arrow properties, so the same function identity comes off the emitter in
|
|
86
|
+
// close() as went on in start().
|
|
87
|
+
#ondata = (chunk) => {
|
|
88
|
+
try {
|
|
89
|
+
this.#readBuffer.append(chunk);
|
|
90
|
+
this.#drainReadBuffer();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.onerror?.(error);
|
|
93
|
+
this.close().catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
#onstdinerror = (error) => {
|
|
98
|
+
this.onerror?.(error);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
async start() {
|
|
102
|
+
if (this.#started) {
|
|
103
|
+
throw new Error('StdioServerTransport already started');
|
|
104
|
+
}
|
|
105
|
+
this.#started = true;
|
|
106
|
+
this.#stdin.on('data', this.#ondata);
|
|
107
|
+
this.#stdin.on('error', this.#onstdinerror);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A frame that will not parse is reported and skipped, never fatal: one bad
|
|
112
|
+
* line from the host must not take down a session whose next line is fine.
|
|
113
|
+
*/
|
|
114
|
+
#drainReadBuffer() {
|
|
115
|
+
while (true) {
|
|
116
|
+
try {
|
|
117
|
+
const message = this.#readBuffer.readMessage(this.#deserialize);
|
|
118
|
+
if (message === null) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.onmessage?.(message);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
this.onerror?.(error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async close() {
|
|
129
|
+
this.#stdin.off('data', this.#ondata);
|
|
130
|
+
this.#stdin.off('error', this.#onstdinerror);
|
|
131
|
+
// Only pause stdin if nothing else is reading it, so the bridge never
|
|
132
|
+
// stalls a host that shares the descriptor.
|
|
133
|
+
if (this.#stdin.listenerCount('data') === 0) {
|
|
134
|
+
this.#stdin.pause();
|
|
135
|
+
}
|
|
136
|
+
this.#readBuffer.clear();
|
|
137
|
+
this.onclose?.();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
send(message) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
if (this.#stdout.write(`${JSON.stringify(message)}\n`)) {
|
|
143
|
+
resolve();
|
|
144
|
+
} else {
|
|
145
|
+
this.#stdout.once('drain', resolve);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP half of the bridge: MCP Streamable HTTP, client side.
|
|
3
|
+
*
|
|
4
|
+
* A direct replacement for StreamableHTTPClientTransport in
|
|
5
|
+
* @modelcontextprotocol/sdk, narrowed to what a relay does. It POSTs a frame,
|
|
6
|
+
* reads the answer as JSON or as an event stream, carries Mcp-Session-Id back
|
|
7
|
+
* on later requests, and opens the optional GET stream once the session is
|
|
8
|
+
* initialized. The SDK's OAuth client, its schema validation and its session
|
|
9
|
+
* termination are gone: the bridge authenticates with a bearer header it is
|
|
10
|
+
* handed, forwards frames without reading them, and is torn down by the host
|
|
11
|
+
* closing stdin.
|
|
12
|
+
*
|
|
13
|
+
* Headers are read from requestInit on every request rather than copied once,
|
|
14
|
+
* because the claim flow attaches Authorization to that same object minutes
|
|
15
|
+
* after the transport started, and the next request has to carry it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { assertJsonRpcPayload, expectsResponse } from './jsonrpc.js';
|
|
19
|
+
import { readEventStream } from './sse.js';
|
|
20
|
+
|
|
21
|
+
/** The SDK's reconnection defaults, kept so a resumable server sees no change. */
|
|
22
|
+
const RECONNECT = {
|
|
23
|
+
initialDelayMs: 1000,
|
|
24
|
+
maxDelayMs: 30_000,
|
|
25
|
+
growthFactor: 1.5,
|
|
26
|
+
maxRetries: 2,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class StreamableHttpError extends Error {
|
|
30
|
+
constructor(code, message) {
|
|
31
|
+
super(`Streamable HTTP error: ${message}`);
|
|
32
|
+
this.name = 'StreamableHttpError';
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** "application/json; charset=utf-8" is the same media type as "application/json". */
|
|
38
|
+
function mediaType(contentType) {
|
|
39
|
+
return (contentType ?? '').split(';')[0].trim().toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Headers arrive as an object, a Headers, or an array of pairs. */
|
|
43
|
+
function toPlainHeaders(headers) {
|
|
44
|
+
if (!headers) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
if (headers instanceof Headers) {
|
|
48
|
+
return Object.fromEntries(headers.entries());
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(headers)) {
|
|
51
|
+
return Object.fromEntries(headers);
|
|
52
|
+
}
|
|
53
|
+
return { ...headers };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Release a body we are not going to read, so the socket goes back to the pool. */
|
|
57
|
+
async function discard(response) {
|
|
58
|
+
try {
|
|
59
|
+
await response.body?.cancel();
|
|
60
|
+
} catch {
|
|
61
|
+
// A body already consumed or already errored needs nothing from us.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class StreamableHttpClientTransport {
|
|
66
|
+
#url;
|
|
67
|
+
#requestInit;
|
|
68
|
+
#fetch;
|
|
69
|
+
#sessionId;
|
|
70
|
+
#abortController;
|
|
71
|
+
#reconnectTimer;
|
|
72
|
+
#serverRetryMs;
|
|
73
|
+
|
|
74
|
+
onmessage;
|
|
75
|
+
onerror;
|
|
76
|
+
onclose;
|
|
77
|
+
|
|
78
|
+
constructor(url, { requestInit, fetch: fetchImpl = fetch, sessionId } = {}) {
|
|
79
|
+
this.#url = url;
|
|
80
|
+
this.#requestInit = requestInit;
|
|
81
|
+
this.#fetch = fetchImpl;
|
|
82
|
+
this.#sessionId = sessionId;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get sessionId() {
|
|
86
|
+
return this.#sessionId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async start() {
|
|
90
|
+
if (this.#abortController) {
|
|
91
|
+
throw new Error('StreamableHttpClientTransport already started');
|
|
92
|
+
}
|
|
93
|
+
this.#abortController = new AbortController();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async close() {
|
|
97
|
+
if (this.#reconnectTimer) {
|
|
98
|
+
clearTimeout(this.#reconnectTimer);
|
|
99
|
+
this.#reconnectTimer = undefined;
|
|
100
|
+
}
|
|
101
|
+
this.#abortController?.abort();
|
|
102
|
+
this.onclose?.();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The session header first, then whatever the caller configured, so a header
|
|
107
|
+
* passed with --header or set by the claim flow wins over ours.
|
|
108
|
+
*/
|
|
109
|
+
#headers() {
|
|
110
|
+
const headers = new Headers();
|
|
111
|
+
if (this.#sessionId) {
|
|
112
|
+
headers.set('mcp-session-id', this.#sessionId);
|
|
113
|
+
}
|
|
114
|
+
for (const [name, value] of Object.entries(toPlainHeaders(this.#requestInit?.headers))) {
|
|
115
|
+
if (value !== undefined && value !== null) {
|
|
116
|
+
headers.set(name, String(value));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return headers;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async send(message) {
|
|
123
|
+
try {
|
|
124
|
+
const headers = this.#headers();
|
|
125
|
+
headers.set('content-type', 'application/json');
|
|
126
|
+
headers.set('accept', 'application/json, text/event-stream');
|
|
127
|
+
|
|
128
|
+
const response = await this.#fetch(this.#url, {
|
|
129
|
+
...this.#requestInit,
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers,
|
|
132
|
+
body: JSON.stringify(message),
|
|
133
|
+
signal: this.#abortController?.signal,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Stateful servers hand out a session on initialize and expect it back.
|
|
137
|
+
const sessionId = response.headers.get('mcp-session-id');
|
|
138
|
+
if (sessionId) {
|
|
139
|
+
this.#sessionId = sessionId;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const body = await response.text().catch(() => null);
|
|
144
|
+
throw new StreamableHttpError(response.status, `Error POSTing to endpoint: ${body}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (response.status === 202) {
|
|
148
|
+
await discard(response);
|
|
149
|
+
// The server accepted the notification and said nothing back. Once the
|
|
150
|
+
// session is initialized, that is the moment to try the GET stream a
|
|
151
|
+
// server may use to push messages. PreReason 405s it, which is fine.
|
|
152
|
+
if (!Array.isArray(message) && message?.method === 'notifications/initialized') {
|
|
153
|
+
this.#openServerStream({}).catch((error) => this.onerror?.(error));
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!expectsResponse(message)) {
|
|
159
|
+
// Nothing was asked, so nothing is read. The body still has to be
|
|
160
|
+
// released or the connection is held open until it times out.
|
|
161
|
+
await discard(response);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const type = mediaType(response.headers.get('content-type'));
|
|
166
|
+
if (type === 'application/json') {
|
|
167
|
+
this.#emit(await response.json());
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (type === 'text/event-stream') {
|
|
171
|
+
// Not awaited: the answer arrives through onmessage as the stream
|
|
172
|
+
// yields it, and send() should not stay pending for a tool call that
|
|
173
|
+
// streams for a minute.
|
|
174
|
+
this.#readStream(response, { reconnectable: false }).catch((error) => this.onerror?.(error));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await discard(response);
|
|
179
|
+
throw new StreamableHttpError(-1, `Unexpected content type: ${response.headers.get('content-type')}`);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
this.onerror?.(error);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One JSON payload, one or many frames, each forwarded to the host. */
|
|
187
|
+
#emit(payload) {
|
|
188
|
+
let frames;
|
|
189
|
+
try {
|
|
190
|
+
frames = assertJsonRpcPayload(payload);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.onerror?.(error);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const frame of Array.isArray(frames) ? frames : [frames]) {
|
|
196
|
+
this.onmessage?.(frame);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Read an event stream to the end, forwarding every message event.
|
|
202
|
+
*
|
|
203
|
+
* A frame that will not parse is reported and skipped rather than ending the
|
|
204
|
+
* stream, because the next event is usually fine.
|
|
205
|
+
*/
|
|
206
|
+
async #readStream(response, { reconnectable, lastEventId: startingEventId }) {
|
|
207
|
+
let lastEventId = startingEventId;
|
|
208
|
+
let sawResponse = false;
|
|
209
|
+
let sawEventId = false;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await readEventStream(response.body, {
|
|
213
|
+
onRetry: (retryMs) => {
|
|
214
|
+
this.#serverRetryMs = retryMs;
|
|
215
|
+
},
|
|
216
|
+
onEvent: (event) => {
|
|
217
|
+
if (event.id) {
|
|
218
|
+
lastEventId = event.id;
|
|
219
|
+
sawEventId = true;
|
|
220
|
+
}
|
|
221
|
+
if (event.event && event.event !== 'message') {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let frame;
|
|
225
|
+
try {
|
|
226
|
+
frame = assertJsonRpcPayload(JSON.parse(event.data));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.onerror?.(error);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const one of Array.isArray(frame) ? frame : [frame]) {
|
|
232
|
+
if ('result' in one || 'error' in one) {
|
|
233
|
+
sawResponse = true;
|
|
234
|
+
}
|
|
235
|
+
this.onmessage?.(one);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
this.onerror?.(new Error(`SSE stream disconnected: ${error}`));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Reconnect the standalone GET stream, and a POST stream that carried an
|
|
244
|
+
// event id, which is the server saying where to resume from. Never once
|
|
245
|
+
// the answer has arrived: that request is finished, and reopening the
|
|
246
|
+
// stream would only replay it.
|
|
247
|
+
const canResume = reconnectable || sawEventId;
|
|
248
|
+
if (canResume && !sawResponse && this.#abortController && !this.#abortController.signal.aborted) {
|
|
249
|
+
this.#scheduleReconnect(lastEventId, 0);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#scheduleReconnect(lastEventId, attempt) {
|
|
254
|
+
if (attempt >= RECONNECT.maxRetries) {
|
|
255
|
+
this.onerror?.(new Error(`Maximum reconnection attempts (${RECONNECT.maxRetries}) exceeded.`));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const backoff = RECONNECT.initialDelayMs * RECONNECT.growthFactor ** attempt;
|
|
259
|
+
const delay = this.#serverRetryMs ?? Math.min(backoff, RECONNECT.maxDelayMs);
|
|
260
|
+
|
|
261
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
262
|
+
this.#openServerStream({ lastEventId }).catch((error) => {
|
|
263
|
+
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
|
|
264
|
+
this.#scheduleReconnect(lastEventId, attempt + 1);
|
|
265
|
+
});
|
|
266
|
+
}, delay);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Open the optional GET stream a server may use to push messages.
|
|
271
|
+
*
|
|
272
|
+
* 405 is the documented way for a server to say it has no such stream, and
|
|
273
|
+
* PreReason's endpoint answers exactly that, so it is a quiet return and not
|
|
274
|
+
* an error.
|
|
275
|
+
*/
|
|
276
|
+
async #openServerStream({ lastEventId }) {
|
|
277
|
+
const headers = this.#headers();
|
|
278
|
+
headers.set('accept', 'text/event-stream');
|
|
279
|
+
if (lastEventId) {
|
|
280
|
+
headers.set('last-event-id', lastEventId);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const response = await this.#fetch(this.#url, {
|
|
284
|
+
...this.#requestInit,
|
|
285
|
+
method: 'GET',
|
|
286
|
+
headers,
|
|
287
|
+
signal: this.#abortController?.signal,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
await discard(response);
|
|
292
|
+
if (response.status === 405) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
throw new StreamableHttpError(response.status, `Failed to open SSE stream: ${response.statusText}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Not awaited: the stream runs for the life of the session while send()
|
|
299
|
+
// keeps working. Errors inside it go to onerror.
|
|
300
|
+
this.#readStream(response, { reconnectable: true, lastEventId }).catch((error) => this.onerror?.(error));
|
|
301
|
+
}
|
|
302
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prereason/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "com.prereason/mcp",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Bitcoin and macro market briefings for AI agents: trend signals, regimes, liquidity and ETF flows.",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "echo 'Build complete'",
|
|
12
|
-
"test": "node --test test/credentials.test.js test/claim.test.js"
|
|
12
|
+
"test": "node --test test/credentials.test.js test/claim.test.js test/jsonrpc.test.js test/sse.test.js test/stdio.test.js test/streamable-http.test.js test/bridge.test.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bin",
|
|
@@ -19,9 +19,6 @@
|
|
|
19
19
|
"CHANGELOG.md",
|
|
20
20
|
".mcp.json"
|
|
21
21
|
],
|
|
22
|
-
"dependencies": {
|
|
23
|
-
"@modelcontextprotocol/sdk": "1.30.0"
|
|
24
|
-
},
|
|
25
22
|
"engines": {
|
|
26
23
|
"node": ">=18"
|
|
27
24
|
},
|