@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 +25 -0
- package/README.md +61 -51
- package/bin/cli.js +95 -47
- package/lib/claim.js +233 -0
- package/lib/credentials.js +152 -0
- package/package.json +8 -6
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
|
[](https://opensource.org/licenses/MIT)
|
|
9
9
|
[](https://glama.ai/mcp/servers/PreReason/mcp)
|
|
10
10
|
|
|
11
|
-
**MCP server for [PreReason](https://www.prereason.com)
|
|
11
|
+
**MCP server for [PreReason](https://www.prereason.com).**
|
|
12
12
|
|
|
13
|
-
|
|
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:
|
|
21
|
+
### Option 1: Claude Desktop, no key needed
|
|
22
|
+
|
|
23
|
+
**Requires [Node.js 18+](https://nodejs.org)**
|
|
21
24
|
|
|
22
|
-
|
|
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
|
-
"
|
|
29
|
-
"
|
|
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
|
-
|
|
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
|
|
48
|
+
### Option 2: Direct HTTP with an API key (Claude Code, Cursor, Windsurf, Codex, Gemini CLI, VS Code, scripts)
|
|
38
49
|
|
|
39
|
-
|
|
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
|
|
43
|
-
claude mcp add
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
### Option 3: Claude.ai and Claude Desktop custom connector
|
|
65
72
|
|
|
66
|
-
Add
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
85
|
+
## 7 MCP Tools
|
|
95
86
|
|
|
96
87
|
| Tool | Auth | Description |
|
|
97
88
|
|------|------|-------------|
|
|
98
|
-
| `list_briefings` | Open | List all
|
|
99
|
-
| `list_metrics` | Open | List
|
|
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
|
-
##
|
|
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 (
|
|
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
|
-
#
|
|
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
|
-
#
|
|
196
|
-
npx @prereason/mcp
|
|
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
|
-
#
|
|
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
|
|
3
|
+
* @prereason/mcp: the stdio bridge to PreReason's Streamable HTTP MCP endpoint.
|
|
4
4
|
*
|
|
5
|
-
* Connects
|
|
5
|
+
* Connects a stdio only client (Claude Desktop and others) to
|
|
6
|
+
* https://api.prereason.com/api/mcp.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
13
|
-
* npx @prereason/mcp
|
|
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
|
|
16
|
-
* PREREASON_API_KEY
|
|
17
|
-
* PREREASON_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.
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
34
|
-
process.stderr.write('
|
|
35
|
-
process.stderr.write('
|
|
36
|
-
process.stderr.write('
|
|
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
|
|
39
|
-
process.stderr.write(' --
|
|
40
|
-
process.stderr.write(' --
|
|
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 (
|
|
66
|
+
if (args.version) {
|
|
46
67
|
process.stderr.write(`${PKG_VERSION}\n`);
|
|
47
68
|
process.exit(0);
|
|
48
69
|
}
|
|
49
70
|
|
|
50
|
-
// ---
|
|
51
|
-
|
|
52
|
-
const
|
|
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 (
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
// ---
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
4
|
-
"mcpName": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"mcpName": "com.prereason/mcp",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "
|
|
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.
|
|
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"
|