@prereason/mcp 0.3.1 → 0.4.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,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (unreleased)
4
+
5
+ ### Added
6
+ - 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.
7
+ - `--login` (ask for access now, save the key, exit), `--logout` (forget the saved key), `--credentials-file <path>` and `PREREASON_CREDENTIALS_FILE`.
8
+ - `PREREASON_CLIENT` (for example `claude-desktop`) is forwarded as `X-PreReason-Client` so the dashboard names the connection; every request carries `User-Agent: prereason-mcp/0.4.0`.
9
+ - `node --test` suite under `test/`: key precedence, file modes, the poll loop against a stubbed server, and that a claim token never touches the disk.
10
+ - `mcpb/manifest.json`: the Claude Desktop extension manifest for a single click install (`npx @anthropic-ai/mcpb pack`), with the key optional.
11
+
12
+ ### Changed
13
+ - Key precedence is documented and tested: `PREREASON_API_KEY`, then `--header`, then the credentials file, then the claim flow.
14
+ - `@modelcontextprotocol/sdk` 1.27.1 to 1.30.0.
15
+ - The version is one number again: `cli.js` printed 0.3.1 while `package.json` said 0.3.2 and `server.json` 0.3.1.
16
+ - `server.json` (the registry entry) returns to `com.prereason/mcp`, the name that has been live in the registry since March; the July rename to `io.github.PreReason/mcp` was never published.
17
+ - `server.json` names the `X-API-Key` header on the remote, the header the published record already declares. The server accepts `Authorization: Bearer` as well. A registry client prompts a person for the header value, and with `X-API-Key` they paste the bare key: there is no `Bearer ` prefix to forget, and a key pasted without one into `Authorization` is silently treated as no key at all.
18
+ - `server.json` `description` is the one line every listing now carries, at 98 characters. It was 196, and the registry rejects anything over 100, so the record could not have been published.
19
+ - `server.json` `version` is 0.8.0 while the npm package stays 0.4.0. The registry marks a record latest only when its version sorts above the current latest, which is 0.7.2, so a record published as 0.4.0 would have been accepted and then ignored by everything downstream.
20
+ - README: the no key path comes first; OAuth is not offered until it is verified. The opening paragraph is the canonical directory copy.
21
+
22
+ ## 0.3.2 (2026-08-19)
23
+
24
+ ### Fixed
25
+ - Briefing count was 17 everywhere; the catalog has served 18 since `btc.etf-flows` (Basic) went live. README tier tables, package description, and server.json now agree with the API.
26
+ - Metric count was 30; the API reports 116.
27
+
3
28
  ## 0.3.0 (2026-04-12)
4
29
 
5
30
  ### Added
package/README.md CHANGED
@@ -8,100 +8,93 @@
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
10
 
