brainchain-mcp 0.5.1 → 0.6.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 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.5.1
19
+ claude mcp add brainchain --scope user -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.6.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.5.1"],
30
+ "args": ["-y", "brainchain-mcp@0.6.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
- [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=brainchain&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJyYWluY2hhaW4tbWNwQDAuNS4xIl0sImVudiI6eyJCUkFJTkNIQUlOX1BBWUVSX0tFWSI6IiR7ZW52OkJSQUlOQ0hBSU5fUEFZRVJfS0VZfSJ9fQ%3D%3D)
39
+ [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=brainchain&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJyYWluY2hhaW4tbWNwQDAuNi4xIl0sImVudiI6eyJCUkFJTkNIQUlOX1BBWUVSX0tFWSI6IiR7ZW52OkJSQUlOQ0hBSU5fUEFZRVJfS0VZfSJ9fQ%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.5.1"],
48
+ "args": ["-y", "brainchain-mcp@0.6.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.5.1"],
66
+ "args": ["-y", "brainchain-mcp@0.6.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.5.1"],
85
+ "args": ["-y", "brainchain-mcp@0.6.1"],
86
86
  "env": { "BRAINCHAIN_PAYER_KEY": "${input:brainchain-key}" }
87
87
  }
88
88
  },
@@ -100,10 +100,10 @@ Save the key in a file only your account can read, then point the server at it:
100
100
  mkdir -p ~/.brainchain
101
101
  (umask 077; printf 'Key: ' && read -rs key && echo && printf '%s\n' "$key" > ~/.brainchain/payer.key)
