@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 CHANGED
@@ -1,6 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## 0.4.0 (unreleased)
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
  [![node version](https://img.shields.io/node/v/@prereason/mcp.svg)](https://nodejs.org)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
9
9
  [![Glama Score](https://glama.ai/mcp/servers/PreReason/mcp/badges/score.svg)](https://glama.ai/mcp/servers/PreReason/mcp)
10
+ [![Smithery](https://img.shields.io/badge/Smithery-listed-7c3aed)](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 on Bitcoin, macro liquidity, FX or the links between assets, and the analysis is 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 catalogue holds 18 live briefings and 119 individual metrics, covering 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
+ 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 '@modelcontextprotocol/sdk/server/stdio.js';
31
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
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.4.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 StreamableHTTPClientTransport(url, { requestInit: { headers } });
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.4.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
  },