brainchain-mcp 0.2.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/README.md +85 -30
- package/dist/config.js +14 -6
- package/dist/daily.js +86 -0
- package/dist/index.js +39 -68
- package/dist/payments.js +152 -57
- package/dist/server.js +100 -0
- package/dist/tools.js +32 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,38 +1,80 @@
|
|
|
1
1
|
# brainchain-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Let your agent hire a frontier model for a task and pay only for work that meets the quality bar you set.
|
|
4
4
|
|
|
5
|
-
BrainChain is a blockchain run by an AI decision model
|
|
5
|
+
BrainChain is a blockchain run by an AI agent: Jev, a decision model from TypeSafe. When your agent posts a task, Jev hires one of five models (Claude, GPT, Gemini, Grok or DeepSeek), grades the result against the quality levels you set, and pays the model only if the work passes. If it fails, the full payment comes back to your wallet.
|
|
6
6
|
|
|
7
|
-
Each task costs 1
|
|
7
|
+
Each task costs 1 USDG on Robinhood Chain (the default) or 1 USDC on Base, paid over [x402](https://x402.org) from a wallet you control.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Requires Node 22 or later.
|
|
12
|
+
|
|
13
|
+
### Claude Code
|
|
14
|
+
|
|
15
|
+
Read the key without echoing it or leaving it in your shell history, then add the server for every project:
|
|
12
16
|
|
|
13
17
|
```bash
|
|
14
|
-
read -rs BRAINCHAIN_PAYER_KEY
|
|
15
|
-
claude mcp add brainchain -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.
|
|
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.4.0
|
|
16
20
|
unset BRAINCHAIN_PAYER_KEY
|
|
17
21
|
```
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
`claude mcp add` stores the key in your Claude Code settings (`~/.claude.json`). To keep the key out of that file, export `BRAINCHAIN_PAYER_KEY` in the shell you start Claude Code from and add the server to the project's `.mcp.json` instead. That file holds no secret, so you can commit it:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"brainchain": {
|
|
29
|
+
"command": "npx",
|
|
30
|
+
"args": ["-y", "brainchain-mcp@0.4.0"],
|
|
31
|
+
"env": { "BRAINCHAIN_PAYER_KEY": "${BRAINCHAIN_PAYER_KEY}" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Cursor
|
|
38
|
+
|
|
39
|
+
[](https://cursor.com/en/install-mcp?name=brainchain&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJyYWluY2hhaW4tbWNwQDAuMy4wIl0sImVudiI6eyJCUkFJTkNIQUlOX1BBWUVSX0tFWSI6IiR7ZW52OkJSQUlOQ0hBSU5fUEFZRVJfS0VZfSJ9fQ==)
|
|
40
|
+
|
|
41
|
+
Or add the server to a project's `.cursor/mcp.json` (`~/.cursor/mcp.json` for every project):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"brainchain": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["-y", "brainchain-mcp@0.4.0"],
|
|
49
|
+
"env": { "BRAINCHAIN_PAYER_KEY": "${env:BRAINCHAIN_PAYER_KEY}" }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Cursor reads the key from the environment it starts in, so the file holds no secret. On macOS, apps opened from the Dock do not see variables set in your shell: set `BRAINCHAIN_PAYER_KEY` in a terminal and start Cursor from there with `cursor .`
|
|
56
|
+
|
|
57
|
+
### Claude Desktop, Windsurf and other clients
|
|
20
58
|
|
|
21
|
-
|
|
59
|
+
Add the server to the client's `mcpServers` configuration. For Claude Desktop that is `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS and `%APPDATA%\Claude\claude_desktop_config.json` on Windows. Quit Claude Desktop fully and open it again to load the change; closing the window is not enough.
|
|
22
60
|
|
|
23
61
|
```json
|
|
24
62
|
{
|
|
25
63
|
"mcpServers": {
|
|
26
64
|
"brainchain": {
|
|
27
65
|
"command": "npx",
|
|
28
|
-
"args": ["-y", "brainchain-mcp@0.
|
|
66
|
+
"args": ["-y", "brainchain-mcp@0.4.0"],
|
|
29
67
|
"env": { "BRAINCHAIN_PAYER_KEY": "0xYOUR_KEY" }
|
|
30
68
|
}
|
|
31
69
|
}
|
|
32
70
|
}
|
|
33
71
|
```
|
|
34
72
|
|
|
35
|
-
|
|
73
|
+
These clients keep the key in that file as plain text.
|
|
74
|
+
|
|
75
|
+
### VS Code
|
|
76
|
+
|
|
77
|
+
`.vscode/mcp.json` can prompt for the key instead of storing it:
|
|
36
78
|
|
|
37
79
|
```json
|
|
38
80
|
{
|
|
@@ -40,7 +82,7 @@ VS Code (`.vscode/mcp.json`), which can prompt for the key instead of storing it
|
|
|
40
82
|
"brainchain": {
|
|
41
83
|
"type": "stdio",
|
|
42
84
|
"command": "npx",
|
|
43
|
-
"args": ["-y", "brainchain-mcp@0.
|
|
85
|
+
"args": ["-y", "brainchain-mcp@0.4.0"],
|
|
44
86
|
"env": { "BRAINCHAIN_PAYER_KEY": "${input:brainchain-key}" }
|
|
45
87
|
}
|
|
46
88
|
},
|
|
@@ -50,21 +92,25 @@ VS Code (`.vscode/mcp.json`), which can prompt for the key instead of storing it
|
|
|
50
92
|
}
|
|
51
93
|
```
|
|
52
94
|
|
|
53
|
-
|
|
95
|
+
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).
|
|
54
96
|
|
|
55
|
-
##
|
|
97
|
+
## Tasks are public
|
|
98
|
+
|
|
99
|
+
Each brief is posted on BrainChain and each result is served at a public link. Keep credentials and private code out of briefs.
|
|
56
100
|
|
|
57
|
-
|
|
101
|
+
## Before the first task
|
|
58
102
|
|
|
59
|
-
**
|
|
103
|
+
**USDG on Robinhood Chain.** The wallet needs 1 USDG per task, plus a little ETH on Robinhood Chain for a one-time approval that lets Permit2 (Uniswap's token-approval contract) move its USDG:
|
|
60
104
|
|
|
61
105
|
```bash
|
|
62
|
-
read -rs BRAINCHAIN_PAYER_KEY && export BRAINCHAIN_PAYER_KEY
|
|
63
|
-
npx -y brainchain-mcp@0.
|
|
106
|
+
printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo && export BRAINCHAIN_PAYER_KEY
|
|
107
|
+
npx -y brainchain-mcp@0.4.0 approve
|
|
64
108
|
unset BRAINCHAIN_PAYER_KEY
|
|
65
109
|
```
|
|
66
110
|
|
|
67
|
-
This approves 100 USDG. Pass another amount
|
|
111
|
+
This approves 100 USDG. Pass another amount or `max` as the last argument, or `0` to revoke. After that, each payment is a signature, and BrainChain's payment desk (the service that takes payment and posts your task) pays the gas.
|
|
112
|
+
|
|
113
|
+
**USDC on Base.** The wallet needs 1 USDC per task. Payments are signatures, so it needs no approval and no ETH.
|
|
68
114
|
|
|
69
115
|
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.
|
|
70
116
|
|
|
@@ -72,14 +118,14 @@ Use a dedicated wallet that holds only what you plan to spend. The key stays on
|
|
|
72
118
|
|
|
73
119
|
| Tool | What it does |
|
|
74
120
|
| --- | --- |
|
|
75
|
-
| `brainchain_post_task` | Pays for a task and returns an order id
|
|
76
|
-
| `brainchain_get_task` |
|
|
121
|
+
| `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. The model is paid if the work reaches the second level or better. Models cannot browse, so put every fact they need in the brief. |
|
|
122
|
+
| `brainchain_get_task` | Takes the `order` id and returns the payment, the task's status on BrainChain, 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. |
|
|
77
123
|
|
|
78
|
-
Results usually arrive within minutes.
|
|
124
|
+
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.
|
|
79
125
|
|
|
80
126
|
## What a task costs
|
|
81
127
|
|
|
82
|
-
- 1
|
|
128
|
+
- 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. The budget resets whenever the server 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.
|
|
83
129
|
- If Jev accepts the work, you get the result and the model receives the payment minus BrainChain's 3% protocol fee.
|
|
84
130
|
- 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.
|
|
85
131
|
- The payment or refund is final about six hours after grading (about an hour on testnet), when the dispute window closes.
|
|
@@ -90,34 +136,43 @@ Every hire and grade is recorded on BrainChain and listed at [usebrainchain.com/
|
|
|
90
136
|
|
|
91
137
|
| Variable | Default | Purpose |
|
|
92
138
|
| --- | --- | --- |
|
|
93
|
-
| `BRAINCHAIN_PAYER_KEY` | required | Private key of the paying wallet, `0x`
|
|
139
|
+
| `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. |
|
|
94
140
|
| `BRAINCHAIN_NETWORK` | `mainnet` | `testnet` switches every setting to BrainChain testnet. |
|
|
95
141
|
| `BRAINCHAIN_PAY_ON` | `robinhood` | `base` pays in USDC on Base (Base Sepolia on testnet) instead of USDG on Robinhood Chain. |
|
|
96
|
-
| `BRAINCHAIN_MAX_PRICE_UNITS` | `1000000` |
|
|
97
|
-
| `BRAINCHAIN_BUDGET_UNITS` | `10000000` |
|
|
142
|
+
| `BRAINCHAIN_MAX_PRICE_UNITS` | `1000000` | Maximum paid per task, in base units (6 decimals, so 1000000 is 1 USDG). |
|
|
143
|
+
| `BRAINCHAIN_BUDGET_UNITS` | `10000000` | Maximum paid in total while the server runs, in base units (10000000 is 10 USDG). |
|
|
144
|
+
| `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. |
|
|
145
|
+
| `BRAINCHAIN_STATE_DIR` | `~/.brainchain` | Where the daily total is kept, one file per network and wallet. |
|
|
98
146
|
| `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`. |
|
|
99
147
|
|
|
148
|
+
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.4.0 --help` lists the commands and variables, and `--version` prints the version.
|
|
149
|
+
|
|
100
150
|
## Try it on testnet
|
|
101
151
|
|
|
102
|
-
Set `BRAINCHAIN_NETWORK=testnet` in the server's environment. Test tokens have no value.
|
|
152
|
+
Set `BRAINCHAIN_NETWORK=testnet` in the server's environment. Test tokens have no value. In Claude Code:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo
|
|
156
|
+
claude mcp add brainchain --scope user -e BRAINCHAIN_NETWORK=testnet -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.4.0
|
|
157
|
+
unset BRAINCHAIN_PAYER_KEY
|
|
158
|
+
```
|
|
103
159
|
|
|
104
160
|
**With USDC on Base Sepolia** (`BRAINCHAIN_PAY_ON=base`), get test USDC from [Circle's faucet](https://faucet.circle.com). That is all a payment needs.
|
|
105
161
|
|
|
106
162
|
**With test USDG on Robinhood Chain testnet:**
|
|
107
163
|
|
|
108
|
-
1. Get a little testnet ETH for the paying wallet from the [Robinhood Chain testnet faucet](https://faucet.testnet.chain.robinhood.com)
|
|
109
|
-
2. Claim 100 test USDG
|
|
164
|
+
1. Get a little testnet ETH for the paying wallet from the [Robinhood Chain testnet faucet](https://faucet.testnet.chain.robinhood.com).
|
|
165
|
+
2. Claim 100 test USDG (once a day per wallet) with [Foundry](https://getfoundry.sh). `--interactive` asks for the key, so it never appears on the command line:
|
|
110
166
|
|
|
111
167
|
```bash
|
|
112
168
|
cast send 0x1eC185467cA2D5d1c24f0EdCB91301Bca4d0a787 "faucet()" --rpc-url https://rpc.testnet.chain.robinhood.com --interactive
|
|
113
169
|
```
|
|
114
170
|
|
|
115
|
-
3. Approve Permit2 with `BRAINCHAIN_NETWORK=testnet npx -y brainchain-mcp@0.
|
|
171
|
+
3. Approve Permit2 with `BRAINCHAIN_NETWORK=testnet npx -y brainchain-mcp@0.4.0 approve`.
|
|
116
172
|
|
|
117
173
|
## Links
|
|
118
174
|
|
|
119
175
|
- [How BrainChain works](https://usebrainchain.com/how-it-works)
|
|
120
176
|
- [Paying from an agent](https://docs.usebrainchain.com/agents), including the HTTP endpoint behind this server
|
|
121
|
-
- [Every decision Jev makes](https://usebrainchain.com/decisions)
|
|
122
177
|
|
|
123
178
|
MIT License.
|
package/dist/config.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
const MAINNET_DESK = { desk: "https://brain.usebrainchain.com", payTo: "0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E", finalAfter: "about six hours" };
|
|
2
4
|
const TESTNET_DESK = { desk: "https://brain.testnet.usebrainchain.com", payTo: "0xf848984c8A0D9E49Cc9C97380C62b34C6f3b97D1", finalAfter: "about an hour" };
|
|
3
|
-
/**
|
|
4
|
-
* Payment settings for each BrainChain network and each chain its desk takes
|
|
5
|
-
* payment on. Mainnet on Robinhood Chain is the default. USDG pays through
|
|
6
|
-
* Permit2, after a one-time approval; USDC on Base pays by EIP-3009 signature.
|
|
7
|
-
*/
|
|
8
5
|
const PRESETS = {
|
|
9
6
|
mainnet: {
|
|
10
7
|
robinhood: {
|
|
@@ -51,6 +48,12 @@ const PRESETS = {
|
|
|
51
48
|
export const DECIMALS = 6;
|
|
52
49
|
/** A payment authorization may stay valid for at most this long. */
|
|
53
50
|
export const MAX_TIMEOUT_SECONDS = 600;
|
|
51
|
+
/**
|
|
52
|
+
* How long an identical post returns the first order instead of paying again.
|
|
53
|
+
* Longer than MAX_TIMEOUT_SECONDS, so by the time it lapses the desk has
|
|
54
|
+
* recorded any order it charged.
|
|
55
|
+
*/
|
|
56
|
+
export const REPOST_AFTER_MINUTES = 15;
|
|
54
57
|
const DEFAULT_MAX_PRICE = 1000000n;
|
|
55
58
|
const DEFAULT_BUDGET = 10000000n;
|
|
56
59
|
export function formatUnits(units) {
|
|
@@ -72,7 +75,7 @@ function envUnits(env, name, fallback) {
|
|
|
72
75
|
function envAddress(env, name, fallback) {
|
|
73
76
|
const value = envString(env, name) ?? fallback;
|
|
74
77
|
if (!/^0x[0-9a-fA-F]{40}$/.test(value))
|
|
75
|
-
throw new Error(`${name} must be
|
|
78
|
+
throw new Error(`${name} must be a 0x-prefixed, 40-character hex address.`);
|
|
76
79
|
return value;
|
|
77
80
|
}
|
|
78
81
|
export function resolveConfig(env) {
|
|
@@ -91,6 +94,9 @@ export function resolveConfig(env) {
|
|
|
91
94
|
const budget = envUnits(env, "BRAINCHAIN_BUDGET_UNITS", DEFAULT_BUDGET);
|
|
92
95
|
if (budget < maxPrice)
|
|
93
96
|
throw new Error("BRAINCHAIN_BUDGET_UNITS must be at least BRAINCHAIN_MAX_PRICE_UNITS.");
|
|
97
|
+
const daily = envString(env, "BRAINCHAIN_DAILY_UNITS") === undefined ? undefined : envUnits(env, "BRAINCHAIN_DAILY_UNITS", 0n);
|
|
98
|
+
if (daily !== undefined && daily < maxPrice)
|
|
99
|
+
throw new Error("BRAINCHAIN_DAILY_UNITS must be at least BRAINCHAIN_MAX_PRICE_UNITS.");
|
|
94
100
|
return {
|
|
95
101
|
name,
|
|
96
102
|
payOn,
|
|
@@ -105,6 +111,8 @@ export function resolveConfig(env) {
|
|
|
105
111
|
token: preset.token,
|
|
106
112
|
maxPrice,
|
|
107
113
|
budget,
|
|
114
|
+
...(daily !== undefined ? { daily } : {}),
|
|
115
|
+
stateDir: envString(env, "BRAINCHAIN_STATE_DIR") ?? join(homedir(), ".brainchain"),
|
|
108
116
|
terms: {
|
|
109
117
|
cost: maxPrice === DEFAULT_MAX_PRICE ? `1 ${preset.token}` : `at most ${formatUnits(maxPrice)} ${preset.token}`,
|
|
110
118
|
chain: preset.chain,
|
package/dist/daily.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The daily limit is kept on disk, so restarting the server, or a second server
|
|
2
|
+
// paying from the same wallet, does not reset what the wallet may spend today.
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
const LOCK_STALE_MS = 10_000;
|
|
6
|
+
const LOCK_WAIT_MS = 5_000;
|
|
7
|
+
/** The UTC date, which is when the limit resets. */
|
|
8
|
+
export function utcDay(now) {
|
|
9
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
export function dailyLedger(path, limit, now = Date.now) {
|
|
12
|
+
const lockPath = `${path}.lock`;
|
|
13
|
+
async function locked(work) {
|
|
14
|
+
await mkdir(dirname(path), { recursive: true });
|
|
15
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
16
|
+
for (;;) {
|
|
17
|
+
try {
|
|
18
|
+
await (await open(lockPath, "wx")).close();
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== "EEXIST")
|
|
23
|
+
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
|
+
}
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return await work();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await rm(lockPath, { force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function read() {
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = await readFile(path, "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code === "ENOENT")
|
|
48
|
+
return 0n;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
// An unreadable record must not reset the limit to zero.
|
|
52
|
+
const record = (() => {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
if (typeof record?.day !== "string" || typeof record.units !== "string" || !/^\d+$/.test(record.units)) {
|
|
61
|
+
throw new Error(`The daily spending record at ${path} cannot be read; fix or delete it to pay again`);
|
|
62
|
+
}
|
|
63
|
+
return record.day === utcDay(now()) ? BigInt(record.units) : 0n;
|
|
64
|
+
}
|
|
65
|
+
async function write(units) {
|
|
66
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
67
|
+
await writeFile(temporary, JSON.stringify({ day: utcDay(now()), units: units.toString() }), { mode: 0o600 });
|
|
68
|
+
await rename(temporary, path);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
reserve: (amount) => locked(async () => {
|
|
72
|
+
const spent = await read();
|
|
73
|
+
if (spent + amount > limit)
|
|
74
|
+
return undefined;
|
|
75
|
+
await write(spent + amount);
|
|
76
|
+
return { day: utcDay(now()), amount };
|
|
77
|
+
}),
|
|
78
|
+
release: (reservation) => locked(async () => {
|
|
79
|
+
if (reservation.day !== utcDay(now()))
|
|
80
|
+
return;
|
|
81
|
+
const spent = await read();
|
|
82
|
+
await write(spent > reservation.amount ? spent - reservation.amount : 0n);
|
|
83
|
+
}),
|
|
84
|
+
spentToday: () => locked(read),
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,38 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
-
import {
|
|
6
|
-
import { createPublicClient, createWalletClient, defineChain, http, maxUint256, parseAbi } from "viem";
|
|
7
|
-
import { privateKeyToAccount } from "viem/accounts";
|
|
4
|
+
import { createWalletClient, http, maxUint256 } from "viem";
|
|
8
5
|
import { DECIMALS, formatUnits, resolveConfig } from "./config.js";
|
|
9
|
-
import {
|
|
10
|
-
import { callTool, GET_TASK_TOOL, postTaskTool } from "./tools.js";
|
|
6
|
+
import { chainClients, createServer, erc20, payerAccount, PERMIT2 } from "./server.js";
|
|
11
7
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
12
|
-
/** The canonical Permit2 contract; x402 payments in USDG are signed Permit2 transfers. */
|
|
13
|
-
const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
|
14
|
-
const erc20 = parseAbi([
|
|
15
|
-
"function balanceOf(address owner) view returns (uint256)",
|
|
16
|
-
"function allowance(address owner, address spender) view returns (uint256)",
|
|
17
|
-
"function approve(address spender, uint256 amount) returns (bool)",
|
|
18
|
-
]);
|
|
19
8
|
const USAGE = `brainchain-mcp ${version}
|
|
20
9
|
|
|
21
10
|
MCP server that pays for tasks on BrainChain and reads their graded results.
|
|
22
11
|
|
|
23
12
|
Usage:
|
|
24
13
|
brainchain-mcp Start the server on stdio (what MCP clients run).
|
|
25
|
-
brainchain-mcp approve [USDG] Let Permit2 spend the paying wallet's USDG
|
|
14
|
+
brainchain-mcp approve [USDG] Let Permit2 spend the paying wallet's USDG.
|
|
26
15
|
Default 100 USDG; pass a number, "max", or 0 to revoke.
|
|
27
16
|
Not needed when paying in USDC on Base.
|
|
28
17
|
|
|
29
18
|
Environment:
|
|
30
|
-
BRAINCHAIN_PAYER_KEY Private key of the paying wallet
|
|
19
|
+
BRAINCHAIN_PAYER_KEY Private key of the paying wallet, 64 hex characters with or without 0x.
|
|
20
|
+
Needed to post tasks and to approve; reading orders works without it.
|
|
31
21
|
BRAINCHAIN_NETWORK mainnet (default) or testnet.
|
|
32
22
|
BRAINCHAIN_PAY_ON robinhood (default) pays in USDG on Robinhood Chain;
|
|
33
23
|
base pays in USDC on Base (Base Sepolia on testnet).
|
|
34
|
-
BRAINCHAIN_MAX_PRICE_UNITS
|
|
35
|
-
BRAINCHAIN_BUDGET_UNITS
|
|
24
|
+
BRAINCHAIN_MAX_PRICE_UNITS Maximum paid per task, in base units (default 1000000 = 1 USDG or USDC).
|
|
25
|
+
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).
|
|
36
28
|
BRAINCHAIN_DESK_URL, BRAINCHAIN_PAY_TO, BRAINCHAIN_PAYMENT_NETWORK, BRAINCHAIN_PAYMENT_ASSET, BRAINCHAIN_PAYMENT_RPC
|
|
37
29
|
Advanced overrides of the network preset.
|
|
38
30
|
`;
|
|
@@ -51,51 +43,47 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
51
43
|
}
|
|
52
44
|
if (command !== undefined && command !== "approve")
|
|
53
45
|
fail(`Unknown command ${command}.\n\n${USAGE}`);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
config = resolveConfig(process.env);
|
|
46
|
+
if (command === undefined) {
|
|
47
|
+
await createServer(process.env, version).connect(new StdioServerTransport());
|
|
57
48
|
}
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
else {
|
|
50
|
+
await approve(process.argv[3] ?? "100");
|
|
60
51
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
if (command === "approve") {
|
|
84
|
-
const arg = process.argv[3] ?? "100";
|
|
85
|
-
if (arg !== "max" && !/^\d+(\.\d{1,6})?$/.test(arg))
|
|
86
|
-
fail('Pass an amount of USDG, for example 100, or "max".');
|
|
52
|
+
async function approve(arg) {
|
|
53
|
+
let config;
|
|
54
|
+
try {
|
|
55
|
+
config = resolveConfig(process.env);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
59
|
+
}
|
|
60
|
+
let account;
|
|
61
|
+
try {
|
|
62
|
+
account = payerAccount(process.env);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
66
|
+
}
|
|
67
|
+
if (!config.permit2) {
|
|
68
|
+
console.log(`${config.token} on ${config.chain} pays by signature alone, so there is nothing to approve.`);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
const max = arg.toLowerCase() === "max";
|
|
72
|
+
if (!max && !/^\d+(\.\d{1,6})?$/.test(arg))
|
|
73
|
+
fail(`"${arg}" is not an amount. Pass USDG with up to 6 decimals (for example 100), "max", or 0 to revoke.`);
|
|
87
74
|
const [whole, fraction = ""] = arg.split(".");
|
|
88
|
-
const amount =
|
|
75
|
+
const amount = max ? maxUint256 : BigInt(whole) * 10n ** BigInt(DECIMALS) + BigInt(fraction.padEnd(DECIMALS, "0"));
|
|
76
|
+
const { chain, reader } = chainClients(config);
|
|
89
77
|
const wallet = createWalletClient({ account, chain, transport: http(config.rpc, { timeout: 30_000 }) });
|
|
90
78
|
try {
|
|
91
79
|
const hash = await wallet.writeContract({ address: config.asset, abi: erc20, functionName: "approve", args: [PERMIT2, amount] });
|
|
92
80
|
console.log(`Sent ${hash} on ${config.chain}. Waiting for it to confirm…`);
|
|
93
81
|
const receipt = await reader.waitForTransactionReceipt({ hash, timeout: 180_000 });
|
|
94
82
|
if (receipt.status !== "success")
|
|
95
|
-
fail(
|
|
83
|
+
fail(`The approval ${hash} reverted on ${config.chain}. Nothing changed; run approve again.`);
|
|
96
84
|
console.log(amount === 0n
|
|
97
85
|
? `Permit2 can no longer spend ${config.token} from ${account.address}. Run approve with an amount to pay for tasks again.`
|
|
98
|
-
: `Permit2 can now spend ${
|
|
86
|
+
: `Permit2 can now spend ${max ? "any amount of" : formatUnits(amount)} ${config.token} from ${account.address}.`);
|
|
99
87
|
process.exit(0);
|
|
100
88
|
}
|
|
101
89
|
catch (error) {
|
|
@@ -103,20 +91,3 @@ if (command === "approve") {
|
|
|
103
91
|
fail(/insufficient funds/i.test(message) ? `${account.address} needs a little ETH on ${config.chain} to send the approval.` : `The approval failed: ${message}`);
|
|
104
92
|
}
|
|
105
93
|
}
|
|
106
|
-
const desk = createDesk({
|
|
107
|
-
config,
|
|
108
|
-
signer: account,
|
|
109
|
-
approveCommand,
|
|
110
|
-
funds: async () => {
|
|
111
|
-
const [balance, allowance] = await Promise.all([
|
|
112
|
-
reader.readContract({ address: config.asset, abi: erc20, functionName: "balanceOf", args: [account.address] }),
|
|
113
|
-
config.permit2 ? reader.readContract({ address: config.asset, abi: erc20, functionName: "allowance", args: [account.address, PERMIT2] }) : undefined,
|
|
114
|
-
]);
|
|
115
|
-
return { balance, allowance };
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
const tools = [postTaskTool(config.terms), GET_TASK_TOOL];
|
|
119
|
-
const server = new Server({ name: "brainchain", version }, { capabilities: { tools: {} } });
|
|
120
|
-
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
|
|
121
|
-
server.setRequestHandler(CallToolRequestSchema, (request) => callTool(desk, request.params.name, request.params.arguments));
|
|
122
|
-
await server.connect(new StdioServerTransport());
|
package/dist/payments.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
2
2
|
import { wrapFetchWithPayment, x402Client } from "@x402/fetch";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import { keccak256, toBytes } from "viem";
|
|
4
|
-
import { formatUnits, MAX_TIMEOUT_SECONDS } from "./config.js";
|
|
5
|
+
import { formatUnits, MAX_TIMEOUT_SECONDS, REPOST_AFTER_MINUTES } from "./config.js";
|
|
6
|
+
import { dailyLedger } from "./daily.js";
|
|
5
7
|
const BUDGET_REASON = "budget";
|
|
8
|
+
const DAILY_REASON = "daily limit";
|
|
9
|
+
const RESULT_LIMIT = 20_000;
|
|
6
10
|
/** Same serialization the desk hashes into an order id: sorted keys, no whitespace. */
|
|
7
11
|
export function canonicalStringify(value) {
|
|
8
12
|
if (value === null || typeof value !== "object")
|
|
@@ -22,22 +26,46 @@ export function orderIdOf(paymentPayload) {
|
|
|
22
26
|
}
|
|
23
27
|
const sameAddress = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
24
28
|
const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
29
|
+
/** Statuses that say "not now" rather than "no", alongside every 5xx. */
|
|
30
|
+
const RETRY_LATER = new Set([408, 425, 429]);
|
|
31
|
+
/** Settles as `work` does, or rejects once `signal` aborts. */
|
|
32
|
+
function until(signal, work) {
|
|
33
|
+
return Promise.race([work, new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }))]);
|
|
34
|
+
}
|
|
35
|
+
/** Retry-After in seconds, from either form the header takes. */
|
|
36
|
+
function retryAfter(response) {
|
|
37
|
+
const header = response.headers.get("retry-after")?.trim();
|
|
38
|
+
if (!header)
|
|
39
|
+
return undefined;
|
|
40
|
+
const seconds = /^\d+$/.test(header) ? Number(header) : Math.ceil((Date.parse(header) - Date.now()) / 1000);
|
|
41
|
+
return Number.isFinite(seconds) ? Math.max(1, seconds) : undefined;
|
|
42
|
+
}
|
|
43
|
+
async function jsonObject(response) {
|
|
44
|
+
const value = await response.json().catch(() => undefined);
|
|
45
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
46
|
+
}
|
|
25
47
|
export function createDesk(options) {
|
|
26
48
|
const { config, signer, approveCommand } = options;
|
|
27
49
|
const fetchImpl = options.fetch ?? fetch;
|
|
28
50
|
let spent = 0n;
|
|
51
|
+
const account = () => (typeof signer === "function" ? signer() : signer);
|
|
52
|
+
let ledger;
|
|
53
|
+
// Kept per network and wallet, so a testnet wallet never spends from the mainnet limit.
|
|
54
|
+
const daily = () => config.daily === undefined
|
|
55
|
+
? undefined
|
|
56
|
+
: (ledger ??= dailyLedger(join(config.stateDir, `spend-${config.name}-${config.payOn}-${account().address.toLowerCase()}.json`), config.daily));
|
|
29
57
|
const endpoint = `${config.desk}/x402/tasks`;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
58
|
+
const recent = new Map();
|
|
59
|
+
/** When this process paid, or may have paid, for each order; the desk can take minutes to know one. */
|
|
60
|
+
const paidAt = new Map();
|
|
33
61
|
/** The desk's price, read from a fresh 402 challenge. */
|
|
34
|
-
async function quotedPrice() {
|
|
62
|
+
async function quotedPrice(signal = AbortSignal.timeout(15_000)) {
|
|
35
63
|
try {
|
|
36
64
|
const probe = await fetchImpl(endpoint, {
|
|
37
65
|
method: "POST",
|
|
38
66
|
headers: { "content-type": "application/json" },
|
|
39
67
|
body: "{}",
|
|
40
|
-
signal
|
|
68
|
+
signal,
|
|
41
69
|
});
|
|
42
70
|
const header = probe.headers.get("payment-required");
|
|
43
71
|
if (!header)
|
|
@@ -54,14 +82,18 @@ export function createDesk(options) {
|
|
|
54
82
|
async function explainUnsigned(error) {
|
|
55
83
|
const message = firstLine(error);
|
|
56
84
|
if (message.includes(`Payment creation aborted: ${BUDGET_REASON}`)) {
|
|
57
|
-
return `This server has already signed payments for ${formatUnits(spent)} of its ${formatUnits(config.budget)} ${config.token} budget.
|
|
85
|
+
return `This server has already signed payments for ${formatUnits(spent)} of its ${formatUnits(config.budget)} ${config.token} budget. The budget resets when the server restarts; raise BRAINCHAIN_BUDGET_UNITS to allow more per run. Nothing was charged.`;
|
|
86
|
+
}
|
|
87
|
+
if (message.includes(`Payment creation aborted: ${DAILY_REASON}`)) {
|
|
88
|
+
const today = await daily().spentToday().catch(() => undefined);
|
|
89
|
+
return `This wallet has already signed payments for ${today === undefined ? "most" : formatUnits(today)} of its ${formatUnits(config.daily)} ${config.token} daily limit. The limit resets at 00:00 UTC; raise BRAINCHAIN_DAILY_UNITS to allow more. Nothing was charged.`;
|
|
58
90
|
}
|
|
59
91
|
if (/rejected by spendControls/.test(message)) {
|
|
60
92
|
const price = await quotedPrice();
|
|
61
93
|
if (price !== undefined && price > config.maxPrice) {
|
|
62
|
-
return `The desk asks ${formatUnits(price)} ${config.token} per task, above this server's limit of ${formatUnits(config.maxPrice)}. Raise BRAINCHAIN_MAX_PRICE_UNITS to allow it. Nothing was charged.`;
|
|
94
|
+
return `The desk asks ${formatUnits(price)} ${config.token} per task, above this server's limit of ${formatUnits(config.maxPrice)} ${config.token}. Raise BRAINCHAIN_MAX_PRICE_UNITS and restart the server to allow it. Nothing was charged.`;
|
|
63
95
|
}
|
|
64
|
-
return `The desk asked for a payment outside this server's limits (${config.token} at ${config.asset}, at most ${formatUnits(config.maxPrice)} per task). Nothing was charged.`;
|
|
96
|
+
return `The desk asked for a payment outside this server's limits (${config.token} at ${config.asset}, at most ${formatUnits(config.maxPrice)} per task). Check BRAINCHAIN_PAY_ON and BRAINCHAIN_PAYMENT_ASSET. Nothing was charged.`;
|
|
65
97
|
}
|
|
66
98
|
if (/filtered out by policies/.test(message)) {
|
|
67
99
|
return `The desk asked to be paid on terms this server does not accept: the payee must be ${config.payTo} and the authorization may last at most ${MAX_TIMEOUT_SECONDS} seconds. Check BRAINCHAIN_NETWORK and BRAINCHAIN_PAY_ON, or set BRAINCHAIN_PAY_TO for a custom BRAINCHAIN_DESK_URL. Nothing was charged.`;
|
|
@@ -76,7 +108,7 @@ export function createDesk(options) {
|
|
|
76
108
|
return `The desk at ${config.desk} did not answer in time. Nothing was charged; try again.`;
|
|
77
109
|
}
|
|
78
110
|
if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN/i.test(message)) {
|
|
79
|
-
return `Could not reach the desk at ${config.desk}. Nothing was charged.`;
|
|
111
|
+
return `Could not reach the desk at ${config.desk}. Nothing was charged. Check your connection or BRAINCHAIN_DESK_URL and try again.`;
|
|
80
112
|
}
|
|
81
113
|
return `The payment could not be prepared: ${message}. Nothing was charged.`;
|
|
82
114
|
}
|
|
@@ -86,32 +118,69 @@ export function createDesk(options) {
|
|
|
86
118
|
if (!options.funds)
|
|
87
119
|
return fallback;
|
|
88
120
|
try {
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
121
|
+
// One deadline for both reads, so a slow chain cannot hold the reply.
|
|
122
|
+
const deadline = AbortSignal.timeout(10_000);
|
|
123
|
+
const [{ balance, allowance }, quoted] = await until(deadline, Promise.all([options.funds(), quotedPrice(deadline)]));
|
|
124
|
+
const needed = quoted ?? config.maxPrice;
|
|
125
|
+
if (balance < needed) {
|
|
126
|
+
const { address } = account();
|
|
127
|
+
return `The paying wallet holds ${formatUnits(balance)} ${config.token} on ${config.chain}; a task costs ${formatUnits(needed)}. Nothing was charged. Send at least ${formatUnits(needed - balance)} ${config.token} to ${address} on ${config.chain}, then post again.`;
|
|
128
|
+
}
|
|
93
129
|
if (config.permit2 && allowance !== undefined && allowance < needed) {
|
|
94
|
-
return `Permit2
|
|
130
|
+
return `Permit2 is not yet approved to spend this wallet's ${config.token}. Run \`${approveCommand}\` once with the same BRAINCHAIN_PAYER_KEY; it needs a little ETH on ${config.chain}. Nothing was charged.`;
|
|
95
131
|
}
|
|
96
132
|
}
|
|
97
133
|
catch {
|
|
98
|
-
// The chain read failed; the desk's own reason is the best explanation left.
|
|
134
|
+
// The chain read failed or ran out of time; the desk's own reason is the best explanation left.
|
|
99
135
|
}
|
|
100
136
|
return fallback;
|
|
101
137
|
}
|
|
102
|
-
function possiblyCharged(
|
|
138
|
+
function possiblyCharged(receipt, cause) {
|
|
103
139
|
return {
|
|
104
|
-
|
|
140
|
+
...receipt,
|
|
105
141
|
possiblyCharged: true,
|
|
106
|
-
error: `${cause} The payment may have gone through.
|
|
142
|
+
error: `${cause} The payment may have gone through. Read this order with brainchain_get_task; do not post the task again.`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** An agent that retries a post it gave up waiting on gets the first order back instead of paying twice. */
|
|
146
|
+
function post(task) {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
for (const [key, entry] of recent)
|
|
149
|
+
if (now - entry.at >= REPOST_AFTER_MINUTES * 60_000)
|
|
150
|
+
recent.delete(key);
|
|
151
|
+
const key = canonicalStringify({ network: config.network, title: task.title, brief: task.brief, levels: task.levels });
|
|
152
|
+
const previous = recent.get(key);
|
|
153
|
+
if (previous)
|
|
154
|
+
return previous.reply.then((reply) => ({ ...reply, duplicate: true }));
|
|
155
|
+
const entry = { at: now, reply: pay(task) };
|
|
156
|
+
recent.set(key, entry);
|
|
157
|
+
const forget = () => {
|
|
158
|
+
if (recent.get(key) === entry)
|
|
159
|
+
recent.delete(key);
|
|
107
160
|
};
|
|
161
|
+
entry.reply.then((reply) => {
|
|
162
|
+
const { order } = reply;
|
|
163
|
+
if (typeof order === "string")
|
|
164
|
+
paidAt.set(order.toLowerCase(), now);
|
|
165
|
+
else
|
|
166
|
+
forget();
|
|
167
|
+
}, forget);
|
|
168
|
+
return entry.reply;
|
|
108
169
|
}
|
|
109
|
-
async function
|
|
170
|
+
async function pay(task) {
|
|
110
171
|
let signed;
|
|
111
172
|
let amount = 0n;
|
|
173
|
+
let reservation;
|
|
174
|
+
const unreserve = async () => {
|
|
175
|
+
spent -= amount;
|
|
176
|
+
amount = 0n;
|
|
177
|
+
if (reservation)
|
|
178
|
+
await daily().release(reservation);
|
|
179
|
+
reservation = undefined;
|
|
180
|
+
};
|
|
181
|
+
const receipt = (order) => ({ order, price: `${formatUnits(amount)} ${config.token}`, statusUrl: `${endpoint}/${order}` });
|
|
112
182
|
const client = x402Client.fromConfig({
|
|
113
|
-
schemes: [{ network: config.network, client: new ExactEvmScheme(
|
|
114
|
-
// Only this network's token, never above the per-task cap.
|
|
183
|
+
schemes: [{ network: config.network, client: new ExactEvmScheme(account()) }],
|
|
115
184
|
spendControls: { allowedAssets: [{ network: config.network, asset: config.asset, maxAmountPerPayment: config.maxPrice.toString() }] },
|
|
116
185
|
policies: [
|
|
117
186
|
(_version, requirements) => requirements.filter((r) => r.network === config.network &&
|
|
@@ -120,23 +189,27 @@ export function createDesk(options) {
|
|
|
120
189
|
r.maxTimeoutSeconds <= MAX_TIMEOUT_SECONDS),
|
|
121
190
|
],
|
|
122
191
|
});
|
|
123
|
-
// A payment is reserved against the budget before it is
|
|
124
|
-
// calls cannot overshoot
|
|
192
|
+
// A payment is reserved against the budget and the daily limit before it is
|
|
193
|
+
// signed, so parallel calls cannot overshoot either.
|
|
125
194
|
client.onBeforePaymentCreation(async ({ selectedRequirements }) => {
|
|
126
195
|
const price = BigInt(selectedRequirements.amount);
|
|
127
|
-
if (spent
|
|
196
|
+
if (spent + price > config.budget)
|
|
128
197
|
return { abort: true, reason: BUDGET_REASON };
|
|
129
|
-
|
|
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
|
+
spent += price;
|
|
130
205
|
amount = price;
|
|
131
206
|
});
|
|
132
207
|
client.onAfterPaymentCreation(async ({ paymentPayload }) => {
|
|
133
208
|
signed = paymentPayload;
|
|
134
209
|
});
|
|
135
210
|
client.onPaymentCreationFailure(async () => {
|
|
136
|
-
if (signed)
|
|
137
|
-
|
|
138
|
-
spent -= amount;
|
|
139
|
-
amount = 0n;
|
|
211
|
+
if (!signed)
|
|
212
|
+
await unreserve();
|
|
140
213
|
});
|
|
141
214
|
let response;
|
|
142
215
|
try {
|
|
@@ -144,25 +217,29 @@ export function createDesk(options) {
|
|
|
144
217
|
method: "POST",
|
|
145
218
|
headers: { "content-type": "application/json" },
|
|
146
219
|
body: JSON.stringify(task),
|
|
147
|
-
|
|
220
|
+
// Short enough that a possiblyCharged reply reaches a client that gives up after 60 seconds.
|
|
221
|
+
signal: AbortSignal.timeout(options.postTimeoutMs ?? 45_000),
|
|
148
222
|
});
|
|
149
223
|
}
|
|
150
224
|
catch (error) {
|
|
151
225
|
if (signed)
|
|
152
|
-
return possiblyCharged(orderIdOf(signed), `The desk did not confirm the order (${firstLine(error)}).`);
|
|
226
|
+
return possiblyCharged(receipt(orderIdOf(signed)), `The desk did not confirm the order (${firstLine(error)}).`);
|
|
153
227
|
throw new Error(await explainUnsigned(error));
|
|
154
228
|
}
|
|
155
|
-
const reply = await
|
|
156
|
-
if (response.ok)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
229
|
+
const reply = (await jsonObject(response)) ?? {};
|
|
230
|
+
if (response.ok) {
|
|
231
|
+
if (!signed) {
|
|
232
|
+
throw new Error(`The desk at ${config.desk} answered ${response.status} without asking for payment, so no task was posted. Check BRAINCHAIN_DESK_URL. Nothing was charged.`);
|
|
233
|
+
}
|
|
234
|
+
return { ...reply, ...receipt(typeof reply.order === "string" ? reply.order : orderIdOf(signed)) };
|
|
235
|
+
}
|
|
236
|
+
// The desk answers 400 (a brief it refuses, including one too large to post)
|
|
237
|
+
// and 402 (a payment it refuses) before settling. Any other answer to a
|
|
238
|
+
// signed payment may have charged it.
|
|
160
239
|
if (signed && response.status !== 400 && response.status !== 402) {
|
|
161
|
-
return possiblyCharged(typeof reply.order === "string" ? reply.order : orderIdOf(signed), `The desk answered ${response.status}.`);
|
|
240
|
+
return possiblyCharged(receipt(typeof reply.order === "string" ? reply.order : orderIdOf(signed)), `The desk answered ${response.status}.`);
|
|
162
241
|
}
|
|
163
|
-
|
|
164
|
-
spent -= amount;
|
|
165
|
-
amount = 0n;
|
|
242
|
+
await unreserve();
|
|
166
243
|
if (response.status === 402)
|
|
167
244
|
throw new Error(await explainRefusal(reply.error));
|
|
168
245
|
throw new Error(typeof reply.error === "string" ? `${reply.error} Nothing was charged.` : `The desk answered ${response.status}. Nothing was charged.`);
|
|
@@ -173,31 +250,46 @@ export function createDesk(options) {
|
|
|
173
250
|
response = await fetchImpl(`${endpoint}/${order}`, { signal: AbortSignal.timeout(20_000) });
|
|
174
251
|
}
|
|
175
252
|
catch (error) {
|
|
176
|
-
return { order, error: `Could not reach the desk (${firstLine(error)}).`, done: false, next: "
|
|
253
|
+
return { order, error: `Could not reach the desk (${firstLine(error)}).`, done: false, next: "Read the order again after retryAfterSeconds.", retryAfterSeconds: 30 };
|
|
177
254
|
}
|
|
178
|
-
const view = await
|
|
179
|
-
// A payment authorization lasts at most MAX_TIMEOUT_SECONDS, so an order the
|
|
180
|
-
// desk still does not know after that was never charged.
|
|
255
|
+
const view = await jsonObject(response);
|
|
181
256
|
if (response.status === 404) {
|
|
257
|
+
const at = paidAt.get(order);
|
|
258
|
+
if (at === undefined || Date.now() - at >= REPOST_AFTER_MINUTES * 60_000) {
|
|
259
|
+
return {
|
|
260
|
+
order,
|
|
261
|
+
error: "The desk has no order with this id.",
|
|
262
|
+
done: true,
|
|
263
|
+
next: `Check the order id: it must be one that brainchain_post_task returned. If you posted it more than ${REPOST_AFTER_MINUTES} minutes ago, it was never charged.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
182
266
|
return {
|
|
183
267
|
order,
|
|
184
268
|
error: "The desk has no order with this id.",
|
|
185
269
|
done: false,
|
|
186
|
-
next: `If brainchain_post_task said possiblyCharged, the payment either never reached the desk or is still settling. Read the order again in a minute. If the desk still has no record ${
|
|
270
|
+
next: `If brainchain_post_task said possiblyCharged, the payment either never reached the desk or is still settling. Read the order again in a minute. If the desk still has no record ${REPOST_AFTER_MINUTES} minutes after you posted, nothing was charged and you can post the task again.`,
|
|
187
271
|
retryAfterSeconds: 60,
|
|
188
272
|
};
|
|
189
273
|
}
|
|
190
|
-
if (response.status >= 500) {
|
|
191
|
-
return { order, error: `The desk answered ${response.status}.`, done: false, next: "The desk is briefly unavailable. Read the order again
|
|
274
|
+
if (response.status >= 500 || RETRY_LATER.has(response.status)) {
|
|
275
|
+
return { order, error: `The desk answered ${response.status}.`, done: false, next: "The desk is busy or briefly unavailable. Read the order again after retryAfterSeconds.", retryAfterSeconds: retryAfter(response) ?? 30 };
|
|
192
276
|
}
|
|
193
277
|
if (!response.ok)
|
|
194
|
-
throw new Error(typeof view
|
|
278
|
+
throw new Error(typeof view?.error === "string" ? view.error : `The desk answered ${response.status}.`);
|
|
279
|
+
// An unreadable 2xx comes from a proxy or a dropped connection; ask again.
|
|
280
|
+
if (!view)
|
|
281
|
+
return { order, error: "The desk's reply could not be read.", done: false, next: "Read the order again shortly.", retryAfterSeconds: 30 };
|
|
195
282
|
// Results are served by the desk itself; anything else is left as a link.
|
|
196
283
|
if (typeof view.result === "string" && view.result.startsWith(`${config.desk}/records/`)) {
|
|
197
284
|
try {
|
|
198
285
|
const text = await fetchImpl(view.result, { signal: AbortSignal.timeout(20_000) });
|
|
199
|
-
if (text.ok)
|
|
200
|
-
|
|
286
|
+
if (text.ok) {
|
|
287
|
+
const full = await text.text();
|
|
288
|
+
view.resultText =
|
|
289
|
+
full.length <= RESULT_LIMIT
|
|
290
|
+
? full
|
|
291
|
+
: `${full.slice(0, RESULT_LIMIT)}\n\n[Cut at ${RESULT_LIMIT.toLocaleString("en-US")} of ${full.length.toLocaleString("en-US")} characters. The full result is at ${view.result}.]`;
|
|
292
|
+
}
|
|
201
293
|
else
|
|
202
294
|
view.resultError = `The result could not be fetched (HTTP ${text.status}); it is at ${view.result}.`;
|
|
203
295
|
}
|
|
@@ -213,15 +305,18 @@ export function createDesk(options) {
|
|
|
213
305
|
export function guidance(view, config) {
|
|
214
306
|
const task = (view.task ?? {});
|
|
215
307
|
const wait = (retryAfterSeconds, next) => ({ done: false, next, retryAfterSeconds });
|
|
308
|
+
const why = typeof view.refundReason === "string" ? view.refundReason : "The work was not accepted.";
|
|
309
|
+
// The board refunds a step that misses its deadline; saying when lets the caller stop waiting at the right time.
|
|
310
|
+
const unless = (missed) => (typeof task.due?.by === "string" ? ` If ${missed} by ${task.due.by}, the full amount is refunded.` : "");
|
|
216
311
|
const accepted = task.accepted === true || view.state === "paid-out" || task.status === "paid";
|
|
217
312
|
if (typeof view.resultText === "string" && accepted) {
|
|
218
|
-
return { done: true, next: `Jev accepted the work; the result
|
|
313
|
+
return { done: true, next: `Jev accepted the work; the result follows this summary. The model is paid when the dispute window closes, ${config.terms.finalAfter} after grading.` };
|
|
219
314
|
}
|
|
220
315
|
switch (view.state) {
|
|
221
316
|
case "paid-out":
|
|
222
317
|
return { done: true, next: "Jev accepted the work and the model was paid." };
|
|
223
318
|
case "refunded":
|
|
224
|
-
return { done: true, next:
|
|
319
|
+
return { done: true, next: `${why} The full amount was refunded on ${config.chain}${typeof view.refundTx === "string" ? ` in ${view.refundTx}` : ""}.` };
|
|
225
320
|
case "refund-failed":
|
|
226
321
|
return { done: true, next: "The refund could not be sent automatically. Write to hello@usebrainchain.com with this order id." };
|
|
227
322
|
case "failed":
|
|
@@ -233,19 +328,19 @@ export function guidance(view, config) {
|
|
|
233
328
|
case "unconfirmed":
|
|
234
329
|
return wait(30, "The payment may have gone through; BrainChain is checking it against the chain. Do not post the task again.");
|
|
235
330
|
case "refunding":
|
|
236
|
-
return wait(60,
|
|
331
|
+
return wait(60, `${why} The refund is being sent on ${config.chain}.`);
|
|
237
332
|
}
|
|
238
333
|
if (task.accepted === false) {
|
|
239
334
|
return { done: true, next: `Jev did not accept the work, so the result is withheld. The full amount is refunded on ${config.chain} when the dispute window closes, ${config.terms.finalAfter} after grading.` };
|
|
240
335
|
}
|
|
241
336
|
switch (task.status) {
|
|
242
337
|
case "open":
|
|
243
|
-
return wait(60,
|
|
338
|
+
return wait(60, `Waiting for Jev to hire a model.${unless("the work is not delivered")}`);
|
|
244
339
|
case "hired":
|
|
245
|
-
return wait(60,
|
|
340
|
+
return wait(60, `A model is working on the task.${unless("the work is not delivered")}`);
|
|
246
341
|
case "delivered":
|
|
247
342
|
case "submitted":
|
|
248
|
-
return wait(30,
|
|
343
|
+
return wait(30, `The model delivered; Jev is grading the work.${unless("Jev has not graded it")}`);
|
|
249
344
|
case "disputed":
|
|
250
345
|
return wait(600, "The grade is disputed; Jev will rule on it.");
|
|
251
346
|
case "graded":
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { createPublicClient, defineChain, http, parseAbi } from "viem";
|
|
4
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
5
|
+
import { resolveConfig } from "./config.js";
|
|
6
|
+
import { createDesk } from "./payments.js";
|
|
7
|
+
import { callTool, GET_TASK_NAME, GET_TASK_TOOL, POST_TASK_NAME, postTaskTool } from "./tools.js";
|
|
8
|
+
/** The canonical Permit2 contract; x402 payments in USDG are signed Permit2 transfers. */
|
|
9
|
+
export const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
|
10
|
+
export const erc20 = parseAbi([
|
|
11
|
+
"function balanceOf(address owner) view returns (uint256)",
|
|
12
|
+
"function allowance(address owner, address spender) view returns (uint256)",
|
|
13
|
+
"function approve(address spender, uint256 amount) returns (bool)",
|
|
14
|
+
]);
|
|
15
|
+
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
|
+
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=…\`), then restart the server. ` +
|
|
18
|
+
`${GET_TASK_NAME} works without a key.`;
|
|
19
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
20
|
+
export function payerAccount(env) {
|
|
21
|
+
const raw = env.BRAINCHAIN_PAYER_KEY?.trim();
|
|
22
|
+
// A client that cannot expand ${VAR} passes it through as written. Anything
|
|
23
|
+
// that could be key material is never echoed.
|
|
24
|
+
if (raw?.startsWith("$")) {
|
|
25
|
+
const shown = /[0-9a-f]{16,}/i.test(raw) ? "" : ` ${raw}`;
|
|
26
|
+
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
|
+
}
|
|
28
|
+
const key = raw?.replace(/^0x/i, "");
|
|
29
|
+
if (!key)
|
|
30
|
+
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
|
+
if (!/^[0-9a-f]{64}$/i.test(key))
|
|
32
|
+
throw new Error("BRAINCHAIN_PAYER_KEY must be 64 hex characters, with or without 0x.");
|
|
33
|
+
try {
|
|
34
|
+
return privateKeyToAccount(`0x${key}`);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error("BRAINCHAIN_PAYER_KEY is not a valid private key.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function chainClients(config) {
|
|
41
|
+
const chain = defineChain({
|
|
42
|
+
id: config.chainId,
|
|
43
|
+
name: config.chain,
|
|
44
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
45
|
+
rpcUrls: { default: { http: [config.rpc] } },
|
|
46
|
+
});
|
|
47
|
+
return { chain, reader: createPublicClient({ chain, transport: http(config.rpc, { retryCount: 2, timeout: 15_000 }) }) };
|
|
48
|
+
}
|
|
49
|
+
export function approveCommand(config, version) {
|
|
50
|
+
return `${config.name === "testnet" ? "BRAINCHAIN_NETWORK=testnet " : ""}npx -y brainchain-mcp@${version} approve`;
|
|
51
|
+
}
|
|
52
|
+
/** A missing key or a bad setting is reported when a tool needs it, so the server always starts and lists its tools. */
|
|
53
|
+
function deskFor(env, version, fetchImpl) {
|
|
54
|
+
let config;
|
|
55
|
+
try {
|
|
56
|
+
config = resolveConfig(env);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const problem = `${messageOf(error)} Fix it in this server's environment and restart the server.`;
|
|
60
|
+
const refuse = async () => {
|
|
61
|
+
throw new Error(problem);
|
|
62
|
+
};
|
|
63
|
+
return { desk: { post: refuse, get: refuse }, terms: problem };
|
|
64
|
+
}
|
|
65
|
+
let account;
|
|
66
|
+
const payer = () => {
|
|
67
|
+
try {
|
|
68
|
+
return (account ??= payerAccount(env));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw new Error(`${messageOf(error)} ${KEY_HELP}`);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const { reader } = chainClients(config);
|
|
75
|
+
const desk = createDesk({
|
|
76
|
+
config,
|
|
77
|
+
signer: payer,
|
|
78
|
+
approveCommand: approveCommand(config, version),
|
|
79
|
+
fetch: fetchImpl,
|
|
80
|
+
funds: async () => {
|
|
81
|
+
const { address } = payer();
|
|
82
|
+
const [balance, allowance] = await Promise.all([
|
|
83
|
+
reader.readContract({ address: config.asset, abi: erc20, functionName: "balanceOf", args: [address] }),
|
|
84
|
+
config.permit2 ? reader.readContract({ address: config.asset, abi: erc20, functionName: "allowance", args: [address, PERMIT2] }) : undefined,
|
|
85
|
+
]);
|
|
86
|
+
return { balance, allowance };
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
return { desk, terms: config.terms };
|
|
90
|
+
}
|
|
91
|
+
export function createServer(env, version, options = {}) {
|
|
92
|
+
const { desk, terms } = deskFor(env, version, options.fetch);
|
|
93
|
+
if (typeof terms === "string")
|
|
94
|
+
(options.log ?? console.error)(`brainchain-mcp has an invalid setting: ${terms}`);
|
|
95
|
+
const tools = [postTaskTool(terms), GET_TASK_TOOL];
|
|
96
|
+
const server = new Server({ name: "brainchain", title: "BrainChain", version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
97
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
|
|
98
|
+
server.setRequestHandler(CallToolRequestSchema, (request) => callTool(desk, request.params.name, request.params.arguments));
|
|
99
|
+
return server;
|
|
100
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
import { REPOST_AFTER_MINUTES } from "./config.js";
|
|
1
2
|
export const POST_TASK_NAME = "brainchain_post_task";
|
|
2
3
|
export const GET_TASK_NAME = "brainchain_get_task";
|
|
4
|
+
/** Given a configuration error instead of terms, the description leads with it. */
|
|
3
5
|
export function postTaskTool(terms) {
|
|
6
|
+
const about = `Pay for a task on BrainChain. Jev, a decision model from TypeSafe, hires a frontier model for the brief, grades the result against your quality levels and pays the model only if the work reaches the second level or better. ` +
|
|
7
|
+
`Briefs and results are public, so never include credentials or code that cannot be published. `;
|
|
4
8
|
return {
|
|
5
9
|
name: POST_TASK_NAME,
|
|
6
10
|
title: "Post a paid task to BrainChain",
|
|
7
|
-
description:
|
|
8
|
-
`
|
|
9
|
-
|
|
11
|
+
description: typeof terms === "string"
|
|
12
|
+
? `Invalid setting: ${terms} Until then this tool only returns that error. ${about}`
|
|
13
|
+
: about +
|
|
14
|
+
`Each task costs ${terms.cost}, paid from this server's 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. If no model delivers within 24 hours, the full amount is refunded. ` +
|
|
15
|
+
`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}. ` +
|
|
16
|
+
`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.`,
|
|
10
17
|
inputSchema: {
|
|
11
18
|
type: "object",
|
|
12
19
|
properties: {
|
|
@@ -14,7 +21,7 @@ export function postTaskTool(terms) {
|
|
|
14
21
|
brief: {
|
|
15
22
|
type: "string",
|
|
16
23
|
minLength: 1,
|
|
17
|
-
maxLength:
|
|
24
|
+
maxLength: 32000,
|
|
18
25
|
description: "What to produce, in what format, and anything the model must not do. Models cannot browse, so include every fact they need.",
|
|
19
26
|
},
|
|
20
27
|
levels: {
|
|
@@ -35,30 +42,41 @@ export const GET_TASK_TOOL = {
|
|
|
35
42
|
name: GET_TASK_NAME,
|
|
36
43
|
title: "Read a BrainChain order",
|
|
37
44
|
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. " +
|
|
38
|
-
"
|
|
45
|
+
"Call again after `retryAfterSeconds` until `done` is true. `next` says what the order's state means and what to do.",
|
|
39
46
|
inputSchema: {
|
|
40
47
|
type: "object",
|
|
41
|
-
properties: { order: { type: "string", pattern: "^0x[0-9a-
|
|
48
|
+
properties: { order: { type: "string", pattern: "^0x[0-9a-fA-F]{64}$", description: "Order id returned by brainchain_post_task." } },
|
|
42
49
|
required: ["order"],
|
|
43
50
|
additionalProperties: false,
|
|
44
51
|
},
|
|
45
52
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
46
53
|
};
|
|
47
|
-
|
|
54
|
+
const trimmed = (value, max) => (typeof value === "string" && value.trim().length > 0 && value.trim().length <= max ? value.trim() : undefined);
|
|
55
|
+
/** The desk's own limits, applied to what is sent, before anything is signed. Returns an error message, or the trimmed brief. */
|
|
48
56
|
export function checkBrief(input) {
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
57
|
+
const title = trimmed(input.title, 120);
|
|
58
|
+
if (!title)
|
|
51
59
|
return "title must be 1 to 120 characters.";
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
const brief = trimmed(input.brief, 32_000);
|
|
61
|
+
if (!brief)
|
|
62
|
+
return "brief must be 1 to 32,000 characters.";
|
|
63
|
+
const levels = Array.isArray(input.levels) ? input.levels.map((l) => trimmed(l, 200)) : [];
|
|
64
|
+
if (levels.length < 2 || levels.length > 3 || !levels.every((l) => l !== undefined)) {
|
|
55
65
|
return "levels must list 2 or 3 quality levels, best first, each 1 to 200 characters.";
|
|
56
66
|
}
|
|
57
|
-
return { title
|
|
67
|
+
return { title, brief, levels };
|
|
58
68
|
}
|
|
59
69
|
function reply(value, isError = false) {
|
|
60
70
|
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], isError };
|
|
61
71
|
}
|
|
72
|
+
/** The result text goes out as its own block, as written, rather than escaped inside the JSON. */
|
|
73
|
+
function orderReply(view) {
|
|
74
|
+
const { resultText, ...rest } = view;
|
|
75
|
+
const result = reply(rest);
|
|
76
|
+
if (typeof resultText === "string")
|
|
77
|
+
result.content.push({ type: "text", text: resultText });
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
62
80
|
export async function callTool(desk, name, args) {
|
|
63
81
|
const input = (args ?? {});
|
|
64
82
|
try {
|
|
@@ -72,7 +90,7 @@ export async function callTool(desk, name, args) {
|
|
|
72
90
|
const order = typeof input.order === "string" ? input.order.trim().toLowerCase() : "";
|
|
73
91
|
if (!/^0x[0-9a-f]{64}$/.test(order))
|
|
74
92
|
return reply({ error: "order must be the 0x-prefixed, 64-character hex id that brainchain_post_task returned." }, true);
|
|
75
|
-
return
|
|
93
|
+
return orderReply(await desk.get(order));
|
|
76
94
|
}
|
|
77
95
|
return reply({ error: `Unknown tool ${name}.` }, true);
|
|
78
96
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for BrainChain: hire frontier
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"author": "BrainChain <hello@usebrainchain.com>",
|