11
- **MCP server for [PreReason](https://www.prereason.com) - the Context API for financial agents.**
11
+ **MCP server for [PreReason](https://www.prereason.com).**
12
12
 
13
- Give your agent market context, not raw price feeds. 17 briefings covering BTC, macro,<br>
14
- cross-asset regimes, and liquidity - each with trend signals, confidence scores, and causal narratives.
13
+ Bitcoin and macro market briefings for AI agents: trend signals, regimes, liquidity and ETF flows.
15
14
 
16
15
  </div>
17
16
 
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
+
18
19
  ## Quick Start
19
20
 
20
- ### Option 1: One-Click Connect (Claude.ai, ChatGPT, Cursor, Codex, Windsurf, VS Code Copilot)
21
+ ### Option 1: Claude Desktop, no key needed
22
+
23
+ **Requires [Node.js 18+](https://nodejs.org)**
21
24
 
22
- Clients that support MCP OAuth can connect with no API key setup required. Add the server URL and the client handles the rest - you'll be prompted to sign in or create a free account, and your API key is provisioned automatically.
25
+ Add this to `claude_desktop_config.json` and restart Claude Desktop:
23
26
 
24
27
  ```json
25
28
  {
26
29
  "mcpServers": {
27
30
  "prereason": {
28
- "type": "http",
29
- "url": "https://api.prereason.com/api/mcp"
31
+ "command": "npx",
32
+ "args": ["-y", "@prereason/mcp"],
33
+ "env": { "PREREASON_CLIENT": "claude-desktop" }
30
34
  }
31
35
  }
32
36
  }
33
37
  ```
34
38
 
35
- No `Authorization` header needed - OAuth handles it.
39
+ The catalogue tools work at once. The first time a briefing needs a key, the bridge asks for access: ask Claude for any briefing and the answer starts with `Approve at https://www.prereason.com/claim/PR-XXXX-XXXX`. Open the link, sign in or create a free account, click Approve. The key arrives in the bridge on its own, is saved to `~/.prereason/credentials.json`, and the next call works. Nothing is created in your account until you click Approve.
40
+
41
+ Config file location:
42
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
43
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
44
+ - **Linux:** `~/.config/Claude/claude_desktop_config.json`
45
+
46
+ Already have a key? Add it to the `env` block as `"PREREASON_API_KEY": "pr_live_..."` and the bridge never asks.
36
47
 
37
- ### Option 2: Direct HTTP with API Key (Claude Code, scripts, cron jobs)
48
+ ### Option 2: Direct HTTP with an API key (Claude Code, Cursor, Windsurf, Codex, Gemini CLI, VS Code, scripts)
38
49
 
39
- For CLI tools or when you want to use an existing API key:
50
+ Clients that hold their own config can call the endpoint directly, with the key as a header:
40
51
 
41
52
  ```bash
42
- # Claude Code (CLI one-liner)
43
- claude mcp add prereason --transport http https://api.prereason.com/api/mcp
53
+ # Claude Code
54
+ claude mcp add --transport http prereason https://api.prereason.com/api/mcp --header "Authorization: Bearer YOUR_API_KEY"
44
55
  ```
45
56
 
46
- Or with an explicit key in your MCP config:
47
-
48
57
  ```json
49
58
  {
50
59
  "mcpServers": {
51
60
  "prereason": {
52
61
  "type": "http",
53
62
  "url": "https://api.prereason.com/api/mcp",
54
- "headers": {
55
- "Authorization": "Bearer YOUR_API_KEY"
56
- }
63
+ "headers": { "Authorization": "Bearer YOUR_API_KEY" }
57
64
  }
58
65
  }
59
66
  }
60
67
  ```
61
68
 
62
- ### Option 3: stdio bridge (Claude Desktop and other stdio-only clients)
69
+ Windsurf uses `serverUrl` instead of `url`; Gemini CLI uses `httpUrl`; Codex uses `url` plus `bearer_token_env_var` in `config.toml`. No key yet? Point the client at the endpoint without a header and ask the assistant to call `request_access`; it walks you through the same one link approval and shows the key once, which you then paste into the config.
63
70
 
64
- **Requires [Node.js 18+](https://nodejs.org)**
71
+ ### Option 3: Claude.ai and Claude Desktop custom connector
65
72
 
66
- Add to your MCP client config (e.g. `claude_desktop_config.json`):
73
+ [Add PreReason as a custom connector](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=PreReason&connectorUrl=https%3A%2F%2Fapi.prereason.com%2Fapi%2Fmcp), choose "No sign-in", and where the Request headers section is available add `Authorization` with the value `Bearer YOUR_API_KEY` (the word Bearer and the space are part of the value). Sign in support for connectors is being re-tested and is not offered until it is verified.
67
74
 
68
- ```json
69
- {
70
- "mcpServers": {
71
- "prereason": {
72
- "command": "npx",
73
- "args": ["-y", "@prereason/mcp"],
74
- "env": {
75
- "PREREASON_API_KEY": "YOUR_API_KEY"
76
- }
77
- }
78
- }
79
- }
80
- ```
75
+ ## Get an API Key
81
76
 
82
- Claude Desktop config file location:
83
- - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
84
- - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
77
+ Three ways, all free:
85
78
 
86
- Restart your MCP client after editing the config. PreReason should appear with 5 tools.
79
+ **From inside the session (any MCP client).** Ask your assistant to call `request_access`. It returns an `approve_url`; open it, sign in or create your account, click Approve. The assistant then calls `check_access` and receives the key once, attached to your account and named `Agent: <client_name>`.
87
80
 
88
- ## Get an API Key
81
+ **From code, for an agent with no browser.** `POST https://api.prereason.com/api/agent/claims` (no auth), show the human the `approve_url`, then poll `GET https://api.prereason.com/api/agent/claims/{claim_code}` with `Authorization: Bearer <claim_token>` until `status` is `approved`. Docs: [prereason.com/docs#agent-access](https://www.prereason.com/docs#agent-access).
89
82
 
90
- 1. Sign up at [prereason.com/signup](https://www.prereason.com/signup)
91
- 2. Go to Dashboard > Settings > API Keys
92
- 3. Copy your key (starts with `pr_live_`)
83
+ **On the website.** Sign up at [prereason.com/signup](https://www.prereason.com/signup), then Dashboard > Settings > API Keys. Keys start with `pr_live_`.
93
84
 
94
- ## 5 MCP Tools
85
+ ## 7 MCP Tools
95
86
 
96
87
  | Tool | Auth | Description |
97
88
  |------|------|-------------|
98
- | `list_briefings` | Open | List all 17 pre-reasoned market briefings with tier requirements |
99
- | `list_metrics` | Open | List all 30 available metrics across bitcoin, macro, and calculated categories |
89
+ | `list_briefings` | Open | List all 18 pre-reasoned market briefings with tier requirements |
90
+ | `list_metrics` | Open | List every available metric across bitcoin, macro, and calculated categories |
100
91
  | `get_health` | Open | API health check, version, account tier |
92
+ | `request_access` | Open | Start getting a free key from inside the session; returns the approve link for the human |
93
+ | `check_access` | Open | Poll that claim; the first poll after approval returns the key once |
101
94
  | `get_context` | Required | Fetch a pre-reasoned market briefing (markdown or JSON) |
102
95
  | `get_metric` | Required | Fetch a single metric with trend/signal/percentile |
103
96
 
104
- ## 17 Market Briefings
97
+ ## 18 Market Briefings
105
98
 
106
99
  ### Free (6 briefings)
107
100
  | Briefing | Description |
@@ -113,7 +106,7 @@ Restart your MCP client after editing the config. PreReason should appear with 5
113
106
  | `btc.pulse` | Volume, fees, mempool analysis |
114
107
  | `btc.grid-stress` | Epoch pace and difficulty adjustment forecast |
115
108
 
116
- ### Basic - $19.99/mo (5 briefings)
109
+ ### Basic - $19.99/mo (6 briefings)
117
110
  | Briefing | Description |
118
111
  |----------|-------------|
119
112
  | `btc.momentum` | 200D MA support/resistance with 7d/30d/90d momentum and YTD percentiles |
@@ -121,6 +114,7 @@ Restart your MCP client after editing the config. PreReason should appear with 5
121
114
  | `btc.on-chain` | Hash rate, difficulty, fees, mempool health |
122
115
  | `cross.breadth` | Cross-asset breadth with SPY, DXY, VIX |
123
116
  | `btc.miner-survival` | Hashprice thermometer with miner stress scoring |
117
+ | `btc.etf-flows` | Spot BTC ETF net daily flows, aggregate AUM, and per-issuer breakdown |
124
118
 
125
119
  ### Pro - $49.99/mo (6 briefings)
126
120
  | Briefing | Description |
@@ -189,19 +183,35 @@ If your client supports remote HTTP servers, use [Quick Start Option 1](#option-
189
183
  ## CLI Usage
190
184
 
191
185
  ```bash
192
- # Using environment variable (recommended)
186
+ # No key: the bridge asks for access and prints one link to approve
187
+ npx @prereason/mcp
188
+
189
+ # Ask for access now, save the key, exit (useful before a first run)
190
+ npx @prereason/mcp --login
191
+
192
+ # Forget the saved key
193
+ npx @prereason/mcp --logout
194
+
195
+ # Use a key from the environment (never asks)
193
196
  PREREASON_API_KEY=pr_live_... npx @prereason/mcp
194
197
 
195
- # Using --header flag (backward compatible)
196
- npx @prereason/mcp --header "Authorization:Bearer YOUR_API_KEY"
198
+ # Name the app the bridge runs in, so your dashboard names the connection
199
+ PREREASON_CLIENT=claude-desktop npx @prereason/mcp
197
200
 
198
- # Custom URL
201
+ # --header (backward compatible), a custom credentials file, a custom endpoint
202
+ npx @prereason/mcp --header "Authorization:Bearer YOUR_API_KEY"
203
+ npx @prereason/mcp --credentials-file /path/to/credentials.json
199
204
  PREREASON_URL=https://custom.endpoint/mcp npx @prereason/mcp
200
205
 
201
- # Help
202
206
  npx @prereason/mcp --help
203
207
  ```
204
208
 
209
+ Key precedence: `PREREASON_API_KEY`, then `--header`, then the credentials file (`~/.prereason/credentials.json`, or `PREREASON_CREDENTIALS_FILE`, or `--credentials-file`), then the claim flow. The file holds the key and which claim issued it, never a claim token. On macOS and Linux the directory is created 0700 and the file 0600; Windows has no mode bits, so the file relies on your profile directory's permissions like every other credential store there.
210
+
211
+ ## Claude Desktop extension (.mcpb)
212
+
213
+ `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
+
205
215
  ## Links
206
216
 
207
217
  - [Documentation](https://www.prereason.com/docs#mcp)
package/bin/cli.js CHANGED
@@ -1,82 +1,123 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @prereason/mcp MCP bridge for Claude Desktop
3
+ * @prereason/mcp: the stdio bridge to PreReason's Streamable HTTP MCP endpoint.
4
4
  *
5
- * Connects Claude Desktop (stdio) to PreReason's Streamable HTTP MCP endpoint.
5
+ * Connects a stdio only client (Claude Desktop and others) to
6
+ * https://api.prereason.com/api/mcp.
6
7
  *
7
- * Auth (preferred works on all platforms):
8
- * Set PREREASON_API_KEY env var in your MCP client config.
8
+ * Where the key comes from, highest first:
9
+ * PREREASON_API_KEY in the environment
10
+ * --header "Authorization:Bearer pr_live_..."
11
+ * the credentials file (~/.prereason/credentials.json, or PREREASON_CREDENTIALS_FILE)
12
+ * no key: the bridge asks for access. It prints one link, the person opens it and
13
+ * approves, and the key arrives here and is saved. Free tools work meanwhile.
9
14
  *
10
15
  * Usage:
11
16
  * npx @prereason/mcp
12
- * npx @prereason/mcp [--header Key:Value]...
13
- * npx @prereason/mcp <URL> [--header Key:Value]...
17
+ * npx @prereason/mcp --login request access now and save the key, then exit
18
+ * npx @prereason/mcp --logout delete the saved key, then exit
19
+ * npx @prereason/mcp [--header Key:Value]... [--credentials-file <path>] [<URL>]
14
20
  *
15
- * Environment variables:
16
- * PREREASON_API_KEY Your API key (adds Authorization: Bearer header)
17
- * PREREASON_URL Override the default endpoint URL
21
+ * Environment:
22
+ * PREREASON_API_KEY Your API key (adds Authorization: Bearer)
23
+ * PREREASON_URL Override the endpoint URL
24
+ * PREREASON_CREDENTIALS_FILE Where a claimed key is kept
25
+ * PREREASON_CLIENT The app this bridge runs in (claude-desktop, cursor, ...),
26
+ * sent as X-PreReason-Client so your dashboard names it
18
27
  */
19
28
 
29
+ import { platform } from 'node:os';
20
30
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
31
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
32
+ import { credentialsPath, deleteCredentials, parseArgs, resolveApiKey } from '../lib/credentials.js';
33
+ import { decorateAuthRequired, runClaimFlow } from '../lib/claim.js';
22
34
 
23
35
  // Keep in sync with package.json on each release
24
36
  const PKG_NAME = '@prereason/mcp';
25
- const PKG_VERSION = '0.3.1';
37
+ const PKG_VERSION = '0.4.0';
26
38
  const DEFAULT_URL = 'https://api.prereason.com/api/mcp';
39
+ const USER_AGENT = `prereason-mcp/${PKG_VERSION} node/${process.versions.node} (${platform()})`;
27
40
 
28
- // --- Help / Version ---
29
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
41
+ const args = parseArgs(process.argv.slice(2));
42
+
43
+ if (args.help) {
30
44
  process.stderr.write(`${PKG_NAME} v${PKG_VERSION}\n\n`);
31
45
  process.stderr.write('Usage:\n');
32
- process.stderr.write(' npx @prereason/mcp\n');
33
- process.stderr.write(' npx @prereason/mcp [--header Key:Value]...\n\n');
34
- process.stderr.write('Environment variables (recommended):\n');
35
- process.stderr.write(' PREREASON_API_KEY Your API key (adds Authorization: Bearer header)\n');
36
- process.stderr.write(' PREREASON_URL Override the default endpoint URL\n\n');
46
+ process.stderr.write(' npx @prereason/mcp run the bridge (asks for access when no key is configured)\n');
47
+ process.stderr.write(' npx @prereason/mcp --login request access now, save the key, exit\n');
48
+ process.stderr.write(' npx @prereason/mcp --logout delete the saved key, exit\n');
49
+ process.stderr.write(' npx @prereason/mcp [--header Key:Value]... [--credentials-file <path>] [<URL>]\n\n');
50
+ process.stderr.write('Environment variables:\n');
51
+ process.stderr.write(' PREREASON_API_KEY Your API key (adds Authorization: Bearer header)\n');
52
+ process.stderr.write(' PREREASON_URL Override the default endpoint URL\n');
53
+ process.stderr.write(' PREREASON_CREDENTIALS_FILE Where a claimed key is kept (default ~/.prereason/credentials.json)\n');
54
+ process.stderr.write(' PREREASON_CLIENT The app this bridge runs in, e.g. claude-desktop\n\n');
37
55
  process.stderr.write('Options:\n');
38
- process.stderr.write(' --header Key:Value Add HTTP header (can be repeated)\n');
39
- process.stderr.write(' --help, -h Show this help\n');
40
- process.stderr.write(' --version, -v Show version\n\n');
56
+ process.stderr.write(' --header Key:Value Add an HTTP header (can be repeated)\n');
57
+ process.stderr.write(' --credentials-file <path> Use this credentials file\n');
58
+ process.stderr.write(' --login Ask for access now and save the key\n');
59
+ process.stderr.write(' --logout Forget the saved key\n');
60
+ process.stderr.write(' --help, -h Show this help\n');
61
+ process.stderr.write(' --version, -v Show version\n\n');
41
62
  process.stderr.write(`Default URL: ${DEFAULT_URL}\n`);
42
63
  process.exit(0);
43
64
  }
44
65
 
45
- if (process.argv.includes('--version') || process.argv.includes('-v')) {
66
+ if (args.version) {
46
67
  process.stderr.write(`${PKG_VERSION}\n`);
47
68
  process.exit(0);
48
69
  }
49
70
 
50
- // --- Read environment variables (preferred, avoids Windows cmd.exe quoting issues) ---
51
- let urlArg = process.env.PREREASON_URL || DEFAULT_URL;
52
- const headers = {};
71
+ // --- Resolve the endpoint, the headers and the key ---
72
+ const url = new URL(args.url || process.env.PREREASON_URL || DEFAULT_URL);
73
+ const credentialsFile = credentialsPath({ env: process.env, flag: args.credentialsFile });
53
74
 
54
- if (process.env.PREREASON_API_KEY) {
55
- headers['Authorization'] = `Bearer ${process.env.PREREASON_API_KEY}`;
75
+ if (args.logout) {
76
+ const existed = deleteCredentials(credentialsFile);
77
+ process.stderr.write(existed ? `PreReason: removed ${credentialsFile}\n` : `PreReason: nothing saved at ${credentialsFile}\n`);
78
+ process.exit(0);
56
79
  }
57
80
 
58
- // --- Parse CLI arguments (override env vars) ---
59
- for (let i = 2; i < process.argv.length; i++) {
60
- if (process.argv[i] === '--header' && process.argv[i + 1]) {
61
- const val = process.argv[++i];
62
- const colonIdx = val.indexOf(':');
63
- if (colonIdx > 0) {
64
- headers[val.slice(0, colonIdx).trim()] = val.slice(colonIdx + 1).trim();
65
- }
66
- } else if (!process.argv[i].startsWith('-')) {
67
- urlArg = process.argv[i];
68
- }
81
+ /** Headers every request carries. The claim flow adds Authorization to this same object once a key arrives. */
82
+ const headers = { 'User-Agent': USER_AGENT, ...args.headers };
83
+
84
+ // Which app this bridge runs in, forwarded as X-PreReason-Client so the
85
+ // dashboard can name the connection. Only the documented ids are useful, and
86
+ // only a header safe value is ever sent.
87
+ const clientApp = /^[a-z0-9-]{1,32}$/.test(process.env.PREREASON_CLIENT || '') ? process.env.PREREASON_CLIENT : null;
88
+ if (clientApp) headers['X-PreReason-Client'] = clientApp;
89
+
90
+ const resolved = resolveApiKey({ env: process.env, headers: args.headers, credentialsFile });
91
+ if (resolved.key && !Object.keys(headers).some((h) => h.toLowerCase() === 'authorization')) {
92
+ headers.Authorization = `Bearer ${resolved.key}`;
69
93
  }
70
94
 
71
- const url = new URL(urlArg);
95
+ const claimContext = {
96
+ mcpUrl: url.toString(),
97
+ headers,
98
+ clientName: `${PKG_NAME} ${PKG_VERSION} (${platform()})`,
99
+ purpose: clientApp ? `MCP bridge for ${clientApp}` : 'MCP bridge on this computer',
100
+ credentialsFile,
101
+ requestHeaders: clientApp ? { 'user-agent': USER_AGENT, 'x-prereason-client': clientApp } : { 'user-agent': USER_AGENT },
102
+ };
72
103
 
73
- // --- Create transports ---
104
+ // --- --login: run the claim flow in the foreground and exit ---
105
+ if (args.login) {
106
+ if (resolved.key && resolved.source !== 'file') {
107
+ process.stderr.write(`PreReason: a key is already configured through ${resolved.source === 'env' ? 'PREREASON_API_KEY' : '--header'}; --login is for the credentials file. Nothing to do.\n`);
108
+ process.exit(0);
109
+ }
110
+ const outcome = await runClaimFlow(claimContext);
111
+ process.exit(outcome.outcome === 'approved' ? 0 : 1);
112
+ }
113
+
114
+ // --- Transports: stdio to the host, Streamable HTTP to PreReason ---
74
115
  const stdio = new StdioServerTransport();
75
- const http = new StreamableHTTPClientTransport(url, {
76
- requestInit: Object.keys(headers).length > 0 ? { headers } : undefined,
77
- });
116
+ const http = new StreamableHTTPClientTransport(url, { requestInit: { headers } });
117
+
118
+ /** While a claim is pending, the approve link the tool results carry. */
119
+ const pending = { approveUrl: null, claimCode: null };
78
120
 
79
- // --- Wire message routing ---
80
121
  stdio.onmessage = (msg) => {
81
122
  http.send(msg).catch((e) => {
82
123
  process.stderr.write(`[prereason:send] ${e.message}\n`);
@@ -84,16 +125,14 @@ stdio.onmessage = (msg) => {
84
125
  };
85
126
 
86
127
  http.onmessage = (msg) => {
87
- stdio.send(msg).catch((e) => {
128
+ stdio.send(decorateAuthRequired(msg, pending.approveUrl)).catch((e) => {
88
129
  process.stderr.write(`[prereason:recv] ${e.message}\n`);
89
130
  });
90
131
  };
91
132
 
92
- // --- Error handling ---
93
133
  stdio.onerror = (e) => process.stderr.write(`[prereason:stdio] ${e.message}\n`);
94
134
  http.onerror = (e) => process.stderr.write(`[prereason:http] ${e.message}\n`);
95
135
 
96
- // --- Graceful shutdown ---
97
136
  stdio.onclose = () => {
98
137
  http.close();
99
138
  process.exit(0);
@@ -104,6 +143,15 @@ http.onclose = () => {
104
143
  process.exit(0);
105
144
  };
106
145
 
107
- // --- Start ---
146
+ // Start serving first: the host must never see "server disconnected" because
147
+ // a claim is waiting on a person. The free tools work without a key.
108
148
  await http.start();
109
149
  await stdio.start();
150
+
151
+ if (!resolved.key) {
152
+ // Fire and forget: the flow logs its own lines and attaches the key to
153
+ // `headers` when the person approves, which the transport reads per request.
154
+ runClaimFlow({ ...claimContext, state: pending }).catch((e) => {
155
+ process.stderr.write(`[prereason:claim] ${e.message}\n`);
156
+ });
157
+ }
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/package.json CHANGED
@@ -1,24 +1,26 @@
1
1
  {
2
2
  "name": "@prereason/mcp",
3
- "version": "0.3.1",
4
- "mcpName": "io.github.PreReason/mcp",
3
+ "version": "0.4.0",
4
+ "mcpName": "com.prereason/mcp",
5
5
  "type": "module",
6
- "description": "MCP server for PreReason the Context API for financial agents. 17 pre-reasoned market briefings with trend signals, regime classification, confidence scores, and cross-asset correlations.",
6
+ "description": "Bitcoin and macro market briefings for AI agents: trend signals, regimes, liquidity and ETF flows.",
7
7
  "bin": {
8
8
  "prereason-mcp": "./bin/cli.js"
9
9
  },
10
10
  "scripts": {
11
- "build": "echo 'Build complete'"
11
+ "build": "echo 'Build complete'",
12
+ "test": "node --test test/credentials.test.js test/claim.test.js"
12
13
  },
13
14
  "files": [
14
15
  "bin",
16
+ "lib",
15
17
  "README.md",
16
18
  "LICENSE",
17
19
  "CHANGELOG.md",
18
20
  ".mcp.json"
19
21
  ],
20
22
  "dependencies": {
21
- "@modelcontextprotocol/sdk": "1.27.1"
23
+ "@modelcontextprotocol/sdk": "1.30.0"
22
24
  },
23
25
  "engines": {
24
26
  "node": ">=18"
@@ -46,7 +48,7 @@
46
48
  "url": "https://www.prereason.com"
47
49
  },
48
50
  "license": "MIT",
49
- "homepage": "https://www.prereason.com/docs#mcp",
51
+ "homepage": "https://www.prereason.com/docs#mcp-integration",
50
52
  "repository": {
51
53
  "type": "git",
52
54
  "url": "git+https://github.com/PreReason/mcp.git"