102
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"}}'
103
+ openclaw mcp set brainchain '{"command":"npx","args":["-y","brainchain-mcp@0.6.1"],"env":{"BRAINCHAIN_PAYER_KEY_FILE":"~/.brainchain/payer.key"}}'
104
104
  ```
105
105
 
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.
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.6.1 wallet` checks the file and prints the wallet it holds and its balance. [`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.
107
107
 
108
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).
109
109
 
@@ -117,7 +117,7 @@ Each brief is posted on BrainChain and each result is served at a public link. K
117
117
 
118
118
  ```bash
119
119
  printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo && export BRAINCHAIN_PAYER_KEY
120
- npx -y brainchain-mcp@0.5.1 approve
120
+ npx -y brainchain-mcp@0.6.1 approve
121
121
  unset BRAINCHAIN_PAYER_KEY
122
122
  ```
123
123
 
@@ -133,8 +133,9 @@ An agent with a shell on the same account can read whatever that account can, in
133
133
 
134
134
  | Tool | What it does |
135
135
  | --- | --- |
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. |
136
+ | `brainchain_post_task` | Pays for a task and returns an order id, the price paid and a `statusUrl`. Takes a one-line `title` of up to 120 characters, a `brief` of up to 32,000 characters and two or three different quality `levels` of up to 200 characters each, best first. The whole task can be at most 64,000 bytes as JSON, so a brief in Chinese, Japanese or Korean fits about 21,000 characters. An emoji counts as two characters. 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. |
137
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. |
138
+ | `brainchain_stock_premiums` | Takes nothing and returns what tokenized US stocks cost on Robinhood Chain against their Chainlink price right now: pool price, Chainlink price, premium in percent and the feed's age for NVDA, SPY, AAPL, TSLA, GOOGL, META and MSFT. Free, no key, read from the chain. The same board is at [usebrainchain.com/premium](https://usebrainchain.com/premium). |
138
139
 
139
140
  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.
140
141
 
@@ -162,7 +163,7 @@ Every hire and grade is recorded on BrainChain and listed at [usebrainchain.com/
162
163
  | `BRAINCHAIN_STATE_DIR` | `~/.brainchain` | Where the daily total is kept, one file per network and wallet: absolute or starting with `~/`. |
163
164
  | `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. |
164
165
 
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.
166
+ 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, and so is a `BRAINCHAIN_STATE_DIR` that cannot hold the daily limit's record when `BRAINCHAIN_DAILY_UNITS` is set. `npx -y brainchain-mcp@0.6.1 wallet` checks the settings and the key, and prints the wallet that pays, what it holds and, for USDG, what Permit2 may spend. `--help` lists the commands and variables, and `--version` prints the version.
166
167
 
167
168
  ## Try it on testnet
168
169
 
@@ -170,7 +171,7 @@ Set `BRAINCHAIN_NETWORK=testnet` in the server's environment. Test tokens have n
170
171
 
171
172
  ```bash
172
173
  printf 'Key: ' && read -rs BRAINCHAIN_PAYER_KEY && echo
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
174
+ claude mcp add brainchain --scope user -e BRAINCHAIN_NETWORK=testnet -e BRAINCHAIN_PAYER_KEY="$BRAINCHAIN_PAYER_KEY" -- npx -y brainchain-mcp@0.6.1
174
175
  unset BRAINCHAIN_PAYER_KEY
175
176
  ```
176
177
 
@@ -189,7 +190,7 @@ unset BRAINCHAIN_PAYER_KEY
189
190
 
190
191
  ```bash
191
192
  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
+ BRAINCHAIN_NETWORK=testnet npx -y brainchain-mcp@0.6.1 approve
193
194
  unset BRAINCHAIN_PAYER_KEY
194
195
  ```
195
196
 
package/dist/daily.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // The daily limit is kept on disk, so restarting the server, or a second server
2
2
  // paying from the same wallet, does not reset what the wallet may spend today.
3
+ import { accessSync, constants, mkdirSync } from "node:fs";
3
4
  import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
5
  import { dirname } from "node:path";
5
6
  const LOCK_STALE_MS = 10_000;
@@ -10,6 +11,28 @@ const TAKEOVER_STALE_MS = 5_000;
10
11
  export function utcDay(now) {
11
12
  return new Date(now).toISOString().slice(0, 10);
12
13
  }
14
+ function stateDirMessage(dir, error) {
15
+ const { code } = error;
16
+ const which = code === "EEXIST" || code === "ENOTDIR"
17
+ ? "is not a folder"
18
+ : code === "EACCES" || code === "EPERM"
19
+ ? "this account cannot write to"
20
+ : code === "EROFS"
21
+ ? "is on a read-only disk"
22
+ : `cannot be used (${code ?? String(error)})`;
23
+ return `BRAINCHAIN_STATE_DIR is ${dir}, which ${which}, so the daily limit's record cannot be kept there. Set it to a folder this account can write, then restart the server.`;
24
+ }
25
+ /** Why the daily limit's record cannot be kept in `dir`, creating the folder if it is missing; undefined when it can. */
26
+ export function stateDirProblem(dir) {
27
+ try {
28
+ mkdirSync(dir, { recursive: true });
29
+ accessSync(dir, constants.W_OK);
30
+ return undefined;
31
+ }
32
+ catch (error) {
33
+ return stateDirMessage(dir, error);
34
+ }
35
+ }
13
36
  /** How long ago a file was last written, or 0 when it is gone. */
14
37
  const ageOf = (file) => stat(file).then((s) => Date.now() - s.mtimeMs, () => 0);
15
38
  /**
@@ -42,7 +65,9 @@ async function clearStale(lockPath) {
42
65
  export function dailyLedger(path, limit, now = Date.now) {
43
66
  const lockPath = `${path}.lock`;
44
67
  async function locked(work) {
45
- await mkdir(dirname(path), { recursive: true });
68
+ await mkdir(dirname(path), { recursive: true }).catch((error) => {
69
+ throw new Error(stateDirMessage(dirname(path), error));
70
+ });
46
71
  const deadline = Date.now() + LOCK_WAIT_MS;
47
72
  for (;;) {
48
73
  try {
@@ -51,7 +76,7 @@ export function dailyLedger(path, limit, now = Date.now) {
51
76
  }
52
77
  catch (error) {
53
78
  if (error.code !== "EEXIST")
54
- throw error;
79
+ throw new Error(stateDirMessage(dirname(path), error));
55
80
  }
56
81
  if ((await ageOf(lockPath)) > LOCK_STALE_MS && (await clearStale(lockPath)))
57
82
  continue;
package/dist/index.js CHANGED
@@ -3,18 +3,20 @@ 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 { approvalFailure, chainClients, createServer, erc20, payerAccount, PERMIT2 } from "./server.js";
6
+ import { stateDirProblem } from "./daily.js";
7
+ import { approvalFailure, approveCommand, chainClients, createServer, erc20, payerAccount, PERMIT2, walletFunds, walletReport } from "./server.js";
7
8
  const { version } = createRequire(import.meta.url)("../package.json");
8
9
  const USAGE = `brainchain-mcp ${version}
9
10
 
10
- MCP server that pays for tasks on BrainChain and reads their graded results.
11
+ MCP server that pays for tasks on BrainChain, reads their graded results, and reads what stock tokens cost on Robinhood Chain.
11
12
 
12
13
  Usage:
13
14
  brainchain-mcp Start the server on stdio (what MCP clients run).
14
15
  brainchain-mcp approve [USDG] Let Permit2 spend the paying wallet's USDG.
15
16
  Default 100 USDG; pass a number, "max", or 0 to revoke.
16
17
  Not needed when paying in USDC on Base.
17
- brainchain-mcp wallet Check the key and settings, and print the wallet that pays.
18
+ brainchain-mcp wallet Check the key and settings, and print the wallet that pays, what it
19
+ holds and, for USDG, what Permit2 may spend.
18
20
 
19
21
  Environment:
20
22
  BRAINCHAIN_PAYER_KEY Private key of the paying wallet, 64 hex characters with or without 0x.
@@ -46,23 +48,34 @@ if (command === "--version" || command === "-v" || command === "version") {
46
48
  }
47
49
  if (command !== undefined && command !== "approve" && command !== "wallet")
48
50
  fail(`Unknown command ${command}.\n\n${USAGE}`);
51
+ const extra = process.argv[command === "approve" ? 4 : 3];
52
+ if (command !== undefined && extra !== undefined)
53
+ fail(`Unexpected argument ${extra}: ${command === "approve" ? "approve takes one amount" : "wallet takes none"}.\n\n${USAGE}`);
49
54
  if (command === undefined) {
50
55
  await createServer(process.env, version).connect(new StdioServerTransport());
51
56
  }
52
57
  else if (command === "wallet") {
53
58
  const { config, account } = settings();
54
- console.log(`${account.address} pays for tasks in ${config.token} on ${config.chain}.`);
59
+ const funds = await walletFunds(config, account.address).catch((error) => (error instanceof Error ? error : new Error(String(error))));
60
+ console.log(walletReport(config, account.address, funds, approveCommand(config, version)));
55
61
  }
56
62
  else {
57
63
  await approve(process.argv[3] ?? "100");
58
64
  }
59
65
  function settings() {
66
+ let config;
67
+ let account;
60
68
  try {
61
- return { config: resolveConfig(process.env), account: payerAccount(process.env) };
69
+ config = resolveConfig(process.env);
70
+ account = payerAccount(process.env);
62
71
  }
63
72
  catch (error) {
64
73
  fail(error instanceof Error ? error.message : String(error));
65
74
  }
75
+ const problem = config.daily === undefined ? undefined : stateDirProblem(config.stateDir);
76
+ if (problem)
77
+ fail(problem);
78
+ return { config, account };
66
79
  }
67
80
  async function approve(arg) {
68
81
  const { config, account } = settings();
package/dist/payments.js CHANGED
@@ -26,6 +26,8 @@ export function orderIdOf(paymentPayload) {
26
26
  }
27
27
  const sameAddress = (a, b) => a.toLowerCase() === b.toLowerCase();
28
28
  const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
29
+ /** Text from the desk or a library, ended as a sentence so what this server adds after it reads apart. */
30
+ const sentence = (text) => (/[.!?]$/.test(text.trim()) ? text.trim() : `${text.trim()}.`);
29
31
  /** Statuses that say "not now" rather than "no", alongside every 5xx. */
30
32
  const RETRY_LATER = new Set([408, 425, 429]);
31
33
  /** Settles as `work` does, or rejects once `signal` aborts. */
@@ -112,11 +114,12 @@ export function createDesk(options) {
112
114
  if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN/i.test(message)) {
113
115
  return `Could not reach the desk at ${config.desk}. Nothing was charged. Check your connection or BRAINCHAIN_DESK_URL and try again.`;
114
116
  }
115
- return `The payment could not be prepared: ${message}. Nothing was charged.`;
117
+ // The x402 client puts its own words before an error raised while it prepared the payment.
118
+ return `The payment could not be prepared: ${sentence(message.replace(/^Failed to create payment payload: /, ""))} Nothing was charged.`;
116
119
  }
