brainchain-mcp 0.4.4 → 0.5.1
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/README.md +32 -17
- package/dist/config.js +34 -4
- package/dist/daily.js +36 -8
- package/dist/index.js +18 -17
- package/dist/payments.js +68 -42
- package/dist/server.js +58 -15
- package/dist/tools.js +4 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Read the key without echoing it or leaving it in your shell history, then add th
|
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo
|
|
19
|
-
claude mcp add brainchain --scope user -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.
|
|
19
|
+
claude mcp add brainchain --scope user -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.5.1
|
|
20
20
|
unset BRAINCHAIN_PAYER_KEY
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -27,7 +27,7 @@ unset BRAINCHAIN_PAYER_KEY
|
|
|
27
27
|
"mcpServers": {
|
|
28
28
|
"brainchain": {
|
|
29
29
|
"command": "npx",
|
|
30
|
-
"args": ["-y", "brainchain-mcp@0.
|
|
30
|
+
"args": ["-y", "brainchain-mcp@0.5.1"],
|
|
31
31
|
"env": { "BRAINCHAIN_PAYER_KEY": "${BRAINCHAIN_PAYER_KEY}" }
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -36,7 +36,7 @@ unset BRAINCHAIN_PAYER_KEY
|
|
|
36
36
|
|
|
37
37
|
### Cursor
|
|
38
38
|
|
|
39
|
-
[](https://cursor.com/en/install-mcp?name=brainchain&config=
|
|
39
|
+
[](https://cursor.com/en/install-mcp?name=brainchain&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJyYWluY2hhaW4tbWNwQDAuNS4xIl0sImVudiI6eyJCUkFJTkNIQUlOX1BBWUVSX0tFWSI6IiR7ZW52OkJSQUlOQ0hBSU5fUEFZRVJfS0VZfSJ9fQ%3D%3D)
|
|
40
40
|
|
|
41
41
|
Or add the server to a project's `.cursor/mcp.json` (`~/.cursor/mcp.json` for every project):
|
|
42
42
|
|
|
@@ -45,7 +45,7 @@ Or add the server to a project's `.cursor/mcp.json` (`~/.cursor/mcp.json` for ev
|
|
|
45
45
|
"mcpServers": {
|
|
46
46
|
"brainchain": {
|
|
47
47
|
"command": "npx",
|
|
48
|
-
"args": ["-y", "brainchain-mcp@0.
|
|
48
|
+
"args": ["-y", "brainchain-mcp@0.5.1"],
|
|
49
49
|
"env": { "BRAINCHAIN_PAYER_KEY": "${env:BRAINCHAIN_PAYER_KEY}" }
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -63,7 +63,7 @@ Add the server to the client's `mcpServers` configuration. For Claude Desktop th
|
|
|
63
63
|
"mcpServers": {
|
|
64
64
|
"brainchain": {
|
|
65
65
|
"command": "npx",
|
|
66
|
-
"args": ["-y", "brainchain-mcp@0.
|
|
66
|
+
"args": ["-y", "brainchain-mcp@0.5.1"],
|
|
67
67
|
"env": { "BRAINCHAIN_PAYER_KEY": "0xYOUR_KEY" }
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -82,7 +82,7 @@ These clients keep the key in that file as plain text.
|
|
|
82
82
|
"brainchain": {
|
|
83
83
|
"type": "stdio",
|
|
84
84
|
"command": "npx",
|
|
85
|
-
"args": ["-y", "brainchain-mcp@0.
|
|
85
|
+
"args": ["-y", "brainchain-mcp@0.5.1"],
|
|
86
86
|
"env": { "BRAINCHAIN_PAYER_KEY": "${input:brainchain-key}" }
|
|
87
87
|
}
|
|
88
88
|
},
|
|
@@ -94,11 +94,16 @@ These clients keep the key in that file as plain text.
|
|
|
94
94
|
|
|
95
95
|
### OpenClaw
|
|
96
96
|
|
|
97
|
+
Save the key in a file only your account can read, then point the server at it:
|
|
98
|
+
|
|
97
99
|
```sh
|
|
98
|
-
|
|
100
|
+
mkdir -p ~/.brainchain
|
|
101
|
+
(umask 077; printf 'Key: ' && read -rs key && echo && printf '%s\n' "$key" > ~/.brainchain/payer.key)
|
|
102
|
+
chmod 600 ~/.brainchain/payer.key
|
|
103
|
+
openclaw mcp set brainchain '{"command":"npx","args":["-y","brainchain-mcp@0.5.1"],"env":{"BRAINCHAIN_PAYER_KEY_FILE":"~/.brainchain/payer.key"}}'
|
|
99
104
|
```
|
|
100
105
|
|
|
101
|
-
|
|
106
|
+
The config holds only the path, and the key never enters OpenClaw's environment, which the agent's shell inherits. A key saved in `skills.entries.brainchain.apiKey` does not reach the server. `BRAINCHAIN_PAYER_KEY_FILE=~/.brainchain/payer.key npx -y brainchain-mcp@0.5.1 wallet` checks the file and prints the wallet it holds. [`examples/openclaw/brainchain`](examples/openclaw/brainchain) is an OpenClaw skill that tells the agent when to hand work to BrainChain, how to write the brief and levels, and checks the wallet before it pays.
|
|
102
107
|
|
|
103
108
|
Each setup above pays in USDG on Robinhood Chain. To pay in USDC on Base instead, add `BRAINCHAIN_PAY_ON=base` to the server's environment (`-e BRAINCHAIN_PAY_ON=base` in Claude Code).
|
|
104
109
|
|
|
@@ -112,7 +117,7 @@ Each brief is posted on BrainChain and each result is served at a public link. K
|
|
|
112
117
|
|
|
113
118
|
```bash
|
|
114
119
|
printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo && export BRAINCHAIN_PAYER_KEY
|
|
115
|
-
npx -y brainchain-mcp@0.
|
|
120
|
+
npx -y brainchain-mcp@0.5.1 approve
|
|
116
121
|
unset BRAINCHAIN_PAYER_KEY
|
|
117
122
|
```
|
|
118
123
|
|
|
@@ -122,21 +127,24 @@ This approves 100 USDG. Pass another amount or `max` as the last argument, or `0
|
|
|
122
127
|
|
|
123
128
|
Use a dedicated wallet that holds only what you plan to spend. The key stays on your machine: the server signs payments with it locally and never sends it anywhere.
|
|
124
129
|
|
|
130
|
+
An agent with a shell on the same account can read whatever that account can, including the key, wherever it is kept. The server tells agents to leave it alone, and nothing enforces that. A key taken that way is outside every limit below. So keep little in the wallet, prefer `BRAINCHAIN_PAYER_KEY_FILE` to an exported variable, since a shell inherits variables, and run the agent's shell in a sandbox where your client offers one. In OpenClaw that is `agents.defaults.sandbox.mode`.
|
|
131
|
+
|
|
125
132
|
## Tools
|
|
126
133
|
|
|
127
134
|
| Tool | What it does |
|
|
128
135
|
| --- | --- |
|
|
129
136
|
| `brainchain_post_task` | Pays for a task and returns an order id, the price paid and a `statusUrl`. Takes a `title` of up to 120 characters, a `brief` of up to 32,000 characters and two or three quality `levels` of up to 200 characters each, best first. With three levels the work is paid at the second level or better; with two, only at the first. Models cannot browse, so put every fact they need in the brief. |
|
|
130
|
-
| `brainchain_get_task` | Takes the `order` id and returns the payment (`network`, `paymentTx`), the order's `state` and the task's status on BrainChain (`task.status`, which appears shortly after the order reads `posted`), whether Jev accepted the work, the result text once it did, and the refund transaction if it did not. `done` says when to stop asking and `retryAfterSeconds` when to ask again. |
|
|
137
|
+
| `brainchain_get_task` | Takes the `order` id and returns the payment (`network`, `paymentTx`), the order's `state` and the task's status on BrainChain (`task.status`, which appears shortly after the order reads `posted`), whether Jev accepted the work, the result text once it did, and the refund transaction if it did not. `done` says when to stop asking and `retryAfterSeconds` when to ask again. A rejected task stays not done until the refund is sent, so `next` gives the verdict first and `retryAfterSeconds` runs to the close of the dispute window. |
|
|
131
138
|
|
|
132
139
|
Results usually arrive within minutes. A post reply with `possiblyCharged` means the payment was signed but the desk never confirmed the order, because the connection dropped or the desk returned an unexpected status. The payment may have gone through, so read the order with `brainchain_get_task` rather than posting again. While the server keeps running, an identical post within 15 minutes returns the first order, marked `duplicate`, instead of paying twice.
|
|
133
140
|
|
|
134
141
|
## What a task costs
|
|
135
142
|
|
|
136
|
-
- 1 USDG or 1 USDC, taken when the task is posted. The server never pays more per task than `BRAINCHAIN_MAX_PRICE_UNITS` (1 USDG or USDC by default), never more in total than `BRAINCHAIN_BUDGET_UNITS` (10 USDG or USDC by default), and only to the BrainChain payment desk.
|
|
137
|
-
- If Jev accepts the work, you get the result and the model receives the payment minus BrainChain's 3%
|
|
143
|
+
- 1 USDG or 1 USDC, taken when the task is posted. The server never pays more per task than `BRAINCHAIN_MAX_PRICE_UNITS` (1 USDG or USDC by default), never more in total than `BRAINCHAIN_BUDGET_UNITS` (10 USDG or USDC by default), and only to the BrainChain payment desk. Each server keeps its own budget, which resets whenever it restarts, including when your MCP client restarts it. For an agent that runs unattended, `BRAINCHAIN_DAILY_UNITS` sets a limit per UTC day that holds across restarts, and across servers paying from the same wallet on either chain, as long as they share a `BRAINCHAIN_STATE_DIR`.
|
|
144
|
+
- If Jev accepts the work, you get the result and the model receives the payment minus BrainChain's protocol fee (3% on mainnet).
|
|
138
145
|
- If Jev rejects the work, or no model delivers within 24 hours, you get no result and the full amount goes back to the paying wallet on the chain it paid on.
|
|
139
146
|
- The payment or refund is final about six hours after grading (about an hour on testnet), when the dispute window closes.
|
|
147
|
+
- The wallet that paid cannot dispute a grade. On chain the poster of a task paid this way is BrainChain's payment desk, and the desk does not dispute for payers. The model that delivered can dispute a rejection while the window is open, and a ruling can then pay it in place of the refund.
|
|
140
148
|
|
|
141
149
|
Every hire and grade is recorded on BrainChain and listed at [usebrainchain.com/decisions](https://usebrainchain.com/decisions).
|
|
142
150
|
|
|
@@ -145,15 +153,16 @@ Every hire and grade is recorded on BrainChain and listed at [usebrainchain.com/
|
|
|
145
153
|
| Variable | Default | Purpose |
|
|
146
154
|
| --- | --- | --- |
|
|
147
155
|
| `BRAINCHAIN_PAYER_KEY` | required to post | Private key of the paying wallet: 64 hex characters, with or without `0x`. Without it the server still starts and reads orders, and `brainchain_post_task` explains how to set it. |
|
|
156
|
+
| `BRAINCHAIN_PAYER_KEY_FILE` | none | The path of a file holding that key, in place of `BRAINCHAIN_PAYER_KEY`: absolute or starting with `~/`, and readable only by your account. |
|
|
148
157
|
| `BRAINCHAIN_NETWORK` | `mainnet` | `testnet` switches every setting to BrainChain testnet. |
|
|
149
158
|
| `BRAINCHAIN_PAY_ON` | `robinhood` | `base` pays in USDC on Base (Base Sepolia on testnet) instead of USDG on Robinhood Chain. |
|
|
150
159
|
| `BRAINCHAIN_MAX_PRICE_UNITS` | `1000000` | Maximum paid per task, in base units (6 decimals, so 1000000 is 1 USDG). |
|
|
151
160
|
| `BRAINCHAIN_BUDGET_UNITS` | `10000000` | Maximum paid in total while the server runs, in base units (10000000 is 10 USDG). |
|
|
152
161
|
| `BRAINCHAIN_DAILY_UNITS` | no limit | Maximum the wallet pays per UTC day, in base units. The running total is kept on disk, so restarting the server does not reset it. A payment signed just before a crash stays counted until 00:00 UTC, even if it was never taken. |
|
|
153
|
-
| `BRAINCHAIN_STATE_DIR` | `~/.brainchain` | Where the daily total is kept, one file per network and wallet
|
|
154
|
-
| `BRAINCHAIN_DESK_URL`, `BRAINCHAIN_PAY_TO`, `BRAINCHAIN_PAYMENT_NETWORK`, `BRAINCHAIN_PAYMENT_ASSET`, `BRAINCHAIN_PAYMENT_RPC` | network preset | Override a single setting. A custom desk needs its own `BRAINCHAIN_PAY_TO
|
|
162
|
+
| `BRAINCHAIN_STATE_DIR` | `~/.brainchain` | Where the daily total is kept, one file per network and wallet: absolute or starting with `~/`. |
|
|
163
|
+
| `BRAINCHAIN_DESK_URL`, `BRAINCHAIN_PAY_TO`, `BRAINCHAIN_PAYMENT_NETWORK`, `BRAINCHAIN_PAYMENT_ASSET`, `BRAINCHAIN_PAYMENT_RPC` | network preset | Override a single setting. A custom desk needs its own `BRAINCHAIN_PAY_TO`, and an https URL unless it is on localhost or 127.0.0.1. |
|
|
155
164
|
|
|
156
|
-
If a setting is invalid, the server says so once on stderr when it starts, and both tools return the error. `npx -y brainchain-mcp@0.
|
|
165
|
+
If a setting is invalid, the server says so once on stderr when it starts, and both tools return the error. A key it cannot use is reported the same way, except that `brainchain_get_task` needs no key and keeps working. `npx -y brainchain-mcp@0.5.1 wallet` checks the settings and the key, and prints the wallet that pays. `--help` lists the commands and variables, and `--version` prints the version.
|
|
157
166
|
|
|
158
167
|
## Try it on testnet
|
|
159
168
|
|
|
@@ -161,7 +170,7 @@ Set `BRAINCHAIN_NETWORK=testnet` in the server's environment. Test tokens have n
|
|
|
161
170
|
|
|
162
171
|
```bash
|
|
163
172
|
printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo
|
|
164
|
-
claude mcp add brainchain --scope user -e BRAINCHAIN_NETWORK=testnet -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.
|
|
173
|
+
claude mcp add brainchain --scope user -e BRAINCHAIN_NETWORK=testnet -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.5.1
|
|
165
174
|
unset BRAINCHAIN_PAYER_KEY
|
|
166
175
|
```
|
|
167
176
|
|
|
@@ -176,7 +185,13 @@ unset BRAINCHAIN_PAYER_KEY
|
|
|
176
185
|
cast send 0x1eC185467cA2D5d1c24f0EdCB91301Bca4d0a787 "faucet()" --rpc-url https://rpc.testnet.chain.robinhood.com --interactive
|
|
177
186
|
```
|
|
178
187
|
|
|
179
|
-
3. Approve Permit2
|
|
188
|
+
3. Approve Permit2:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo && export BRAINCHAIN_PAYER_KEY
|
|
192
|
+
BRAINCHAIN_NETWORK=testnet npx -y brainchain-mcp@0.5.1 approve
|
|
193
|
+
unset BRAINCHAIN_PAYER_KEY
|
|
194
|
+
```
|
|
180
195
|
|
|
181
196
|
## Links
|
|
182
197
|
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
3
|
const MAINNET_DESK = { desk: "https://brain.usebrainchain.com", payTo: "0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E", finalAfter: "about six hours" };
|
|
4
4
|
const TESTNET_DESK = { desk: "https://brain.testnet.usebrainchain.com", payTo: "0xf848984c8A0D9E49Cc9C97380C62b34C6f3b97D1", finalAfter: "about an hour" };
|
|
5
5
|
const PRESETS = {
|
|
@@ -78,6 +78,36 @@ function envAddress(env, name, fallback) {
|
|
|
78
78
|
throw new Error(`${name} must be a 0x-prefixed, 40-character hex address.`);
|
|
79
79
|
return value;
|
|
80
80
|
}
|
|
81
|
+
/** MCP clients start servers without a shell, so nothing else expands the ~ in a config file. */
|
|
82
|
+
export function expandHome(path) {
|
|
83
|
+
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
84
|
+
}
|
|
85
|
+
/** A relative path would follow whatever directory the client starts in, and a daily limit kept there would reset without a word. */
|
|
86
|
+
function envDirectory(env, name, fallback) {
|
|
87
|
+
const raw = envString(env, name);
|
|
88
|
+
if (raw === undefined)
|
|
89
|
+
return fallback;
|
|
90
|
+
const path = expandHome(raw);
|
|
91
|
+
if (!isAbsolute(path))
|
|
92
|
+
throw new Error(`${name} must be an absolute path or start with ~/.`);
|
|
93
|
+
return path;
|
|
94
|
+
}
|
|
95
|
+
/** The value is never echoed: an RPC URL can carry a key. */
|
|
96
|
+
function envUrl(env, name, fallback) {
|
|
97
|
+
const value = envString(env, name) ?? fallback;
|
|
98
|
+
if (!URL.canParse(value) || !["http:", "https:"].includes(new URL(value).protocol))
|
|
99
|
+
throw new Error(`${name} must be an http or https URL.`);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
/** Signed payments travel to the desk, so plain http is allowed only where they never leave the machine. */
|
|
103
|
+
function envDesk(env, fallback) {
|
|
104
|
+
const desk = envUrl(env, "BRAINCHAIN_DESK_URL", fallback).replace(/\/+$/, "");
|
|
105
|
+
const { protocol, hostname } = new URL(desk);
|
|
106
|
+
if (protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(hostname)) {
|
|
107
|
+
throw new Error("BRAINCHAIN_DESK_URL must be an https URL, or http on localhost or 127.0.0.1.");
|
|
108
|
+
}
|
|
109
|
+
return desk;
|
|
110
|
+
}
|
|
81
111
|
export function resolveConfig(env) {
|
|
82
112
|
const name = (envString(env, "BRAINCHAIN_NETWORK") ?? "mainnet").toLowerCase();
|
|
83
113
|
if (name !== "mainnet" && name !== "testnet")
|
|
@@ -101,18 +131,18 @@ export function resolveConfig(env) {
|
|
|
101
131
|
name,
|
|
102
132
|
payOn,
|
|
103
133
|
permit2: preset.permit2,
|
|
104
|
-
desk: (
|
|
134
|
+
desk: envDesk(env, preset.desk),
|
|
105
135
|
network: network,
|
|
106
136
|
chainId: Number(match[1]),
|
|
107
137
|
asset: envAddress(env, "BRAINCHAIN_PAYMENT_ASSET", preset.asset),
|
|
108
138
|
payTo: envAddress(env, "BRAINCHAIN_PAY_TO", preset.payTo),
|
|
109
|
-
rpc:
|
|
139
|
+
rpc: envUrl(env, "BRAINCHAIN_PAYMENT_RPC", preset.rpc),
|
|
110
140
|
chain: preset.chain,
|
|
111
141
|
token: preset.token,
|
|
112
142
|
maxPrice,
|
|
113
143
|
budget,
|
|
114
144
|
...(daily !== undefined ? { daily } : {}),
|
|
115
|
-
stateDir:
|
|
145
|
+
stateDir: envDirectory(env, "BRAINCHAIN_STATE_DIR", join(homedir(), ".brainchain")),
|
|
116
146
|
terms: {
|
|
117
147
|
cost: maxPrice === DEFAULT_MAX_PRICE ? `1 ${preset.token}` : `at most ${formatUnits(maxPrice)} ${preset.token}`,
|
|
118
148
|
chain: preset.chain,
|
package/dist/daily.js
CHANGED
|
@@ -4,10 +4,41 @@ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/prom
|
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
const LOCK_STALE_MS = 10_000;
|
|
6
6
|
const LOCK_WAIT_MS = 5_000;
|
|
7
|
+
/** Clearing a lock takes a few system calls, so a marker older than this was left by a crash. */
|
|
8
|
+
const TAKEOVER_STALE_MS = 5_000;
|
|
7
9
|
/** The UTC date, which is when the limit resets. */
|
|
8
10
|
export function utcDay(now) {
|
|
9
11
|
return new Date(now).toISOString().slice(0, 10);
|
|
10
12
|
}
|
|
13
|
+
/** How long ago a file was last written, or 0 when it is gone. */
|
|
14
|
+
const ageOf = (file) => stat(file).then((s) => Date.now() - s.mtimeMs, () => 0);
|
|
15
|
+
/**
|
|
16
|
+
* Removes a lock left by a process that died mid-write. The marker lets one
|
|
17
|
+
* process at a time judge and remove it, since removing a lock another process
|
|
18
|
+
* has just taken would give the lock to both.
|
|
19
|
+
*/
|
|
20
|
+
async function clearStale(lockPath) {
|
|
21
|
+
const marker = `${lockPath}.takeover`;
|
|
22
|
+
try {
|
|
23
|
+
await (await open(marker, "wx")).close();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== "EEXIST")
|
|
27
|
+
throw error;
|
|
28
|
+
if ((await ageOf(marker)) >= TAKEOVER_STALE_MS)
|
|
29
|
+
await rm(marker, { force: true });
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
if ((await ageOf(lockPath)) <= LOCK_STALE_MS)
|
|
34
|
+
return false;
|
|
35
|
+
await rm(lockPath, { force: true });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await rm(marker, { force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
11
42
|
export function dailyLedger(path, limit, now = Date.now) {
|
|
12
43
|
const lockPath = `${path}.lock`;
|
|
13
44
|
async function locked(work) {
|
|
@@ -21,15 +52,12 @@ export function dailyLedger(path, limit, now = Date.now) {
|
|
|
21
52
|
catch (error) {
|
|
22
53
|
if (error.code !== "EEXIST")
|
|
23
54
|
throw error;
|
|
24
|
-
// A lock left by a process that died mid-write.
|
|
25
|
-
const age = await stat(lockPath).then((s) => Date.now() - s.mtimeMs, () => 0);
|
|
26
|
-
if (age > LOCK_STALE_MS)
|
|
27
|
-
await rm(lockPath, { force: true });
|
|
28
|
-
else if (Date.now() > deadline)
|
|
29
|
-
throw new Error(`The daily spending record at ${path} is locked by another process; try again`);
|
|
30
|
-
else
|
|
31
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
32
55
|
}
|
|
56
|
+
if ((await ageOf(lockPath)) > LOCK_STALE_MS && (await clearStale(lockPath)))
|
|
57
|
+
continue;
|
|
58
|
+
if (Date.now() > deadline)
|
|
59
|
+
throw new Error(`The daily spending record at ${path} is locked by another process; try again`);
|
|
60
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
33
61
|
}
|
|
34
62
|
try {
|
|
35
63
|
return await work();
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { createWalletClient, http, maxUint256 } from "viem";
|
|
5
5
|
import { DECIMALS, formatUnits, resolveConfig } from "./config.js";
|
|
6
|
-
import { chainClients, createServer, erc20, payerAccount, PERMIT2 } from "./server.js";
|
|
6
|
+
import { approvalFailure, chainClients, createServer, erc20, payerAccount, PERMIT2 } from "./server.js";
|
|
7
7
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
8
8
|
const USAGE = `brainchain-mcp ${version}
|
|
9
9
|
|
|
@@ -14,17 +14,19 @@ Usage:
|
|
|
14
14
|
brainchain-mcp approve [USDG] Let Permit2 spend the paying wallet's USDG.
|
|
15
15
|
Default 100 USDG; pass a number, "max", or 0 to revoke.
|
|
16
16
|
Not needed when paying in USDC on Base.
|
|
17
|
+
brainchain-mcp wallet Check the key and settings, and print the wallet that pays.
|
|
17
18
|
|
|
18
19
|
Environment:
|
|
19
20
|
BRAINCHAIN_PAYER_KEY Private key of the paying wallet, 64 hex characters with or without 0x.
|
|
20
21
|
Needed to post tasks and to approve; reading orders works without it.
|
|
22
|
+
BRAINCHAIN_PAYER_KEY_FILE A file holding that key instead, readable only by your account. Absolute or starting with ~/.
|
|
21
23
|
BRAINCHAIN_NETWORK mainnet (default) or testnet.
|
|
22
24
|
BRAINCHAIN_PAY_ON robinhood (default) pays in USDG on Robinhood Chain;
|
|
23
25
|
base pays in USDC on Base (Base Sepolia on testnet).
|
|
24
26
|
BRAINCHAIN_MAX_PRICE_UNITS Maximum paid per task, in base units (default 1000000 = 1 USDG or USDC).
|
|
25
27
|
BRAINCHAIN_BUDGET_UNITS Maximum paid in total while the server runs (default 10000000 = 10 USDG or USDC).
|
|
26
|
-
BRAINCHAIN_DAILY_UNITS Maximum the wallet pays per UTC day, kept across restarts (default: no daily limit).
|
|
27
|
-
BRAINCHAIN_STATE_DIR Where the daily limit's record is kept (default ~/.brainchain).
|
|
28
|
+
BRAINCHAIN_DAILY_UNITS Maximum the wallet pays per UTC day, on either chain, kept across restarts (default: no daily limit).
|
|
29
|
+
BRAINCHAIN_STATE_DIR Where the daily limit's record is kept (default ~/.brainchain). Absolute or starting with ~/.
|
|
28
30
|
BRAINCHAIN_DESK_URL, BRAINCHAIN_PAY_TO, BRAINCHAIN_PAYMENT_NETWORK, BRAINCHAIN_PAYMENT_ASSET, BRAINCHAIN_PAYMENT_RPC
|
|
29
31
|
Advanced overrides of the network preset.
|
|
30
32
|
`;
|
|
@@ -33,7 +35,8 @@ function fail(message) {
|
|
|
33
35
|
process.exit(1);
|
|
34
36
|
}
|
|
35
37
|
const command = process.argv[2];
|
|
36
|
-
|
|
38
|
+
const HELP = ["--help", "-h", "help"];
|
|
39
|
+
if ((command !== undefined && HELP.includes(command)) || (["approve", "wallet"].includes(command ?? "") && HELP.includes(process.argv[3] ?? ""))) {
|
|
37
40
|
console.log(USAGE);
|
|
38
41
|
process.exit(0);
|
|
39
42
|
}
|
|
@@ -41,29 +44,28 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
41
44
|
console.log(version);
|
|
42
45
|
process.exit(0);
|
|
43
46
|
}
|
|
44
|
-
if (command !== undefined && command !== "approve")
|
|
47
|
+
if (command !== undefined && command !== "approve" && command !== "wallet")
|
|
45
48
|
fail(`Unknown command ${command}.\n\n${USAGE}`);
|
|
46
49
|
if (command === undefined) {
|
|
47
50
|
await createServer(process.env, version).connect(new StdioServerTransport());
|
|
48
51
|
}
|
|
52
|
+
else if (command === "wallet") {
|
|
53
|
+
const { config, account } = settings();
|
|
54
|
+
console.log(`${account.address} pays for tasks in ${config.token} on ${config.chain}.`);
|
|
55
|
+
}
|
|
49
56
|
else {
|
|
50
57
|
await approve(process.argv[3] ?? "100");
|
|
51
58
|
}
|
|
52
|
-
|
|
53
|
-
let config;
|
|
59
|
+
function settings() {
|
|
54
60
|
try {
|
|
55
|
-
config
|
|
56
|
-
}
|
|
57
|
-
catch (error) {
|
|
58
|
-
fail(error instanceof Error ? error.message : String(error));
|
|
59
|
-
}
|
|
60
|
-
let account;
|
|
61
|
-
try {
|
|
62
|
-
account = payerAccount(process.env);
|
|
61
|
+
return { config: resolveConfig(process.env), account: payerAccount(process.env) };
|
|
63
62
|
}
|
|
64
63
|
catch (error) {
|
|
65
64
|
fail(error instanceof Error ? error.message : String(error));
|
|
66
65
|
}
|
|
66
|
+
}
|
|
67
|
+
async function approve(arg) {
|
|
68
|
+
const { config, account } = settings();
|
|
67
69
|
if (!config.permit2) {
|
|
68
70
|
console.log(`${config.token} on ${config.chain} pays by signature alone, so there is nothing to approve.`);
|
|
69
71
|
process.exit(0);
|
|
@@ -87,7 +89,6 @@ async function approve(arg) {
|
|
|
87
89
|
process.exit(0);
|
|
88
90
|
}
|
|
89
91
|
catch (error) {
|
|
90
|
-
|
|
91
|
-
fail(/insufficient funds/i.test(message) ? `${account.address} needs a little ETH on ${config.chain} to send the approval.` : `The approval failed: ${message}`);
|
|
92
|
+
fail(approvalFailure(account.address, config.chain, error));
|
|
92
93
|
}
|
|
93
94
|
}
|
package/dist/payments.js
CHANGED
|
@@ -50,10 +50,11 @@ export function createDesk(options) {
|
|
|
50
50
|
let spent = 0n;
|
|
51
51
|
const account = () => (typeof signer === "function" ? signer() : signer);
|
|
52
52
|
let ledger;
|
|
53
|
-
// Kept per network and wallet, so a testnet wallet never spends from the mainnet limit
|
|
53
|
+
// Kept per network and wallet, so a testnet wallet never spends from the mainnet limit, and one
|
|
54
|
+
// limit holds whichever chain the wallet pays on: USDG and USDC count in the same units.
|
|
54
55
|
const daily = () => config.daily === undefined
|
|
55
56
|
? undefined
|
|
56
|
-
: (ledger ??= dailyLedger(join(config.stateDir, `spend-${config.name}-${
|
|
57
|
+
: (ledger ??= dailyLedger(join(config.stateDir, `spend-${config.name}-${account().address.toLowerCase()}.json`), config.daily));
|
|
57
58
|
const endpoint = `${config.desk}/x402/tasks`;
|
|
58
59
|
const recent = new Map();
|
|
59
60
|
/** When this process paid, or may have paid, for each order; the desk can take minutes to know one. */
|
|
@@ -86,7 +87,8 @@ export function createDesk(options) {
|
|
|
86
87
|
}
|
|
87
88
|
if (message.includes(`Payment creation aborted: ${DAILY_REASON}`)) {
|
|
88
89
|
const today = await daily().spentToday().catch(() => undefined);
|
|
89
|
-
|
|
90
|
+
// One limit covers both chains, so it is named without a token.
|
|
91
|
+
return `This wallet has already signed payments for ${today === undefined ? "most" : formatUnits(today)} of its daily limit of ${formatUnits(config.daily)}, counting USDG and USDC together. The limit resets at 00:00 UTC; raise BRAINCHAIN_DAILY_UNITS to allow more. Nothing was charged.`;
|
|
90
92
|
}
|
|
91
93
|
if (/rejected by spendControls/.test(message)) {
|
|
92
94
|
const price = await quotedPrice();
|
|
@@ -190,19 +192,28 @@ export function createDesk(options) {
|
|
|
190
192
|
],
|
|
191
193
|
});
|
|
192
194
|
// A payment is reserved against the budget and the daily limit before it is
|
|
193
|
-
// signed, so parallel calls cannot overshoot either.
|
|
195
|
+
// signed, so parallel calls cannot overshoot either. The budget is counted before
|
|
196
|
+
// the first await, so no other call can pass the check in between.
|
|
194
197
|
client.onBeforePaymentCreation(async ({ selectedRequirements }) => {
|
|
195
198
|
const price = BigInt(selectedRequirements.amount);
|
|
196
199
|
if (spent + price > config.budget)
|
|
197
200
|
return { abort: true, reason: BUDGET_REASON };
|
|
198
|
-
const today = daily();
|
|
199
|
-
if (today) {
|
|
200
|
-
reservation = await today.reserve(price);
|
|
201
|
-
if (!reservation)
|
|
202
|
-
return { abort: true, reason: DAILY_REASON };
|
|
203
|
-
}
|
|
204
201
|
spent += price;
|
|
205
202
|
amount = price;
|
|
203
|
+
let today;
|
|
204
|
+
try {
|
|
205
|
+
today = daily();
|
|
206
|
+
if (today)
|
|
207
|
+
reservation = await today.reserve(price);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
await unreserve();
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
if (today && !reservation) {
|
|
214
|
+
await unreserve();
|
|
215
|
+
return { abort: true, reason: DAILY_REASON };
|
|
216
|
+
}
|
|
206
217
|
});
|
|
207
218
|
client.onAfterPaymentCreation(async ({ paymentPayload }) => {
|
|
208
219
|
signed = paymentPayload;
|
|
@@ -301,22 +312,38 @@ export function createDesk(options) {
|
|
|
301
312
|
}
|
|
302
313
|
return { post, get, spent: () => spent };
|
|
303
314
|
}
|
|
304
|
-
/** What the order means for the caller, and when to look again. */
|
|
305
|
-
export function guidance(view, config) {
|
|
315
|
+
/** What the order means for the caller, and when to look again. Every state and status pair is in payments.test.ts. */
|
|
316
|
+
export function guidance(view, config, now = Date.now()) {
|
|
306
317
|
const task = (view.task ?? {});
|
|
307
318
|
const wait = (retryAfterSeconds, next) => ({ done: false, next, retryAfterSeconds });
|
|
308
|
-
|
|
319
|
+
// Without a task the desk could not post the order, so no work ever existed.
|
|
320
|
+
const why = typeof view.refundReason === "string" ? `${view.refundReason} ` : task.status === undefined ? "The task could not be posted. " : "";
|
|
321
|
+
const due = typeof task.due?.by === "string" ? task.due.by : undefined;
|
|
322
|
+
const by = due ? ` by ${due}` : "";
|
|
309
323
|
// The board refunds a step that misses its deadline; saying when lets the caller stop waiting at the right time.
|
|
310
|
-
const unless = (missed) => (
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
324
|
+
const unless = (missed) => (due ? ` If ${missed}${by}, the full amount is refunded.` : "");
|
|
325
|
+
const fetched = typeof view.resultText === "string";
|
|
326
|
+
const link = !fetched && typeof view.result === "string" && view.resultError === undefined ? view.result : undefined;
|
|
327
|
+
const result = fetched ? " The result follows this summary." : link ? ` The result is at ${link}.` : "";
|
|
328
|
+
// Accepted comes from the grade alone: a ruling can pay work the grade rejected.
|
|
329
|
+
const paid = () => ({ done: true, next: `${task.accepted === true ? "Jev accepted the work and the model was paid" : "The model was paid"}.${result}` });
|
|
330
|
+
// Not done: the caller is owed a refund transaction, and the model can still dispute.
|
|
331
|
+
// The caller hears the verdict now and comes back once, when the window has closed.
|
|
332
|
+
const rejected = () => {
|
|
333
|
+
const start = "Jev did not accept the work, so the result is withheld.";
|
|
334
|
+
const closes = due === undefined ? NaN : Date.parse(due);
|
|
335
|
+
const left = Number.isFinite(closes) ? Math.ceil((closes - now) / 1000) : undefined;
|
|
336
|
+
// The desk sends the refund within a minute or two of the window closing.
|
|
337
|
+
if (left !== undefined && left <= 0)
|
|
338
|
+
return wait(60, `${start} The dispute window has closed, so the full amount is refunded on ${config.chain} shortly.`);
|
|
339
|
+
const when = left === undefined ? `, ${config.terms.finalAfter} after grading` : ` at ${due}`;
|
|
340
|
+
return wait(left === undefined ? 900 : left + 60, `${start} The full amount is refunded on ${config.chain} when the dispute window closes${when}, unless the model disputes the grade and a ruling pays it. Report this now, then read the order again after that time for the refund transaction.`);
|
|
341
|
+
};
|
|
315
342
|
switch (view.state) {
|
|
316
343
|
case "paid-out":
|
|
317
|
-
return
|
|
344
|
+
return paid();
|
|
318
345
|
case "refunded":
|
|
319
|
-
return { done: true, next: `${why}
|
|
346
|
+
return { done: true, next: `${why}The full amount was refunded on ${config.chain}${typeof view.refundTx === "string" ? ` in ${view.refundTx}` : ""}.` };
|
|
320
347
|
case "refund-failed":
|
|
321
348
|
return { done: true, next: "The refund could not be sent automatically. Write to hello@usebrainchain.com with this order id." };
|
|
322
349
|
case "failed":
|
|
@@ -328,39 +355,38 @@ export function guidance(view, config) {
|
|
|
328
355
|
case "unconfirmed":
|
|
329
356
|
return wait(30, "The payment may have gone through; BrainChain is checking it against the chain. Do not post the task again.");
|
|
330
357
|
case "refunding":
|
|
331
|
-
return wait(60, `${why}
|
|
358
|
+
return wait(60, `${why}The refund is being sent on ${config.chain}.`);
|
|
332
359
|
}
|
|
333
|
-
// A dispute, a ruling under its appeal window and an appeal can each still change a grade, accepted or not.
|
|
334
|
-
const due = typeof task.due?.by === "string" ? task.due.by : undefined;
|
|
335
|
-
const by = due ? ` by ${due}` : "";
|
|
336
360
|
switch (task.status) {
|
|
361
|
+
case "paid":
|
|
362
|
+
return paid();
|
|
363
|
+
case "refunded":
|
|
364
|
+
// The board has returned the money to the desk; the payer's refund transaction comes next.
|
|
365
|
+
return wait(60, `${why}The refund is being sent on ${config.chain}.`);
|
|
366
|
+
// A dispute, a ruling under its appeal window and an appeal can each still change a grade, accepted or not.
|
|
337
367
|
case "disputed":
|
|
338
|
-
return wait(600, `The grade is disputed; Jev rules on it${by}. If no ruling comes, the grade stands
|
|
368
|
+
return wait(600, `The grade is disputed; Jev rules on it${by}. If no ruling comes, the grade stands.${result}`);
|
|
339
369
|
case "ruled":
|
|
340
|
-
return wait(900, `Jev ruled on the dispute. The ruling settles when its appeal window closes${due ? ` at ${due}` : ""}, unless the side it went against appeals
|
|
370
|
+
return wait(900, `Jev ruled on the dispute. The ruling settles when its appeal window closes${due ? ` at ${due}` : ""}, unless the side it went against appeals.${result}`);
|
|
341
371
|
case "appealed":
|
|
342
|
-
return wait(600, `The ruling was appealed; Jev rules again${by}. If no second ruling comes, the first one stands
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
switch (task.status) {
|
|
372
|
+
return wait(600, `The ruling was appealed; Jev rules again${by}. If no second ruling comes, the first one stands.${result}`);
|
|
373
|
+
case "graded":
|
|
374
|
+
if (task.accepted === true) {
|
|
375
|
+
// The caller has what it paid for, and on a desk task only the model could still dispute, against its own payment.
|
|
376
|
+
if (result)
|
|
377
|
+
return { done: true, next: `Jev accepted the work.${result} The payment becomes final when the dispute window closes, ${config.terms.finalAfter} after grading.` };
|
|
378
|
+
return wait(60, typeof view.resultError === "string" ? "Jev accepted the work, but the result could not be read just now." : "Jev accepted the work; the result is being prepared.");
|
|
379
|
+
}
|
|
380
|
+
if (task.accepted === false)
|
|
381
|
+
return rejected();
|
|
382
|
+
return wait(60, "Jev graded the work; the verdict is being recorded.");
|
|
355
383
|
case "open":
|
|
356
|
-
return wait(60, `Waiting for Jev to hire a model.${unless("
|
|
384
|
+
return wait(60, `Waiting for Jev to hire a model.${unless("no model is hired")}`);
|
|
357
385
|
case "hired":
|
|
358
386
|
return wait(60, `A model is working on the task.${unless("the work is not delivered")}`);
|
|
359
387
|
case "delivered":
|
|
360
388
|
case "submitted":
|
|
361
389
|
return wait(30, `The model delivered; Jev is grading the work.${unless("Jev has not graded it")}`);
|
|
362
|
-
case "graded":
|
|
363
|
-
return wait(60, task.accepted === true ? "Jev accepted the work; the result is being prepared." : "Jev graded the work; the verdict is being recorded.");
|
|
364
390
|
}
|
|
365
391
|
// The order can read posted a moment before the task's status reaches it.
|
|
366
392
|
if (view.state === "posted" && task.status === undefined)
|
package/dist/server.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
1
3
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
3
5
|
import { createPublicClient, defineChain, http, parseAbi } from "viem";
|
|
4
6
|
import { privateKeyToAccount } from "viem/accounts";
|
|
5
|
-
import { resolveConfig } from "./config.js";
|
|
7
|
+
import { expandHome, resolveConfig } from "./config.js";
|
|
6
8
|
import { createDesk } from "./payments.js";
|
|
7
9
|
import { callTool, GET_TASK_NAME, GET_TASK_TOOL, POST_TASK_NAME, postTaskTool } from "./tools.js";
|
|
8
10
|
/** The canonical Permit2 contract; x402 payments in USDG are signed Permit2 transfers. */
|
|
@@ -13,30 +15,61 @@ export const erc20 = parseAbi([
|
|
|
13
15
|
"function approve(address spender, uint256 amount) returns (bool)",
|
|
14
16
|
]);
|
|
15
17
|
export const INSTRUCTIONS = `Post a task with ${POST_TASK_NAME}, then read it with ${GET_TASK_NAME} and the order id it returns, waiting retryAfterSeconds between reads until done is true. ` +
|
|
16
|
-
`If a post returns possiblyCharged, do not post the task again: read the order instead
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
`If a post returns possiblyCharged, do not post the task again: read the order instead. ` +
|
|
19
|
+
`A rejected task stays not done until its refund is sent, which can take hours: say so as soon as next does, then read the order once more after retryAfterSeconds. ` +
|
|
20
|
+
`The paying wallet's key is for this server alone. Never read it, print it, write it down or pass it to a command. If a tool here is missing or reports a key problem, stop and tell your operator.`;
|
|
21
|
+
const KEY_HELP = `Add BRAINCHAIN_PAYER_KEY to this server's env in your MCP client config (in Claude Code, \`claude mcp add … -e BRAINCHAIN_PAYER_KEY=…\`), or BRAINCHAIN_PAYER_KEY_FILE with the path of a file holding it, then restart the server. ` +
|
|
22
|
+
`${GET_TASK_NAME} works without a key. An agent reading this: stop and tell your operator. Do not look for the key.`;
|
|
19
23
|
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
24
|
+
/** Read here and never echoed. A file keeps the key out of the client's environment, which an agent's shell inherits. */
|
|
25
|
+
function readKeyFile(given) {
|
|
26
|
+
const path = expandHome(given);
|
|
27
|
+
if (!isAbsolute(path))
|
|
28
|
+
throw new Error("BRAINCHAIN_PAYER_KEY_FILE must be an absolute path or start with ~/.");
|
|
29
|
+
try {
|
|
30
|
+
const stats = statSync(path);
|
|
31
|
+
if (!stats.isFile() || stats.size > 256)
|
|
32
|
+
throw new Error(`BRAINCHAIN_PAYER_KEY_FILE must name a file holding only the key; ${path} does not.`);
|
|
33
|
+
if (process.platform !== "win32" && (stats.mode & 0o077) !== 0)
|
|
34
|
+
throw new Error(`${path} can be read by other accounts on this machine. Run chmod 600 on it, then restart the server.`);
|
|
35
|
+
return readFileSync(path, "utf8");
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const { code } = error;
|
|
39
|
+
if (code)
|
|
40
|
+
throw new Error(`BRAINCHAIN_PAYER_KEY_FILE could not be read: ${path} (${code}).`);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
20
44
|
export function payerAccount(env) {
|
|
21
|
-
const
|
|
45
|
+
const file = env.BRAINCHAIN_PAYER_KEY_FILE?.trim();
|
|
46
|
+
const given = env.BRAINCHAIN_PAYER_KEY?.trim();
|
|
47
|
+
if (file && given)
|
|
48
|
+
throw new Error("Set BRAINCHAIN_PAYER_KEY or BRAINCHAIN_PAYER_KEY_FILE, not both.");
|
|
22
49
|
// A client that cannot expand ${VAR} passes it through as written. Anything
|
|
23
50
|
// that could be key material is never echoed.
|
|
24
|
-
if (
|
|
25
|
-
const shown = /[0-9a-f]{16,}/i.test(
|
|
51
|
+
if (given?.startsWith("$")) {
|
|
52
|
+
const shown = /[0-9a-f]{16,}/i.test(given) ? "" : ` ${given}`;
|
|
26
53
|
throw new Error(`BRAINCHAIN_PAYER_KEY is not set: your MCP client passed the placeholder${shown} through unexpanded, so the variable was not set when the client started.`);
|
|
27
54
|
}
|
|
28
|
-
const key =
|
|
29
|
-
if (!key)
|
|
55
|
+
const key = (file ? readKeyFile(file) : given)?.trim().replace(/^0x/i, "");
|
|
56
|
+
if (!file && !key)
|
|
30
57
|
throw new Error("Set BRAINCHAIN_PAYER_KEY to the private key of the wallet that pays for tasks: 64 hex characters, with or without 0x.");
|
|
31
|
-
|
|
32
|
-
|
|
58
|
+
const name = file ? "The key in BRAINCHAIN_PAYER_KEY_FILE" : "BRAINCHAIN_PAYER_KEY";
|
|
59
|
+
if (!/^[0-9a-f]{64}$/i.test(key ?? ""))
|
|
60
|
+
throw new Error(`${name} must be 64 hex characters, with or without 0x.`);
|
|
33
61
|
try {
|
|
34
62
|
return privateKeyToAccount(`0x${key}`);
|
|
35
63
|
}
|
|
36
64
|
catch {
|
|
37
|
-
throw new Error(
|
|
65
|
+
throw new Error(`${name} is not a valid private key.`);
|
|
38
66
|
}
|
|
39
67
|
}
|
|
68
|
+
/** What a failed `approve` should tell a person. viem says the cost "exceeds the balance"; nodes say "insufficient funds". */
|
|
69
|
+
export function approvalFailure(address, chain, error) {
|
|
70
|
+
const message = error instanceof Error ? (error.message.split("\n")[0] ?? "") : String(error);
|
|
71
|
+
return /insufficient funds|exceeds the balance/i.test(message) ? `${address} needs a little ETH on ${chain} to send the approval.` : `The approval failed: ${message}`;
|
|
72
|
+
}
|
|
40
73
|
export function chainClients(config) {
|
|
41
74
|
const chain = defineChain({
|
|
42
75
|
id: config.chainId,
|
|
@@ -63,6 +96,15 @@ function deskFor(env, version, fetchImpl) {
|
|
|
63
96
|
return { desk: { post: refuse, get: refuse }, terms: problem };
|
|
64
97
|
}
|
|
65
98
|
let account;
|
|
99
|
+
let keyProblem;
|
|
100
|
+
try {
|
|
101
|
+
account = payerAccount(env);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
// No key at all is a read-only server, which is allowed. A key that is there and unusable is a mistake to report.
|
|
105
|
+
if (env.BRAINCHAIN_PAYER_KEY?.trim() || env.BRAINCHAIN_PAYER_KEY_FILE?.trim())
|
|
106
|
+
keyProblem = messageOf(error);
|
|
107
|
+
}
|
|
66
108
|
const payer = () => {
|
|
67
109
|
try {
|
|
68
110
|
return (account ??= payerAccount(env));
|
|
@@ -86,12 +128,13 @@ function deskFor(env, version, fetchImpl) {
|
|
|
86
128
|
return { balance, allowance };
|
|
87
129
|
},
|
|
88
130
|
});
|
|
89
|
-
return { desk, terms: config.terms };
|
|
131
|
+
return { desk, terms: { ...config.terms, ...(account ? { wallet: account.address } : {}), ...(keyProblem ? { keyProblem } : {}) } };
|
|
90
132
|
}
|
|
91
133
|
export function createServer(env, version, options = {}) {
|
|
92
134
|
const { desk, terms } = deskFor(env, version, options.fetch);
|
|
93
|
-
|
|
94
|
-
|
|
135
|
+
const problem = typeof terms === "string" ? terms : terms.keyProblem;
|
|
136
|
+
if (problem)
|
|
137
|
+
(options.log ?? console.error)(`brainchain-mcp has an invalid setting: ${problem}`);
|
|
95
138
|
const tools = [postTaskTool(terms), GET_TASK_TOOL];
|
|
96
139
|
const server = new Server({ name: "brainchain", title: "BrainChain", version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
97
140
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
|
package/dist/tools.js
CHANGED
|
@@ -31,8 +31,9 @@ export function postTaskTool(terms) {
|
|
|
31
31
|
title: "Post a paid task to BrainChain",
|
|
32
32
|
description: typeof terms === "string"
|
|
33
33
|
? `Invalid setting: ${terms} Until then this tool only returns that error. ${about}`
|
|
34
|
-
:
|
|
35
|
-
|
|
34
|
+
: (terms.keyProblem ? `Invalid setting: ${terms.keyProblem} Until the operator fixes it, this tool only returns that error. ` : "") +
|
|
35
|
+
about +
|
|
36
|
+
`Each task costs ${terms.cost}, paid from this server's wallet${terms.wallet ? ` ${terms.wallet}` : ""} on ${terms.chain}. The result is delivered only if Jev accepts the work. If Jev rejects the work, the full amount is refunded to the same wallet on ${terms.chain}, ${terms.finalAfter} after grading, unless the model disputes the grade and a ruling pays it. If no model delivers within 24 hours, the full amount is refunded. ` +
|
|
36
37
|
`Returns an order id for ${GET_TASK_NAME}; results usually arrive within minutes. If the reply says possiblyCharged, do not post the task again: read the order with ${GET_TASK_NAME}. ` +
|
|
37
38
|
`While this server keeps running, an identical post within ${REPOST_AFTER_MINUTES} minutes returns the first post's order, marked duplicate, instead of paying again.`,
|
|
38
39
|
inputSchema: POST_TASK_INPUT,
|
|
@@ -43,7 +44,7 @@ export const GET_TASK_TOOL = {
|
|
|
43
44
|
name: GET_TASK_NAME,
|
|
44
45
|
title: "Read a BrainChain order",
|
|
45
46
|
description: "Read an order from brainchain_post_task: the payment, the task's status on BrainChain, Jev's grade, the result text once the work is accepted, and the refund transaction if it is not. " +
|
|
46
|
-
"Call again after `retryAfterSeconds` until `done` is true. `next` says what the order's state means and what to do.",
|
|
47
|
+
"Call again after `retryAfterSeconds` until `done` is true. `next` says what the order's state means and what to do. A rejected task stays not done until the refund is sent, when the dispute window closes.",
|
|
47
48
|
inputSchema: {
|
|
48
49
|
type: "object",
|
|
49
50
|
properties: { order: { type: "string", pattern: "^0x[0-9a-fA-F]{64}$", description: "Order id returned by brainchain_post_task." } },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "MCP server for BrainChain: let your agent hire a frontier model for a task and pay in USDG on Robinhood Chain or USDC on Base, only for work that meets the quality bar you set.",
|
|
5
5
|
"mcpName": "com.usebrainchain/brainchain",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
|
-
"@x402/evm": "2.
|
|
39
|
-
"@x402/fetch": "2.
|
|
38
|
+
"@x402/evm": "2.29.0",
|
|
39
|
+
"@x402/fetch": "2.29.0",
|
|
40
40
|
"viem": "^2.0.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|