stellar-tollbooth 0.1.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 +54 -0
- package/dist/index.js +149 -0
- package/dist/wallet.js +84 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# stellar-tollbooth
|
|
2
|
+
|
|
3
|
+
**Give your coding agent a Stellar wallet.**
|
|
4
|
+
|
|
5
|
+
An MCP server that holds a wallet. When your agent calls a paid tool, it settles in XLM
|
|
6
|
+
over [x402](https://developers.stellar.org/docs/build/agentic-payments/x402) on Stellar —
|
|
7
|
+
around three seconds, network fee sponsored by the facilitator. No card, no signup, no API key.
|
|
8
|
+
|
|
9
|
+
> **Testnet only.** This handles a Stellar secret key. Use throwaway testnet accounts.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
claude mcp add tollbooth \
|
|
15
|
+
--env STELLAR_SECRET_KEY=S... \
|
|
16
|
+
--env TOLLBOOTH_API=https://tollbooth-equinox.vercel.app \
|
|
17
|
+
-- npx -y stellar-tollbooth
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or get a funded wallet and register it in one step:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
curl -fsSL https://tollbooth-equinox.vercel.app/install.sh | bash
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| tool | price | |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `wallet_balance` | free | address, balance, remaining budget |
|
|
31
|
+
| `test_payment` | 0.01 XLM | smallest real payment — proves the wallet works |
|
|
32
|
+
| `raven_search` | 0.05 XLM | Stellar ecosystem search |
|
|
33
|
+
| `raven_execute` | 0.25 XLM | composed sandboxed queries |
|
|
34
|
+
| `pay_and_fetch` | varies | pay **any** x402 URL |
|
|
35
|
+
|
|
36
|
+
Every paid result carries a receipt — amount, measured settlement time, tx hash, explorer link.
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
| env | default | |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `STELLAR_SECRET_KEY` | — | **required**; the agent's wallet |
|
|
43
|
+
| `TOLLBOOTH_API` | `http://localhost:3000` | the paid API |
|
|
44
|
+
| `STELLAR_NETWORK` | `stellar:testnet` | CAIP-2 network id |
|
|
45
|
+
| `TOLLBOOTH_SESSION_BUDGET` | `5` | cumulative XLM cap for the process |
|
|
46
|
+
| `TOLLBOOTH_MAX_PER_CALL` | `5000000` | per-payment cap, atomic units (0.5 XLM) |
|
|
47
|
+
|
|
48
|
+
Two caps, because they do different jobs: x402's own `spendControls` limits each
|
|
49
|
+
*individual* payment, so an agent in a loop could still drain a wallet one capped payment
|
|
50
|
+
at a time. `TOLLBOOTH_SESSION_BUDGET` is the cumulative ceiling.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ADDRESS, API, BudgetExceeded, NETWORK, agentHeaders, balanceXlm, paidPost, payingFetch, remainingXlm, spentXlm, } from "./wallet.js";
|
|
6
|
+
const PRICES = { ping: 0.01, search: 0.05, execute: 0.25 };
|
|
7
|
+
const explorer = (tx) => `https://stellar.expert/explorer/testnet/tx/${tx}`;
|
|
8
|
+
/**
|
|
9
|
+
* The receipt is rendered into the TOOL RESULT, not just logged.
|
|
10
|
+
*
|
|
11
|
+
* This is deliberate and load-bearing for the live demo: stderr from an MCP server is
|
|
12
|
+
* not reliably surfaced by every client, but tool result content always renders in the
|
|
13
|
+
* transcript. The payment has to be visible on screen or the whole premise is invisible.
|
|
14
|
+
*/
|
|
15
|
+
function receiptBlock(r) {
|
|
16
|
+
return [
|
|
17
|
+
"",
|
|
18
|
+
" ┌─ paid via x402 on Stellar ─────────────",
|
|
19
|
+
` │ amount ${r.priceXlm} XLM`,
|
|
20
|
+
` │ settled ${(r.settledMs / 1000).toFixed(1)}s`,
|
|
21
|
+
` │ network ${NETWORK} (fees sponsored)`,
|
|
22
|
+
r.txHash ? ` │ tx ${r.txHash.slice(0, 16)}…` : " │ tx (pending)",
|
|
23
|
+
r.txHash ? ` │ explorer ${explorer(r.txHash)}` : " │",
|
|
24
|
+
` │ spent ${spentXlm().toFixed(4)} XLM this session · ${remainingXlm().toFixed(4)} left`,
|
|
25
|
+
" └────────────────────────────────────────",
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
29
|
+
/** Free precheck, so an unlinked attendee is never charged for a call that must fail. */
|
|
30
|
+
async function linkStatus() {
|
|
31
|
+
try {
|
|
32
|
+
const r = await fetch(`${API}/api/link-status?agent=${ADDRESS}`);
|
|
33
|
+
return (await r.json());
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { linked: true, linkUrl: `${API}/auth/start?agent=${ADDRESS}` }; // don't block on a probe failure
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const needsLink = (s) => text(`Your raven account isn't linked to this wallet yet, so nothing was charged.\n\n` +
|
|
40
|
+
`Link it once (opens raven sign-in):\n ${s.linkUrl}\n\nThen run this tool again.`);
|
|
41
|
+
async function runPaid(path, body, priceXlm, label) {
|
|
42
|
+
const status = await linkStatus();
|
|
43
|
+
if (!status.linked)
|
|
44
|
+
return needsLink(status);
|
|
45
|
+
try {
|
|
46
|
+
const { ok, status: code, json, receipt } = await paidPost(path, body, priceXlm);
|
|
47
|
+
if (!ok) {
|
|
48
|
+
if (code === 409)
|
|
49
|
+
return needsLink(json);
|
|
50
|
+
return text(`${label} failed (HTTP ${code}): ${JSON.stringify(json)}`);
|
|
51
|
+
}
|
|
52
|
+
const payload = "result" in json ? json.result : json;
|
|
53
|
+
return text(`${JSON.stringify(payload, null, 2)}\n${receiptBlock(receipt)}`);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
if (e instanceof BudgetExceeded)
|
|
57
|
+
return text(`Payment refused by local spend cap.\n\n${e.message}`);
|
|
58
|
+
return text(`${label} failed: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const server = new McpServer({ name: "tollbooth", version: "0.1.0" });
|
|
62
|
+
server.registerTool("wallet_balance", {
|
|
63
|
+
title: "Agent wallet balance",
|
|
64
|
+
description: "Show this agent's own Stellar wallet address, XLM balance and remaining spend budget. Free.",
|
|
65
|
+
inputSchema: {},
|
|
66
|
+
}, async () => {
|
|
67
|
+
const bal = await balanceXlm().catch((e) => `unavailable (${e.message})`);
|
|
68
|
+
return text([
|
|
69
|
+
`address ${ADDRESS}`,
|
|
70
|
+
`balance ${bal} XLM`,
|
|
71
|
+
`network ${NETWORK}`,
|
|
72
|
+
`spent ${spentXlm().toFixed(4)} XLM this session`,
|
|
73
|
+
`remaining ${remainingXlm().toFixed(4)} XLM of session budget`,
|
|
74
|
+
].join("\n"));
|
|
75
|
+
});
|
|
76
|
+
server.registerTool("raven_search", {
|
|
77
|
+
title: "Search the Stellar ecosystem (paid)",
|
|
78
|
+
description: `Search Stellar ecosystem knowledge via raven: operations, skills, projects, repos and docs. ` +
|
|
79
|
+
`Costs ${PRICES.search} XLM per call, paid automatically from this agent's wallet over x402.`,
|
|
80
|
+
inputSchema: { query: z.string().describe("What to search for"), limit: z.number().optional() },
|
|
81
|
+
}, async ({ query, limit }) => runPaid("/paid/raven/search", { query, limit }, PRICES.search, "raven_search"));
|
|
82
|
+
server.registerTool("raven_execute", {
|
|
83
|
+
title: "Run composed Stellar queries (paid)",
|
|
84
|
+
description: `Run a sandboxed JavaScript script composing raven operations (Promise.all across services, ` +
|
|
85
|
+
`then follow-up calls). Costs ${PRICES.execute} XLM per call, paid automatically over x402.`,
|
|
86
|
+
inputSchema: { code: z.string().describe("Sandboxed JS script to run against raven services") },
|
|
87
|
+
}, async ({ code }) => runPaid("/paid/raven/execute", { code }, PRICES.execute, "raven_execute"));
|
|
88
|
+
server.registerTool("test_payment", {
|
|
89
|
+
title: "Test that this agent can pay",
|
|
90
|
+
description: `Make the smallest real payment on the system (${PRICES.ping} XLM) to prove this ` +
|
|
91
|
+
`agent's wallet works end to end. Needs no raven account. Use this first if payments fail.`,
|
|
92
|
+
inputSchema: {},
|
|
93
|
+
}, async () => {
|
|
94
|
+
try {
|
|
95
|
+
const { ok, status, json, receipt } = await paidPost("/paid/demo/ping", {}, PRICES.ping);
|
|
96
|
+
if (!ok)
|
|
97
|
+
return text(`test_payment failed (HTTP ${status}): ${JSON.stringify(json)}`);
|
|
98
|
+
return text(`${json.message ?? "paid"}\n${receiptBlock(receipt)}`);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
if (e instanceof BudgetExceeded)
|
|
102
|
+
return text(`Payment refused by local spend cap.\n\n${e.message}`);
|
|
103
|
+
return text(`test_payment failed: ${e.message}`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
server.registerTool("pay_and_fetch", {
|
|
107
|
+
title: "Pay any x402 URL",
|
|
108
|
+
description: "Fetch ANY x402-protected URL, paying automatically in XLM from this agent's wallet. " +
|
|
109
|
+
"The generic primitive: point it at your own paid endpoint to test it.",
|
|
110
|
+
inputSchema: {
|
|
111
|
+
url: z.string().describe("An x402-protected URL"),
|
|
112
|
+
method: z.enum(["GET", "POST"]).optional(),
|
|
113
|
+
body: z.string().optional().describe("JSON string body for POST"),
|
|
114
|
+
},
|
|
115
|
+
}, async ({ url, method = "GET", body }) => {
|
|
116
|
+
const t0 = Date.now();
|
|
117
|
+
try {
|
|
118
|
+
// Identity headers go ONLY to our own API. Sending a signed wallet assertion to
|
|
119
|
+
// an arbitrary third-party URL would leak this agent's address for no reason.
|
|
120
|
+
const own = url.startsWith(API);
|
|
121
|
+
const path = own ? new URL(url).pathname : "";
|
|
122
|
+
const res = await payingFetch(url, {
|
|
123
|
+
method,
|
|
124
|
+
headers: {
|
|
125
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
126
|
+
...(own ? agentHeaders(path) : {}),
|
|
127
|
+
},
|
|
128
|
+
...(body ? { body } : {}),
|
|
129
|
+
});
|
|
130
|
+
const txt = await res.text();
|
|
131
|
+
const raw = res.headers.get("payment-response") ?? res.headers.get("x-payment-response");
|
|
132
|
+
let txHash;
|
|
133
|
+
if (raw) {
|
|
134
|
+
try {
|
|
135
|
+
txHash = JSON.parse(Buffer.from(raw, "base64").toString()).transaction;
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
}
|
|
139
|
+
const settledMs = Date.now() - t0;
|
|
140
|
+
const paid = Boolean(txHash);
|
|
141
|
+
return text(`HTTP ${res.status}\n${txt.slice(0, 4000)}` +
|
|
142
|
+
(paid ? receiptBlock({ priceXlm: 0, ms: settledMs, txHash, settledMs }) : `\n(no payment was required)`));
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
return text(`pay_and_fetch failed: ${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
await server.connect(new StdioServerTransport());
|
|
149
|
+
console.error(`[tollbooth] ready · agent ${ADDRESS} · api ${API}`);
|
package/dist/wallet.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Keypair, Horizon } from "@stellar/stellar-sdk";
|
|
2
|
+
import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
|
|
3
|
+
import { createEd25519Signer } from "@x402/stellar";
|
|
4
|
+
import { ExactStellarScheme } from "@x402/stellar/exact/client";
|
|
5
|
+
export const NETWORK = (process.env.STELLAR_NETWORK ?? "stellar:testnet");
|
|
6
|
+
export const NATIVE_SAC = process.env.NATIVE_SAC ?? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
|
|
7
|
+
export const API = (process.env.TOLLBOOTH_API ?? "http://localhost:3000").replace(/\/$/, "");
|
|
8
|
+
export const HORIZON = process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org";
|
|
9
|
+
/** Per-payment ceiling, in atomic XLM units (7dp). */
|
|
10
|
+
const MAX_PER_CALL = process.env.TOLLBOOTH_MAX_PER_CALL ?? "5000000"; // 0.5 XLM
|
|
11
|
+
/** Cumulative ceiling for this process, in XLM. */
|
|
12
|
+
const SESSION_BUDGET = Number(process.env.TOLLBOOTH_SESSION_BUDGET ?? 5);
|
|
13
|
+
const secret = process.env.STELLAR_SECRET_KEY ?? "";
|
|
14
|
+
if (!secret) {
|
|
15
|
+
console.error("[tollbooth] STELLAR_SECRET_KEY is not set. Run the installer to mint a funded testnet wallet.");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
export const keypair = Keypair.fromSecret(secret);
|
|
19
|
+
export const ADDRESS = keypair.publicKey();
|
|
20
|
+
export class BudgetExceeded extends Error {
|
|
21
|
+
}
|
|
22
|
+
let spent = 0;
|
|
23
|
+
export const spentXlm = () => spent;
|
|
24
|
+
export const remainingXlm = () => Math.max(0, SESSION_BUDGET - spent);
|
|
25
|
+
/**
|
|
26
|
+
* x402's own spendControls caps each INDIVIDUAL payment, not the total. An agent in a
|
|
27
|
+
* loop would happily drain a wallet one capped payment at a time, so the cumulative
|
|
28
|
+
* budget below is enforced here as well.
|
|
29
|
+
*/
|
|
30
|
+
function assertBudget(priceXlm) {
|
|
31
|
+
if (spent + priceXlm > SESSION_BUDGET) {
|
|
32
|
+
throw new BudgetExceeded(`session budget exhausted: spent ${spent.toFixed(4)} of ${SESSION_BUDGET} XLM; ` +
|
|
33
|
+
`this call needs ${priceXlm}. Raise TOLLBOOTH_SESSION_BUDGET to continue.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const signer = createEd25519Signer(secret, NETWORK);
|
|
37
|
+
export const payingFetch = wrapFetchWithPaymentFromConfig(fetch, {
|
|
38
|
+
schemes: [{ network: NETWORK, client: new ExactStellarScheme(signer) }],
|
|
39
|
+
spendControls: {
|
|
40
|
+
// Native XLM is NOT one of x402's "default assets" (those are USDC only), so it
|
|
41
|
+
// must be opted in explicitly or the client refuses before any network call.
|
|
42
|
+
// maxAmountPerPayment is ATOMIC units here, not a "$1" string.
|
|
43
|
+
allowedAssets: [{ network: NETWORK, asset: NATIVE_SAC, maxAmountPerPayment: MAX_PER_CALL }],
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
/** Identity headers. Must match the server's challenge format in lib/identity.ts. */
|
|
47
|
+
export function agentHeaders(path) {
|
|
48
|
+
const ts = String(Date.now());
|
|
49
|
+
const msg = `tollbooth:${ADDRESS}:${ts}:${path}`;
|
|
50
|
+
return {
|
|
51
|
+
"x-agent": ADDRESS,
|
|
52
|
+
"x-agent-ts": ts,
|
|
53
|
+
"x-agent-sig": Buffer.from(keypair.sign(Buffer.from(msg, "utf8"))).toString("base64"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** POST to a paid tollbooth route: budget check, pay, settle, return body + receipt. */
|
|
57
|
+
export async function paidPost(path, body, priceXlm) {
|
|
58
|
+
assertBudget(priceXlm);
|
|
59
|
+
const t0 = Date.now();
|
|
60
|
+
const res = await payingFetch(`${API}${path}`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json", ...agentHeaders(path) },
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
});
|
|
65
|
+
const settledMs = Date.now() - t0;
|
|
66
|
+
const json = (await res.json().catch(() => ({})));
|
|
67
|
+
let txHash;
|
|
68
|
+
const raw = res.headers.get("payment-response") ?? res.headers.get("x-payment-response");
|
|
69
|
+
if (raw) {
|
|
70
|
+
try {
|
|
71
|
+
txHash = JSON.parse(Buffer.from(raw, "base64").toString()).transaction;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* receipt is a nicety, never fail the call over it */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (res.ok)
|
|
78
|
+
spent += priceXlm;
|
|
79
|
+
return { ok: res.ok, status: res.status, json, receipt: { priceXlm, ms: settledMs, txHash, settledMs } };
|
|
80
|
+
}
|
|
81
|
+
export async function balanceXlm() {
|
|
82
|
+
const acct = await new Horizon.Server(HORIZON).loadAccount(ADDRESS);
|
|
83
|
+
return acct.balances.find((b) => b.asset_type === "native")?.balance ?? "0";
|
|
84
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stellar-tollbooth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Give your coding agent a Stellar wallet. Paid MCP tools settled in XLM over x402.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tollbooth": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
21
|
+
"@stellar/stellar-sdk": "^17.1.0",
|
|
22
|
+
"@x402/core": "^2.26.0",
|
|
23
|
+
"@x402/fetch": "^2.26.0",
|
|
24
|
+
"@x402/stellar": "^2.26.0",
|
|
25
|
+
"zod": "^4.6.5"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22",
|
|
29
|
+
"typescript": "^5.6.0"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"author": "Wlad Mendes",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mcp",
|
|
35
|
+
"stellar",
|
|
36
|
+
"x402",
|
|
37
|
+
"agent",
|
|
38
|
+
"payments",
|
|
39
|
+
"soroban",
|
|
40
|
+
"claude"
|
|
41
|
+
],
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/wmendes/tollbooth.git"
|
|
45
|
+
}
|
|
46
|
+
}
|