117
120
  /** Explains a payment the desk refused, from the wallet's balance and Permit2 allowance where possible. */
118
121
  async function explainRefusal(reason) {
119
- const fallback = typeof reason === "string" ? `The desk refused the payment: ${reason}. Nothing was charged.` : "The desk refused the payment. Nothing was charged.";
122
+ const fallback = typeof reason === "string" && reason.trim() ? `The desk refused the payment: ${sentence(reason)} Nothing was charged.` : "The desk refused the payment. Nothing was charged.";
120
123
  if (!options.funds)
121
124
  return fallback;
122
125
  try {
@@ -222,14 +225,19 @@ export function createDesk(options) {
222
225
  if (!signed)
223
226
  await unreserve();
224
227
  });
228
+ // Short enough that a possiblyCharged reply reaches a client that gives up after 60 seconds.
229
+ // The x402 client sends clones of the request, and a clone follows the request's signal
230
+ // through weak references only, so once garbage is collected a deadline set on the request
231
+ // never fires. Each request it sends carries the deadline itself, which the timer keeps alive.
232
+ const deadline = new AbortController();
233
+ const timer = setTimeout(() => deadline.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError")), options.postTimeoutMs ?? 45_000);
234
+ const timed = (input, init) => fetchImpl(input, { ...init, signal: deadline.signal });
225
235
  let response;
226
236
  try {
227
- response = await wrapFetchWithPayment(fetchImpl, client)(endpoint, {
237
+ response = await wrapFetchWithPayment(timed, client)(endpoint, {
228
238
  method: "POST",
229
239
  headers: { "content-type": "application/json" },
230
240
  body: JSON.stringify(task),
231
- // Short enough that a possiblyCharged reply reaches a client that gives up after 60 seconds.
232
- signal: AbortSignal.timeout(options.postTimeoutMs ?? 45_000),
233
241
  });
234
242
  }
235
243
  catch (error) {
@@ -237,6 +245,9 @@ export function createDesk(options) {
237
245
  return possiblyCharged(receipt(orderIdOf(signed)), `The desk did not confirm the order (${firstLine(error)}).`);
238
246
  throw new Error(await explainUnsigned(error));
239
247
  }
248
+ finally {
249
+ clearTimeout(timer);
250
+ }
240
251
  const reply = (await jsonObject(response)) ?? {};
241
252
  if (response.ok) {
242
253
  if (!signed) {
@@ -253,7 +264,7 @@ export function createDesk(options) {
253
264
  await unreserve();
254
265
  if (response.status === 402)
255
266
  throw new Error(await explainRefusal(reply.error));
256
- throw new Error(typeof reply.error === "string" ? `${reply.error} Nothing was charged.` : `The desk answered ${response.status}. Nothing was charged.`);
267
+ throw new Error(typeof reply.error === "string" && reply.error.trim() ? `${sentence(reply.error)} Nothing was charged.` : `The desk answered ${response.status}. Nothing was charged.`);
257
268
  }
258
269
  async function get(order) {
259
270
  let response;
@@ -0,0 +1,94 @@
1
+ import { createPublicClient, http, parseAbi } from "viem";
2
+ export const PREMIUMS_NAME = "brainchain_stock_premiums";
3
+ /** The desks' mandate on Robinhood Chain: it lists each stock token's deepest USDG pool and Chainlink feed. */
4
+ export const MANDATE = "0xA76916D1b75a82776779fb9d7EB156A4E4a55099";
5
+ export const ROBINHOOD_RPC = "https://rpc.mainnet.chain.robinhood.com";
6
+ const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
7
+ const USDG = "0x5fc5360d0400a0fd4f2af552add042d716f1d168";
8
+ const mandateAbi = parseAbi([
9
+ "function tokenCount() view returns (uint256)",
10
+ "function tokens(uint256) view returns (address)",
11
+ "function venues(address) view returns (address pool, address feed, bool live)",
12
+ ]);
13
+ const tokenAbi = parseAbi(["function symbol() view returns (string)", "function uiMultiplier() view returns (uint256)"]);
14
+ const poolAbi = parseAbi([
15
+ "function token0() view returns (address)",
16
+ "function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16, uint16, uint16, uint8, bool)",
17
+ ]);
18
+ const feedAbi = parseAbi(["function latestRoundData() view returns (uint80, int256 answer, uint256, uint256 updatedAt, uint80)"]);
19
+ export const PREMIUMS_TOOL = {
20
+ name: PREMIUMS_NAME,
21
+ title: "Stock premiums on Robinhood Chain",
22
+ description: "What tokenized US stocks cost on Robinhood Chain against their Chainlink price right now: pool price, Chainlink price, " +
23
+ "the premium in percent and the feed's age, for NVDA, SPY, AAPL, TSLA, GOOGL, META and MSFT. Read from the chain, free, no key " +
24
+ "and no payment. Outside US market hours no new tokens are minted, so the premium shows where the market clears on its own. " +
25
+ "Prices are in USDG per token with each token's split multiplier applied. Not advice; stock tokens on Robinhood Chain are not " +
26
+ "available to US persons.",
27
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
28
+ };
29
+ export function chainReader(rpc = ROBINHOOD_RPC) {
30
+ return createPublicClient({ transport: http(rpc) });
31
+ }
32
+ /** Monday to Friday, 14:30 to 21:00 UTC, the regular NYSE session in winter time. */
33
+ export function usMarketOpen(now) {
34
+ const day = now.getUTCDay();
35
+ if (day === 0 || day === 6)
36
+ return false;
37
+ const minutes = now.getUTCHours() * 60 + now.getUTCMinutes();
38
+ return minutes >= 14 * 60 + 30 && minutes < 21 * 60;
39
+ }
40
+ const usd = (units) => `${units / 1000000n}.${((units % 1000000n) / 10000n).toString().padStart(2, "0")}`;
41
+ /** USDG (6 decimals) for one whole stock token at the pool's current price. */
42
+ function spotFromSlot0(sqrtPriceX96, token0) {
43
+ const squared = sqrtPriceX96 * sqrtPriceX96;
44
+ return token0.toLowerCase() === USDG ? (10n ** 18n << 192n) / squared : (10n ** 18n * squared) >> 192n;
45
+ }
46
+ export async function stockPremiums(reader, now = new Date()) {
47
+ const count = Number(await reader.readContract({ address: MANDATE, abi: mandateAbi, functionName: "tokenCount" }));
48
+ const tokens = (await reader.multicall({
49
+ multicallAddress: MULTICALL3,
50
+ allowFailure: false,
51
+ contracts: Array.from({ length: count }, (_, i) => ({ address: MANDATE, abi: mandateAbi, functionName: "tokens", args: [BigInt(i)] })),
52
+ }));
53
+ const venues = (await reader.multicall({
54
+ multicallAddress: MULTICALL3,
55
+ allowFailure: false,
56
+ contracts: tokens.map((token) => ({ address: MANDATE, abi: mandateAbi, functionName: "venues", args: [token] })),
57
+ }));
58
+ const live = tokens.map((token, i) => ({ token, pool: venues[i][0], feed: venues[i][1], live: venues[i][2] })).filter((v) => v.live);
59
+ const reads = await reader.multicall({
60
+ multicallAddress: MULTICALL3,
61
+ allowFailure: false,
62
+ contracts: live.flatMap((v) => [
63
+ { address: v.token, abi: tokenAbi, functionName: "symbol" },
64
+ { address: v.token, abi: tokenAbi, functionName: "uiMultiplier" },
65
+ { address: v.pool, abi: poolAbi, functionName: "token0" },
66
+ { address: v.pool, abi: poolAbi, functionName: "slot0" },
67
+ { address: v.feed, abi: feedAbi, functionName: "latestRoundData" },
68
+ ]),
69
+ });
70
+ const at = Math.floor(now.getTime() / 1000);
71
+ const stocks = live.map((v, i) => {
72
+ const [symbol, multiplier, token0, slot0, round] = reads.slice(i * 5, i * 5 + 5);
73
+ const spot = spotFromSlot0(slot0[0], token0);
74
+ const feed = (round[1] * multiplier) / 10n ** 20n;
75
+ const bps = feed === 0n ? 0 : Number(((spot - feed) * 1000000n) / feed) / 100;
76
+ const updatedAt = Number(round[3]);
77
+ return {
78
+ symbol,
79
+ token: v.token,
80
+ pool: v.pool,
81
+ poolPrice: usd(spot),
82
+ chainlinkPrice: usd(feed),
83
+ premiumPct: `${bps >= 0 ? "+" : ""}${(bps / 100).toFixed(2)}`,
84
+ feedUpdatedAt: new Date(updatedAt * 1000).toISOString(),
85
+ feedAgeSeconds: Math.max(0, at - updatedAt),
86
+ };
87
+ });
88
+ return { chain: "Robinhood Chain", readAt: now.toISOString(), usMarketOpen: usMarketOpen(now), stocks };
89
+ }
90
+ let reader;
91
+ export function readPremiums() {
92
+ reader ??= chainReader();
93
+ return stockPremiums(reader);
94
+ }
package/dist/server.js CHANGED
@@ -2,10 +2,12 @@ import { readFileSync, statSync } from "node:fs";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { createPublicClient, defineChain, http, parseAbi } from "viem";
5
+ import { createPublicClient, defineChain, formatEther, http, maxUint256, parseAbi } from "viem";
6
6
  import { privateKeyToAccount } from "viem/accounts";
7
- import { expandHome, resolveConfig } from "./config.js";
7
+ import { expandHome, formatUnits, resolveConfig } from "./config.js";
8
+ import { stateDirProblem } from "./daily.js";
8
9
  import { createDesk } from "./payments.js";
10
+ import { PREMIUMS_NAME, PREMIUMS_TOOL } from "./premiums.js";
9
11
  import { callTool, GET_TASK_NAME, GET_TASK_TOOL, POST_TASK_NAME, postTaskTool } from "./tools.js";
10
12
  /** The canonical Permit2 contract; x402 payments in USDG are signed Permit2 transfers. */
11
13
  export const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
@@ -19,7 +21,7 @@ export const INSTRUCTIONS = `Post a task with ${POST_TASK_NAME}, then read it wi
19
21
  `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
22
  `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
23
  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.`;
24
+ `${GET_TASK_NAME} and ${PREMIUMS_NAME} work without a key. An agent reading this: stop and tell your operator. Do not look for the key.`;
23
25
  const messageOf = (error) => (error instanceof Error ? error.message : String(error));
24
26
  /** Read here and never echoed. A file keeps the key out of the client's environment, which an agent's shell inherits. */
25
27
  function readKeyFile(given) {
@@ -79,6 +81,38 @@ export function chainClients(config) {
79
81
  });
80
82
  return { chain, reader: createPublicClient({ chain, transport: http(config.rpc, { retryCount: 2, timeout: 15_000 }) }) };
81
83
  }
84
+ /** Reads what `wallet` reports, on the payment chain only. */
85
+ export async function walletFunds(config, address) {
86
+ const { reader } = chainClients(config);
87
+ const [balance, allowance, eth] = await Promise.all([
88
+ reader.readContract({ address: config.asset, abi: erc20, functionName: "balanceOf", args: [address] }),
89
+ config.permit2 ? reader.readContract({ address: config.asset, abi: erc20, functionName: "allowance", args: [address, PERMIT2] }) : undefined,
90
+ reader.getBalance({ address }),
91
+ ]);
92
+ return { balance, ...(allowance === undefined ? {} : { allowance }), eth };
93
+ }
94
+ /** What `wallet` says: the wallet that pays, what it holds, and what it still needs before a post can be paid. */
95
+ export function walletReport(config, address, funds, approve) {
96
+ const lines = [`${address} pays for tasks in ${config.token} on ${config.chain}.`];
97
+ if (funds instanceof Error) {
98
+ lines.push(`Its balance could not be read from ${config.chain} (${messageOf(funds).split("\n")[0]}). The key and settings are fine.`);
99
+ return lines.join("\n");
100
+ }
101
+ const { balance, allowance, eth } = funds;
102
+ const short = balance < config.maxPrice ? ` A task costs ${config.terms.cost}, so send at least ${formatUnits(config.maxPrice - balance)} ${config.token} to it on ${config.chain} before posting.` : "";
103
+ lines.push(`It holds ${formatUnits(balance)} ${config.token}.${short}`);
104
+ if (allowance !== undefined) {
105
+ if (allowance >= maxUint256 / 2n)
106
+ lines.push(`Permit2 may spend any amount of its ${config.token}.`);
107
+ else if (allowance >= config.maxPrice)
108
+ lines.push(`Permit2 may spend up to ${formatUnits(allowance)} of its ${config.token}.`);
109
+ else {
110
+ const only = allowance > 0n ? ` (only ${formatUnits(allowance)})` : "";
111
+ lines.push(`Permit2 is not approved to spend enough of its ${config.token} for a task${only}. Run \`${approve}\` once; it needs a little ETH on ${config.chain}, and the wallet holds ${formatEther(eth)} ETH there.`);
112
+ }
113
+ }
114
+ return lines.join("\n");
115
+ }
82
116
  export function approveCommand(config, version) {
83
117
  return `${config.name === "testnet" ? "BRAINCHAIN_NETWORK=testnet " : ""}npx -y brainchain-mcp@${version} approve`;
84
118
  }
@@ -105,6 +139,8 @@ function deskFor(env, version, fetchImpl) {
105
139
  if (env.BRAINCHAIN_PAYER_KEY?.trim() || env.BRAINCHAIN_PAYER_KEY_FILE?.trim())
106
140
  keyProblem = messageOf(error);
107
141
  }
142
+ // Said at start, not at the first post: with a daily limit set, no payment can be signed without its record.
143
+ const problem = [keyProblem, config.daily === undefined ? undefined : stateDirProblem(config.stateDir)].filter(Boolean).join(" ");
108
144
  const payer = () => {
109
145
  try {
110
146
  return (account ??= payerAccount(env));
@@ -128,14 +164,14 @@ function deskFor(env, version, fetchImpl) {
128
164
  return { balance, allowance };
129
165
  },
130
166
  });
131
- return { desk, terms: { ...config.terms, ...(account ? { wallet: account.address } : {}), ...(keyProblem ? { keyProblem } : {}) } };
167
+ return { desk, terms: { ...config.terms, ...(account ? { wallet: account.address } : {}), ...(problem ? { problem } : {}) } };
132
168
  }
133
169
  export function createServer(env, version, options = {}) {
134
170
  const { desk, terms } = deskFor(env, version, options.fetch);
135
- const problem = typeof terms === "string" ? terms : terms.keyProblem;
171
+ const problem = typeof terms === "string" ? terms : terms.problem;
136
172
  if (problem)
137
173
  (options.log ?? console.error)(`brainchain-mcp has an invalid setting: ${problem}`);
138
- const tools = [postTaskTool(terms), GET_TASK_TOOL];
174
+ const tools = [postTaskTool(terms), GET_TASK_TOOL, PREMIUMS_TOOL];
139
175
  const server = new Server({ name: "brainchain", title: "BrainChain", version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
140
176
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
141
177
  server.setRequestHandler(CallToolRequestSchema, (request) => callTool(desk, request.params.name, request.params.arguments));
package/dist/tools.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { REPOST_AFTER_MINUTES } from "./config.js";
2
+ import { canonicalStringify } from "./payments.js";
3
+ import { PREMIUMS_NAME, readPremiums } from "./premiums.js";
2
4
  export const POST_TASK_NAME = "brainchain_post_task";
5
+ /** The desk's cap on a task as it goes on chain: title, brief, levels and pass level as JSON. */
6
+ export const MAX_TASK_BYTES = 64_000;
3
7
  export const GET_TASK_NAME = "brainchain_get_task";
4
8
  const POST_TASK_INPUT = {
5
9
  type: "object",
@@ -9,14 +13,16 @@ const POST_TASK_INPUT = {
9
13
  type: "string",
10
14
  minLength: 1,
11
15
  maxLength: 32000,
12
- description: "What to produce, in what format, and anything the model must not do. Models cannot browse, so include every fact they need.",
16
+ description: "What to produce, in what format, and anything the model must not do. Models cannot browse, so include every fact they need. " +
17
+ "Up to 32,000 characters, an emoji counting as two, and the whole task as JSON at most 64,000 bytes: about 21,000 characters of Chinese, Japanese or Korean.",
13
18
  },
14
19
  levels: {
15
20
  type: "array",
16
21
  items: { type: "string", minLength: 1, maxLength: 200 },
17
22
  minItems: 2,
18
23
  maxItems: 3,
19
- description: "Two or three quality levels, best first, each a short description a reader could check. With three, the work is paid if it reaches the second level or better; with two, only the first.",
24
+ uniqueItems: true,
25
+ description: "Two or three different quality levels, best first, each a short description a reader could check. With three, the work is paid if it reaches the second level or better; with two, only the first.",
20
26
  },
21
27
  },
22
28
  required: ["title", "brief", "levels"],
@@ -25,13 +31,14 @@ const POST_TASK_INPUT = {
25
31
  /** Given a configuration error instead of terms, the description leads with it. */
26
32
  export function postTaskTool(terms) {
27
33
  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 your pass level: with three quality levels, the second or better; with two, only the first. ` +
28
- `Briefs and results are public, so never include credentials or code that cannot be published. `;
34
+ `Briefs and results are public, so never include credentials or code that cannot be published. ` +
35
+ `A brief can be up to 32,000 characters, and the whole task at most 64,000 bytes as JSON. `;
29
36
  return {
30
37
  name: POST_TASK_NAME,
31
38
  title: "Post a paid task to BrainChain",
32
39
  description: typeof terms === "string"
33
40
  ? `Invalid setting: ${terms} Until then this tool only returns that error. ${about}`
34
- : (terms.keyProblem ? `Invalid setting: ${terms.keyProblem} Until the operator fixes it, this tool only returns that error. ` : "") +
41
+ : (terms.problem ? `Invalid setting: ${terms.problem} Until the operator fixes it, this tool only returns that error. ` : "") +
35
42
  about +
36
43
  `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. ` +
37
44
  `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}. ` +
@@ -55,14 +62,20 @@ export const GET_TASK_TOOL = {
55
62
  };
56
63
  const typeName = (value) => (value === null ? "null" : Array.isArray(value) ? "an array" : typeof value === "object" ? "an object" : `a ${typeof value}`);
57
64
  const count = (n) => n.toLocaleString("en-US");
58
- /** A string field as it is sent, trimmed; throws what is wrong with it. */
65
+ /**
66
+ * Text that shows nothing: whitespace, control and format characters, line and
67
+ * paragraph separators, and the default-ignorable and blank letters that have
68
+ * no Unicode property of their own.
69
+ */
70
+ const BLANK = /^[\s\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u034f\u115f\u1160\u17b4\u17b5\u180b-\u180f\u2800\u3164\ufe00-\ufe0f\uffa0\u{e0000}-\u{e0fff}]*$/u;
71
+ /** A string field as it is sent, trimmed; throws what is wrong with it. Lengths count UTF-16 units, as the desk does. */
59
72
  function text(name, value, max = Infinity) {
60
73
  if (value === undefined)
61
74
  throw new Error(`${name} is required.`);
62
75
  if (typeof value !== "string")
63
76
  throw new Error(`${name} must be a string, not ${typeName(value)}.`);
64
77
  const sent = value.trim();
65
- if (sent.length === 0)
78
+ if (BLANK.test(sent))
66
79
  throw new Error(`${name} is empty.`);
67
80
  if (sent.length > max)
68
81
  throw new Error(`${name} is ${count(sent.length)} characters; the limit is ${count(max)}.`);
@@ -74,9 +87,15 @@ function refuseUnknownFields(input, schema) {
74
87
  if (unknown.length > 0)
75
88
  throw new Error(`Unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. This tool takes only ${known.join(", ")}.`);
76
89
  }
77
- /** The desk's own limits, applied to what is sent, before anything is signed. Returns the trimmed brief; throws what is wrong with it. */
90
+ /**
91
+ * The desk's own limits, applied to what is sent, before anything is signed,
92
+ * plus what this tool's description promises: a title of one line and levels
93
+ * that differ. Returns the trimmed brief; throws what is wrong with it.
94
+ */
78
95
  export function checkBrief(input) {
79
96
  const title = text("title", input.title, 120);
97
+ if (/[\n\r\u2028\u2029]/.test(title))
98
+ throw new Error("title must be one line.");
80
99
  const brief = text("brief", input.brief, 32_000);
81
100
  const { levels } = input;
82
101
  if (levels === undefined)
@@ -85,7 +104,18 @@ export function checkBrief(input) {
85
104
  throw new Error(`levels must be an array of strings, not ${typeName(levels)}.`);
86
105
  if (levels.length < 2 || levels.length > 3)
87
106
  throw new Error(`levels must list 2 or 3 quality levels, best first; this lists ${levels.length}.`);
88
- return { title, brief, levels: levels.map((level, i) => text(`levels[${i}]`, level, 200)) };
107
+ const sent = levels.map((level, i) => text(`levels[${i}]`, level, 200));
108
+ sent.forEach((level, i) => {
109
+ const first = sent.indexOf(level);
110
+ if (first < i)
111
+ throw new Error(`levels[${i}] repeats levels[${first}]; each level must describe a different quality.`);
112
+ });
113
+ // Measured as the desk measures it: the trimmed task with its pass level, as canonical JSON.
114
+ const bytes = Buffer.byteLength(canonicalStringify({ title, brief, levels: sent, passIndex: Math.min(1, sent.length - 2) }));
115
+ if (bytes > MAX_TASK_BYTES) {
116
+ throw new Error(`The task is ${count(bytes)} bytes as JSON; the limit is ${count(MAX_TASK_BYTES)}. Shorten the brief.`);
117
+ }
118
+ return { title, brief, levels: sent };
89
119
  }
90
120
  function reply(value, isError = false) {
91
121
  return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], isError };
@@ -98,7 +128,7 @@ function orderReply(view) {
98
128
  result.content.push({ type: "text", text: resultText });
99
129
  return result;
100
130
  }
101
- export async function callTool(desk, name, args) {
131
+ export async function callTool(desk, name, args, premiums = readPremiums) {
102
132
  const input = (args ?? {});
103
133
  try {
104
134
  if (name === POST_TASK_NAME) {
@@ -112,6 +142,8 @@ export async function callTool(desk, name, args) {
112
142
  throw new Error("order must be the 0x-prefixed, 64-character hex id that brainchain_post_task returned.");
113
143
  return orderReply(await desk.get(order));
114
144
  }
145
+ if (name === PREMIUMS_NAME)
146
+ return reply(await premiums());
115
147
  return reply({ error: `Unknown tool ${name}.` }, true);
116
148
  }
117
149
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-mcp",
3
- "version": "0.5.1",
3
+ "version": "0.6.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",