pion-mcp 0.2.0 → 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 +144 -17
- package/dist/horizon.d.ts +7 -1
- package/dist/horizon.js +18 -5
- package/dist/index.js +55 -7
- package/dist/networks.d.ts +69 -0
- package/dist/networks.js +135 -0
- package/dist/payments.d.ts +99 -0
- package/dist/payments.js +187 -0
- package/dist/platform.d.ts +3 -0
- package/dist/platform.js +43 -6
- package/dist/tools/common.d.ts +15 -0
- package/dist/tools/common.js +21 -1
- package/dist/tools/get-account-payments.d.ts +2 -1
- package/dist/tools/get-account-payments.js +4 -4
- package/dist/tools/get-wallet-balance.d.ts +2 -1
- package/dist/tools/get-wallet-balance.js +4 -4
- package/dist/tools/query-transaction.d.ts +2 -1
- package/dist/tools/query-transaction.js +4 -4
- package/dist/tools/send-payment.d.ts +3 -0
- package/dist/tools/send-payment.js +250 -0
- package/dist/tools/verify-user.js +4 -1
- package/package.json +19 -3
- package/dist/horizon.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/platform.js.map +0 -1
- package/dist/tools/common.js.map +0 -1
- package/dist/tools/get-account-payments.js.map +0 -1
- package/dist/tools/get-wallet-balance.js.map +0 -1
- package/dist/tools/query-transaction.js.map +0 -1
- package/dist/tools/verify-user.js.map +0 -1
package/README.md
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
**Model Context Protocol server for Pi Network** — connect AI agents
|
|
4
4
|
(Claude, Cursor, and any MCP-compatible client) to Pi Network chain data.
|
|
5
5
|
|
|
6
|
-
> ⚠️
|
|
6
|
+
> ⚠️ **Reads: both networks. Payments: testnet only.** The chain tools query Pi
|
|
7
|
+
> Mainnet or Pi Testnet, selected with `PION_NETWORK`. `send_payment` moves real
|
|
8
|
+
> funds, must be explicitly armed, and *cannot* be armed on mainnet — Pi
|
|
9
|
+
> restricts App-to-User payments to testnet.
|
|
7
10
|
|
|
8
11
|
## Why "Pion"?
|
|
9
12
|
The pion is the π meson — the particle physicists named after pi.
|
|
@@ -12,11 +15,13 @@ for MCPs (millicharged particles). We couldn't resist.
|
|
|
12
15
|
|
|
13
16
|
## Tools
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
Out of the box Pion reads and cannot spend: Tiers A and B need no credentials
|
|
19
|
+
and move no value. Tier C is the exception and is off unless you arm it.
|
|
20
|
+
(Tiers refer to [`docs/tool-mapping.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/tool-mapping.md).)
|
|
17
21
|
|
|
18
22
|
**Tier A — chain reads.** Zero-permission queries against Pi's public Horizon
|
|
19
|
-
API. No credentials at all.
|
|
23
|
+
API. No credentials at all. These work on **both Pi chains** — testnet by
|
|
24
|
+
default, mainnet with `PION_NETWORK=mainnet`.
|
|
20
25
|
|
|
21
26
|
| Tool | What it does |
|
|
22
27
|
|---|---|
|
|
@@ -27,6 +32,13 @@ API. No credentials at all.
|
|
|
27
32
|
Amounts are decimal strings. Pi is reported as the asset `PI`, custom tokens as
|
|
28
33
|
`CODE:ISSUER`, and liquidity-pool shares as `pool:ID`.
|
|
29
34
|
|
|
35
|
+
Every result names the chain it came from in a `network` field, and the startup
|
|
36
|
+
banner says it too. That redundancy is deliberate: **the same address can hold
|
|
37
|
+
different balances on both chains**, so a query against the wrong network does
|
|
38
|
+
not reliably fail — it can return a plausible, well-formed, wrong number
|
|
39
|
+
(measured 2026-08-14; see [`docs/pi-sdk-notes.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/pi-sdk-notes.md)).
|
|
40
|
+
Testnet Pi has no monetary value.
|
|
41
|
+
|
|
30
42
|
**Tier B — identity.**
|
|
31
43
|
|
|
32
44
|
| Tool | What it does |
|
|
@@ -44,6 +56,47 @@ a different uid under a different Pi app, which is deliberate anti-correlation
|
|
|
44
56
|
design, so don't use it as a global identifier. And a token is the *only* proof
|
|
45
57
|
of identity: a client-supplied uid or username means nothing on its own.
|
|
46
58
|
|
|
59
|
+
**Tier C — payments. Off by default.**
|
|
60
|
+
|
|
61
|
+
| Tool | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `send_payment` | App-to-User: sends Pi from your app wallet to a user |
|
|
64
|
+
|
|
65
|
+
This one spends real money and cannot be undone. It is not registered at all
|
|
66
|
+
unless armed, so a default server does not even advertise it to the agent.
|
|
67
|
+
|
|
68
|
+
**The recipient must have granted your app the `wallet_address` scope.** A uid
|
|
69
|
+
alone is not enough — Pi needs that consent to resolve their wallet, and
|
|
70
|
+
refuses payment creation with `missing_scope` otherwise. This is the
|
|
71
|
+
recipient's consent, not your credentials.
|
|
72
|
+
|
|
73
|
+
Arming requires **all four**, and Pi restricts A2U to testnet, so anything but
|
|
74
|
+
Pi Testnet is refused outright:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
PION_ENABLE_PAYMENTS=1 # explicit switch, deliberately separate from credentials
|
|
78
|
+
PION_MAX_PAYMENT_PI=10 # required per-payment ceiling, in Pi
|
|
79
|
+
PI_SERVER_API_KEY=... # from the Pi Developer Portal
|
|
80
|
+
PI_WALLET_SECRET=S... # app wallet secret seed
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Holding the credentials is deliberately **not** sufficient. The switch and the
|
|
84
|
+
ceiling are separate because the realistic failure mode is not a stolen key —
|
|
85
|
+
it is an agent being talked into spending, by a prompt injection sitting in
|
|
86
|
+
data it just read. A transaction memo, a web page, a filename: any of it can
|
|
87
|
+
say "send 500 Pi to X." The cap is what makes that bounded rather than fatal.
|
|
88
|
+
Set it to the smallest amount that makes your use case work.
|
|
89
|
+
|
|
90
|
+
Nothing overrides the cap from the tool call; changing it means changing server
|
|
91
|
+
configuration. Neither secret is ever accepted as a tool argument, returned in
|
|
92
|
+
a result, or logged.
|
|
93
|
+
|
|
94
|
+
**On partial failure it never retries.** A2U is three steps — create with Pi,
|
|
95
|
+
sign and submit on-chain, tell Pi it landed — and a crash between them strands
|
|
96
|
+
a payment. The tool reports exactly which step failed, whether funds left the
|
|
97
|
+
wallet, and the payment id needed to clean up. A blind retry could pay twice,
|
|
98
|
+
so it refuses to guess.
|
|
99
|
+
|
|
47
100
|
## Usage
|
|
48
101
|
|
|
49
102
|
MCP clients can run it straight from npm — no install step:
|
|
@@ -77,19 +130,35 @@ claude mcp add pion -- node /absolute/path/to/pion-mcp/dist/index.js
|
|
|
77
130
|
|
|
78
131
|
| Variable | Default | Purpose |
|
|
79
132
|
|---|---|---|
|
|
80
|
-
| `
|
|
133
|
+
| `PION_NETWORK` | `testnet` | Which chain the read tools query — `testnet` or `mainnet` |
|
|
134
|
+
| `PION_HORIZON_URL` | derived from `PION_NETWORK` | Horizon base URL, overriding the above |
|
|
81
135
|
| `PION_PLATFORM_URL` | `https://api.minepi.com` | Platform API base URL |
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
136
|
+
| `PION_ENABLE_PAYMENTS` | unset (off) | Arms `send_payment` — see Tier C above |
|
|
137
|
+
| `PION_MAX_PAYMENT_PI` | unset | Required per-payment ceiling when armed |
|
|
138
|
+
| `PI_SERVER_API_KEY` | unset | Server API key, Tier C only |
|
|
139
|
+
| `PI_WALLET_SECRET` | unset | App wallet secret seed, Tier C only |
|
|
140
|
+
|
|
141
|
+
For read-only use there is nothing to configure — `verify_user` takes its token
|
|
142
|
+
as a call argument, not from the environment. The bottom four are needed only
|
|
143
|
+
if you arm payments, and belong in a secrets manager, never in a committed
|
|
144
|
+
file.
|
|
145
|
+
|
|
146
|
+
`PION_NETWORK` and `PION_HORIZON_URL` are resolved once, in one place, into a
|
|
147
|
+
single network object that the Horizon client, the banner, every tool result and
|
|
148
|
+
the arming check all read. Setting both to contradictory chains is a startup
|
|
149
|
+
error rather than a silent winner, and an unrecognised `PION_HORIZON_URL`
|
|
150
|
+
resolves to an explicitly *unknown* chain — never to a Pi network by
|
|
151
|
+
resemblance.
|
|
86
152
|
|
|
87
153
|
## Development
|
|
88
154
|
|
|
89
155
|
```sh
|
|
90
156
|
npm run build # compile src/ -> dist/
|
|
91
157
|
npm run typecheck # types only, no emit
|
|
92
|
-
npm run smoke
|
|
158
|
+
npm run smoke # end-to-end against live testnet
|
|
159
|
+
npm run smoke:mainnet # the same checks against live mainnet
|
|
160
|
+
npm run crossnet # proves the two chains are actually distinguished
|
|
161
|
+
npm run arming # Tier C guards and spend cap (no credentials needed)
|
|
93
162
|
```
|
|
94
163
|
|
|
95
164
|
`npm run smoke` spawns the server over stdio as a real MCP client, discovers a
|
|
@@ -100,15 +169,73 @@ It covers `verify_user` only on the **rejection** path — confirming a genuine
|
|
|
100
169
|
token would need a real user credential, which the test deliberately does not
|
|
101
170
|
handle. The success path is unverified; see below.
|
|
102
171
|
|
|
172
|
+
`npm run crossnet` proves network selection is real rather than cosmetic. It
|
|
173
|
+
does not rely on an address being absent from the other chain — that assumption
|
|
174
|
+
is false — but on a wallet we control being testnet-only, and on a shared
|
|
175
|
+
address returning *different* ledger state from each chain.
|
|
176
|
+
|
|
177
|
+
`npm run arming` covers Tier C without touching real money: every refusal
|
|
178
|
+
branch, the exact cap boundary, that credentials alone do not arm it, that a
|
|
179
|
+
disarmed server does not advertise the tool, that a fully-credentialled mainnet
|
|
180
|
+
server still refuses to advertise it, and that neither secret leaks into
|
|
181
|
+
a result. It uses a freshly generated, never-funded keypair. The one live call
|
|
182
|
+
it makes is a deliberately-rejected create against the Pi API, which proves the
|
|
183
|
+
first failure stage end to end.
|
|
184
|
+
|
|
185
|
+
## Known gaps
|
|
186
|
+
|
|
187
|
+
- **`verify_user` success path — confirmed** against a live token. Returns
|
|
188
|
+
`uid`, `username`, `app_id`, `scopes`, and `valid_until`. Everything but
|
|
189
|
+
`uid` stays optional, since the rest depends on granted scopes.
|
|
190
|
+
- **`send_payment` success path — verified on testnet (2026-08-01).** A real
|
|
191
|
+
A2U payment ran through all three irreversible steps — create, sign, submit,
|
|
192
|
+
complete — and was confirmed independently against public Horizon and Pi's
|
|
193
|
+
block explorer, not just from the tool's own report. The 28-byte memo
|
|
194
|
+
question that hung over the design is answered: Pi payment identifiers are
|
|
195
|
+
exactly 28 bytes and fit the Stellar text memo with no room to spare.
|
|
196
|
+
- **`send_payment` failure paths after create — still unproven.** Sign, submit
|
|
197
|
+
and complete have each succeeded once; none has been observed *failing*
|
|
198
|
+
against live infrastructure. The two worst branches of the stranded-payment
|
|
199
|
+
report — "record created, nothing signed" and "funds left, Pi not notified" —
|
|
200
|
+
are verified by construction only. Treat `send_payment` as experimental until
|
|
201
|
+
they have been deliberately exercised.
|
|
202
|
+
|
|
203
|
+
This is why it ships **off**, and why turning it on takes four separate,
|
|
204
|
+
deliberate acts: `PION_ENABLE_PAYMENTS=1`, a mandatory `PION_MAX_PAYMENT_PI`
|
|
205
|
+
ceiling, both credentials, and Pi Testnet as the selected network. Holding the credentials
|
|
206
|
+
is not enough on its own. Disarmed, the tool is not registered at all, so an
|
|
207
|
+
agent cannot see that a spending capability exists — that gate is deliberate
|
|
208
|
+
design (see Tier C above), not a placeholder for unfinished work. The
|
|
209
|
+
experimental label is about the failure paths, not about the guards.
|
|
210
|
+
- **`send_payment` cannot pay an arbitrary uid.** Pi requires the *recipient*
|
|
211
|
+
to have granted your app the `wallet_address` scope, through the Pi Browser
|
|
212
|
+
SDK. A valid uid is not sufficient, and this is a permanent property of the
|
|
213
|
+
Pi API rather than a transient error — creation fails with
|
|
214
|
+
`401 missing_scope` and retrying will not help.
|
|
215
|
+
|
|
216
|
+
Start with a minimum-amount payment and a low `PION_MAX_PAYMENT_PI`. Run
|
|
217
|
+
`npm run probe:a2u <uid>` first: it exercises create and cancel without moving
|
|
218
|
+
funds, and its `from_address` is the only authoritative statement of which app
|
|
219
|
+
wallet Pi will actually spend from.
|
|
220
|
+
|
|
103
221
|
## Roadmap
|
|
104
222
|
|
|
105
|
-
|
|
106
|
-
`
|
|
107
|
-
|
|
223
|
+
Done in v0.4: mainnet reads. The rest of Tier C: `get_payment_status`, `list_incomplete_payments`,
|
|
224
|
+
`approve_payment` / `complete_payment` / `cancel_payment` — the U2A backend half
|
|
225
|
+
and the recovery tooling for stranded payments. See
|
|
226
|
+
[`docs/tool-mapping.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/tool-mapping.md).
|
|
227
|
+
|
|
228
|
+
## Legal
|
|
229
|
+
|
|
230
|
+
The code is Apache-2.0; see [`LICENSE`](LICENSE). The website and the hosted U2A
|
|
231
|
+
endpoints are covered separately by the
|
|
232
|
+
[Privacy Policy](https://pionmcp.com/privacy) and
|
|
233
|
+
[Terms of Service](https://pionmcp.com/terms) — sources in
|
|
234
|
+
[`site/privacy.html`](site/privacy.html) and [`site/terms.html`](site/terms.html).
|
|
235
|
+
Where the two disagree about the software itself, the Apache licence wins.
|
|
108
236
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
confirming against a real token before depending on them.
|
|
237
|
+
Short version: the MCP server has no telemetry and talks only to Pi's public
|
|
238
|
+
endpoints, the site sets no cookies and runs no analytics, and nothing here is
|
|
239
|
+
stored in a database — there isn't one.
|
|
113
240
|
|
|
114
241
|
*Unofficial community project — not affiliated with Pi Network.*
|
package/dist/horizon.d.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* Every endpoint used here is public and unauthenticated — no API key, no
|
|
5
5
|
* bearer token, no wallet secret. See docs/pi-sdk-notes.md, "Layer 3".
|
|
6
6
|
*/
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Horizon base URL for the selected network.
|
|
9
|
+
*
|
|
10
|
+
* Derived from the resolved network rather than read from the environment
|
|
11
|
+
* directly, so there is exactly one place that decides which chain we are on.
|
|
12
|
+
* `PION_NETWORK` and `PION_HORIZON_URL` are both handled in networks.ts.
|
|
13
|
+
*/
|
|
8
14
|
export declare const HORIZON_URL: string;
|
|
9
15
|
/** A Horizon request that failed — network, timeout, or non-2xx response. */
|
|
10
16
|
export declare class HorizonError extends Error {
|
package/dist/horizon.js
CHANGED
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
* Every endpoint used here is public and unauthenticated — no API key, no
|
|
5
5
|
* bearer token, no wallet secret. See docs/pi-sdk-notes.md, "Layer 3".
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
import { NETWORK } from "./networks.js";
|
|
8
8
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Horizon base URL for the selected network.
|
|
11
|
+
*
|
|
12
|
+
* Derived from the resolved network rather than read from the environment
|
|
13
|
+
* directly, so there is exactly one place that decides which chain we are on.
|
|
14
|
+
* `PION_NETWORK` and `PION_HORIZON_URL` are both handled in networks.ts.
|
|
15
|
+
*/
|
|
16
|
+
export const HORIZON_URL = NETWORK.horizonUrl;
|
|
11
17
|
/** A Horizon request that failed — network, timeout, or non-2xx response. */
|
|
12
18
|
export class HorizonError extends Error {
|
|
13
19
|
status;
|
|
@@ -55,7 +61,15 @@ async function describeFailure(response, path) {
|
|
|
55
61
|
// Non-JSON error body — fall through to the generic message.
|
|
56
62
|
}
|
|
57
63
|
if (response.status === 404) {
|
|
58
|
-
|
|
64
|
+
// Name the network. A common cause of a surprising 404 is looking for an
|
|
65
|
+
// account on the chain it does not live on, and that is invisible unless
|
|
66
|
+
// said. Note the asymmetry: a 404 is evidence the address is absent *here*,
|
|
67
|
+
// but a success is not evidence you asked the right chain — some addresses
|
|
68
|
+
// exist on both with different balances (docs/FINDINGS.md, finding 5).
|
|
69
|
+
return (`Not found on ${NETWORK.label} Horizon (${HORIZON_URL}${path}). The account or ` +
|
|
70
|
+
`transaction does not exist on ${NETWORK.label}, or has never been funded. ` +
|
|
71
|
+
"Pi Mainnet and Pi Testnet are separate ledgers sharing one address format, so " +
|
|
72
|
+
"this address may still be real on the other chain.");
|
|
59
73
|
}
|
|
60
74
|
const parts = [problem.title, problem.detail, problem.extras?.reason].filter(Boolean);
|
|
61
75
|
return parts.length > 0
|
|
@@ -79,4 +93,3 @@ export function cursorFromLink(href) {
|
|
|
79
93
|
return undefined;
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
|
-
//# sourceMappingURL=horizon.js.map
|
package/dist/index.js
CHANGED
|
@@ -7,25 +7,34 @@
|
|
|
7
7
|
* Horizon API. No credentials of any kind.
|
|
8
8
|
* Tier B — verify_user, which validates a *user* access token supplied by
|
|
9
9
|
* the caller against the Platform API.
|
|
10
|
+
* Tier C — send_payment (A2U), which MOVES REAL FUNDS from the app wallet.
|
|
11
|
+
* Disabled unless explicitly armed; see src/payments.ts.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* Tiers A and B read no credentials from the environment and cannot move
|
|
14
|
+
* value. Tier C is the sole exception and is off by default.
|
|
15
|
+
*
|
|
16
|
+
* Networks: Tier A reads either Pi chain, selected with PION_NETWORK and
|
|
17
|
+
* reported in the `network` field of every result. Tier C is testnet-only —
|
|
18
|
+
* Pi restricts A2U to testnet, so mainnet selection makes payments unarmable
|
|
19
|
+
* rather than merely discouraged. See src/networks.ts and src/payments.ts.
|
|
13
20
|
*/
|
|
14
21
|
import { createRequire } from "node:module";
|
|
15
22
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
23
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
24
|
import { HORIZON_URL } from "./horizon.js";
|
|
25
|
+
import { NETWORK, NETWORK_ERROR, NETWORK_WARNING } from "./networks.js";
|
|
26
|
+
import { checkPaymentsArming } from "./payments.js";
|
|
18
27
|
import { PLATFORM_URL } from "./platform.js";
|
|
19
28
|
import { registerGetAccountPayments } from "./tools/get-account-payments.js";
|
|
20
29
|
import { registerGetWalletBalance } from "./tools/get-wallet-balance.js";
|
|
21
30
|
import { registerQueryTransaction } from "./tools/query-transaction.js";
|
|
31
|
+
import { registerSendPayment } from "./tools/send-payment.js";
|
|
22
32
|
import { registerVerifyUser } from "./tools/verify-user.js";
|
|
23
33
|
// Single source of truth for the version. `../package.json` resolves to the
|
|
24
34
|
// package root from both dist/index.js and src/index.ts, so this is correct
|
|
25
35
|
// whether running the build or the sources directly. Resolved at runtime
|
|
26
36
|
// rather than imported so the JSON never has to be copied into dist/.
|
|
27
37
|
const { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
28
|
-
const NETWORK = HORIZON_URL.includes("testnet") ? "Pi Testnet" : `custom (${HORIZON_URL})`;
|
|
29
38
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
30
39
|
process.stdout.write(`${VERSION}\n`);
|
|
31
40
|
process.exit(0);
|
|
@@ -38,18 +47,38 @@ if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
|
38
47
|
"invoking it directly.",
|
|
39
48
|
"",
|
|
40
49
|
"Tools: get_wallet_balance, get_account_payments, query_transaction, verify_user",
|
|
50
|
+
" send_payment (only when explicitly armed — see below)",
|
|
41
51
|
"",
|
|
42
52
|
"Environment:",
|
|
43
|
-
"
|
|
53
|
+
" PION_NETWORK testnet (default) or mainnet — which chain the read tools query",
|
|
54
|
+
" PION_HORIZON_URL Horizon base URL, overriding PION_NETWORK's default",
|
|
44
55
|
" PION_PLATFORM_URL Platform API base URL (default: https://api.minepi.com)",
|
|
45
56
|
"",
|
|
57
|
+
"Reads work on both Pi chains. Payments do not: Pi restricts App-to-User",
|
|
58
|
+
"payments to testnet, so send_payment cannot be armed when PION_NETWORK=mainnet.",
|
|
59
|
+
"",
|
|
60
|
+
"Arming send_payment (all four required; testnet only):",
|
|
61
|
+
" PION_ENABLE_PAYMENTS=1 explicit switch, separate from credentials",
|
|
62
|
+
" PION_MAX_PAYMENT_PI required per-payment ceiling, in Pi",
|
|
63
|
+
" PI_SERVER_API_KEY Pi Developer Portal server API key",
|
|
64
|
+
" PI_WALLET_SECRET app wallet secret seed (S...)",
|
|
65
|
+
"",
|
|
46
66
|
].join("\n"));
|
|
47
67
|
process.exit(0);
|
|
48
68
|
}
|
|
69
|
+
// A network we cannot identify is a network we will not serve. Reported here,
|
|
70
|
+
// after --help/--version, so a misconfigured operator can still read the usage.
|
|
71
|
+
if (NETWORK_ERROR) {
|
|
72
|
+
console.error(`pion-mcp: ${NETWORK_ERROR.message}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
49
75
|
const server = new McpServer({ name: "pion-mcp", version: VERSION }, {
|
|
50
76
|
instructions: `Pion exposes read-only Pi Network data. get_wallet_balance, get_account_payments, ` +
|
|
51
77
|
`and query_transaction are public ledger reads from Horizon at ${HORIZON_URL} ` +
|
|
52
|
-
`(${NETWORK}), needing no credentials.
|
|
78
|
+
`(${NETWORK.label}), needing no credentials. This server is reading ` +
|
|
79
|
+
`${NETWORK.label} — every result repeats it in its "network" field, and the two Pi ` +
|
|
80
|
+
`chains are separate ledgers, so an address funded on one does not exist on the ` +
|
|
81
|
+
`other. Amounts are decimal strings; Pi itself is ` +
|
|
53
82
|
'reported as the asset "PI", custom tokens as "CODE:ISSUER", and liquidity-pool ' +
|
|
54
83
|
'shares as "pool:ID". verify_user is different: it checks a user access token ' +
|
|
55
84
|
`against the Pi Platform API at ${PLATFORM_URL} and requires the caller to supply ` +
|
|
@@ -59,13 +88,32 @@ registerGetWalletBalance(server, NETWORK);
|
|
|
59
88
|
registerGetAccountPayments(server, NETWORK);
|
|
60
89
|
registerQueryTransaction(server, NETWORK);
|
|
61
90
|
registerVerifyUser(server);
|
|
91
|
+
// Tier C is registered only when fully armed. A disarmed server does not
|
|
92
|
+
// advertise a payment tool at all, so an agent cannot try to spend and cannot
|
|
93
|
+
// be talked into thinking it might succeed.
|
|
94
|
+
//
|
|
95
|
+
// Passed the resolved network, not the URL: arming turns on what chain this
|
|
96
|
+
// *is*, and a string containing "testnet" is not the same claim.
|
|
97
|
+
const payments = checkPaymentsArming(NETWORK);
|
|
98
|
+
if (payments.armed) {
|
|
99
|
+
registerSendPayment(server, payments.config);
|
|
100
|
+
}
|
|
62
101
|
async function main() {
|
|
63
102
|
// stdout is the JSON-RPC channel — every log line must go to stderr.
|
|
64
103
|
await server.connect(new StdioServerTransport());
|
|
65
|
-
|
|
104
|
+
// The chain is stated first and unabbreviated. A user who never sees this
|
|
105
|
+
// line still gets it on every result, but the one who does see it should not
|
|
106
|
+
// have to infer mainnet from a hostname.
|
|
107
|
+
const emphasis = NETWORK.id === "mainnet" ? " — REAL VALUE" : "";
|
|
108
|
+
console.error(`pion-mcp ${VERSION} ready on stdio — reading ${NETWORK.label}${emphasis} ` +
|
|
109
|
+
`(Horizon: ${HORIZON_URL})`);
|
|
110
|
+
if (NETWORK_WARNING)
|
|
111
|
+
console.error(`⚠ ${NETWORK_WARNING}`);
|
|
112
|
+
console.error(payments.armed
|
|
113
|
+
? `⚠ send_payment ARMED — can spend up to ${payments.config.maxAmountPi} Pi per call from the app wallet`
|
|
114
|
+
: `send_payment disabled — ${payments.reason}`);
|
|
66
115
|
}
|
|
67
116
|
main().catch((error) => {
|
|
68
117
|
console.error("pion-mcp failed to start:", error);
|
|
69
118
|
process.exit(1);
|
|
70
119
|
});
|
|
71
|
-
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Pi chain this server reads.
|
|
3
|
+
*
|
|
4
|
+
* One resolved object is the single source of truth. Everything downstream —
|
|
5
|
+
* the Horizon client, the startup banner, the `network` field on every read
|
|
6
|
+
* result, and the payments arming check — reads it instead of re-deriving the
|
|
7
|
+
* network by sniffing a URL string.
|
|
8
|
+
*
|
|
9
|
+
* That is the point. Before v0.4 the network was inferred in two places by
|
|
10
|
+
* testing `HORIZON_URL.includes("testnet")`: once for the banner, once for
|
|
11
|
+
* arming. Two independent sniffs of one string is how a configuration ends up
|
|
12
|
+
* testnet by one check and mainnet by another.
|
|
13
|
+
*
|
|
14
|
+
* Values below were verified against the live nodes on 2026-08-14, not assumed
|
|
15
|
+
* from the naming pattern. See docs/pi-sdk-notes.md, "Layer 3".
|
|
16
|
+
*/
|
|
17
|
+
export type NetworkId = "testnet" | "mainnet" | "custom";
|
|
18
|
+
export interface PiNetwork {
|
|
19
|
+
id: NetworkId;
|
|
20
|
+
/** Horizon base URL, no trailing slash. */
|
|
21
|
+
horizonUrl: string;
|
|
22
|
+
/** Human-facing name, for banners and tool output. NOT a signing input. */
|
|
23
|
+
label: string;
|
|
24
|
+
/**
|
|
25
|
+
* The Stellar network passphrase, as reported by the node itself.
|
|
26
|
+
* `undefined` for a custom endpoint, whose chain identity we cannot vouch for.
|
|
27
|
+
*/
|
|
28
|
+
passphrase: string | undefined;
|
|
29
|
+
/** Block explorer base, for pointing a human at independent confirmation. */
|
|
30
|
+
explorerUrl: string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* True only for the genuine Pi Testnet. This is the flag payments arm on;
|
|
33
|
+
* nothing else may set it, including a custom endpoint that looks testnet-ish.
|
|
34
|
+
*/
|
|
35
|
+
isTestnet: boolean;
|
|
36
|
+
}
|
|
37
|
+
export declare const PI_TESTNET: PiNetwork;
|
|
38
|
+
export declare const PI_MAINNET: PiNetwork;
|
|
39
|
+
/**
|
|
40
|
+
* Pi publishes a secondary mainnet Horizon at https://api2.mainnet.minepi.com
|
|
41
|
+
* (from `REACT_APP_MAINNET_SECONDARY_API_URL` in the explorer's production
|
|
42
|
+
* build). It is live and reports the same passphrase.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately not wired as automatic failover. Two hosts silently serving one
|
|
45
|
+
* client can disagree — different ingestion lag, one stale — and a read that
|
|
46
|
+
* quietly changes source is exactly the kind of thing that makes a later
|
|
47
|
+
* "the balance was wrong" impossible to reconstruct. Set PION_HORIZON_URL to it
|
|
48
|
+
* by hand if the primary is down.
|
|
49
|
+
*/
|
|
50
|
+
export declare const PI_MAINNET_SECONDARY_URL = "https://api2.mainnet.minepi.com";
|
|
51
|
+
/** A `PION_NETWORK` / `PION_HORIZON_URL` combination we refuse to guess at. */
|
|
52
|
+
export declare class NetworkConfigError extends Error {
|
|
53
|
+
constructor(message: string);
|
|
54
|
+
}
|
|
55
|
+
export interface Resolution {
|
|
56
|
+
network: PiNetwork;
|
|
57
|
+
/** Non-fatal note for the operator, printed at startup. */
|
|
58
|
+
warning?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolves the network from the environment.
|
|
62
|
+
*
|
|
63
|
+
* Pure and env-injectable so the guards can be tested without mutating the
|
|
64
|
+
* real process environment.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveNetwork(env?: NodeJS.ProcessEnv): Resolution;
|
|
67
|
+
export declare const NETWORK: PiNetwork;
|
|
68
|
+
export declare const NETWORK_WARNING: string | undefined;
|
|
69
|
+
export declare const NETWORK_ERROR: NetworkConfigError | undefined;
|
package/dist/networks.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Pi chain this server reads.
|
|
3
|
+
*
|
|
4
|
+
* One resolved object is the single source of truth. Everything downstream —
|
|
5
|
+
* the Horizon client, the startup banner, the `network` field on every read
|
|
6
|
+
* result, and the payments arming check — reads it instead of re-deriving the
|
|
7
|
+
* network by sniffing a URL string.
|
|
8
|
+
*
|
|
9
|
+
* That is the point. Before v0.4 the network was inferred in two places by
|
|
10
|
+
* testing `HORIZON_URL.includes("testnet")`: once for the banner, once for
|
|
11
|
+
* arming. Two independent sniffs of one string is how a configuration ends up
|
|
12
|
+
* testnet by one check and mainnet by another.
|
|
13
|
+
*
|
|
14
|
+
* Values below were verified against the live nodes on 2026-08-14, not assumed
|
|
15
|
+
* from the naming pattern. See docs/pi-sdk-notes.md, "Layer 3".
|
|
16
|
+
*/
|
|
17
|
+
export const PI_TESTNET = {
|
|
18
|
+
id: "testnet",
|
|
19
|
+
horizonUrl: "https://api.testnet.minepi.com",
|
|
20
|
+
label: "Pi Testnet",
|
|
21
|
+
passphrase: "Pi Testnet",
|
|
22
|
+
explorerUrl: "https://blockexplorer.minepi.com/testnet",
|
|
23
|
+
isTestnet: true,
|
|
24
|
+
};
|
|
25
|
+
export const PI_MAINNET = {
|
|
26
|
+
id: "mainnet",
|
|
27
|
+
horizonUrl: "https://api.mainnet.minepi.com",
|
|
28
|
+
label: "Pi Mainnet",
|
|
29
|
+
/**
|
|
30
|
+
* "Pi Network" — NOT "Pi Mainnet".
|
|
31
|
+
*
|
|
32
|
+
* This is the one value here that the naming pattern gets wrong, and getting
|
|
33
|
+
* it wrong is expensive: the passphrase is hashed into every signature, so a
|
|
34
|
+
* transaction signed against the wrong string is invalid on the chain it was
|
|
35
|
+
* meant for.
|
|
36
|
+
*
|
|
37
|
+
* Verified three ways on 2026-08-14:
|
|
38
|
+
* 1. `GET https://api.mainnet.minepi.com/` reports
|
|
39
|
+
* `"network_passphrase": "Pi Network"`. For a Stellar network the node's
|
|
40
|
+
* own answer is definitive — it is the string it validates against.
|
|
41
|
+
* 2. The secondary node, api2.mainnet.minepi.com, reports the same.
|
|
42
|
+
* 3. The string "Pi Mainnet" *does* appear in Pi's production explorer
|
|
43
|
+
* bundle, but only as English UI copy ("...not been activated on the Pi
|
|
44
|
+
* Mainnet yet"). It is a display label that reads like corroboration and
|
|
45
|
+
* is not. Hence `label` and `passphrase` are separate fields here.
|
|
46
|
+
*/
|
|
47
|
+
passphrase: "Pi Network",
|
|
48
|
+
explorerUrl: "https://blockexplorer.minepi.com/mainnet",
|
|
49
|
+
isTestnet: false,
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Pi publishes a secondary mainnet Horizon at https://api2.mainnet.minepi.com
|
|
53
|
+
* (from `REACT_APP_MAINNET_SECONDARY_API_URL` in the explorer's production
|
|
54
|
+
* build). It is live and reports the same passphrase.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately not wired as automatic failover. Two hosts silently serving one
|
|
57
|
+
* client can disagree — different ingestion lag, one stale — and a read that
|
|
58
|
+
* quietly changes source is exactly the kind of thing that makes a later
|
|
59
|
+
* "the balance was wrong" impossible to reconstruct. Set PION_HORIZON_URL to it
|
|
60
|
+
* by hand if the primary is down.
|
|
61
|
+
*/
|
|
62
|
+
export const PI_MAINNET_SECONDARY_URL = "https://api2.mainnet.minepi.com";
|
|
63
|
+
const KNOWN = {
|
|
64
|
+
testnet: PI_TESTNET,
|
|
65
|
+
mainnet: PI_MAINNET,
|
|
66
|
+
};
|
|
67
|
+
/** A `PION_NETWORK` / `PION_HORIZON_URL` combination we refuse to guess at. */
|
|
68
|
+
export class NetworkConfigError extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "NetworkConfigError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolves the network from the environment.
|
|
76
|
+
*
|
|
77
|
+
* Pure and env-injectable so the guards can be tested without mutating the
|
|
78
|
+
* real process environment.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveNetwork(env = process.env) {
|
|
81
|
+
const requestedRaw = env.PION_NETWORK?.trim();
|
|
82
|
+
const requested = requestedRaw?.toLowerCase();
|
|
83
|
+
if (requested !== undefined && requested !== "" && KNOWN[requested] === undefined) {
|
|
84
|
+
throw new NetworkConfigError(`PION_NETWORK must be "testnet" or "mainnet", got "${requestedRaw}". ` +
|
|
85
|
+
"Refusing to start rather than guessing which chain you meant.");
|
|
86
|
+
}
|
|
87
|
+
const selected = requested ? KNOWN[requested] : PI_TESTNET;
|
|
88
|
+
const override = env.PION_HORIZON_URL?.trim().replace(/\/+$/, "");
|
|
89
|
+
if (!override)
|
|
90
|
+
return { network: selected };
|
|
91
|
+
const matched = Object.values(KNOWN).find((candidate) => candidate.horizonUrl === override);
|
|
92
|
+
if (matched) {
|
|
93
|
+
// Both variables are set and they name different chains. One of them is a
|
|
94
|
+
// mistake and we cannot tell which, so stop instead of picking a winner.
|
|
95
|
+
if (requested && matched.id !== selected.id) {
|
|
96
|
+
throw new NetworkConfigError(`PION_NETWORK=${selected.id} and PION_HORIZON_URL=${override} name different ` +
|
|
97
|
+
`networks (${selected.label} vs ${matched.label}). Set one or the other, not both.`);
|
|
98
|
+
}
|
|
99
|
+
return { network: matched };
|
|
100
|
+
}
|
|
101
|
+
// An endpoint we have not verified. It may well be a proxy in front of the
|
|
102
|
+
// real testnet, but we cannot confirm that from here, and "probably testnet"
|
|
103
|
+
// is not a basis for arming a spend. Label it honestly as unknown.
|
|
104
|
+
return {
|
|
105
|
+
network: {
|
|
106
|
+
id: "custom",
|
|
107
|
+
horizonUrl: override,
|
|
108
|
+
label: `custom endpoint (${override})`,
|
|
109
|
+
passphrase: undefined,
|
|
110
|
+
explorerUrl: undefined,
|
|
111
|
+
isTestnet: false,
|
|
112
|
+
},
|
|
113
|
+
warning: `PION_HORIZON_URL points at ${override}, which is not a Pi network Pion knows. ` +
|
|
114
|
+
"Reads will be attempted against it and reported as an unverified chain; " +
|
|
115
|
+
"payments cannot be armed against a custom endpoint.",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Resolved once at startup. On a configuration error this falls back to testnet
|
|
120
|
+
* — the safe default — and records the error for `index.ts` to report before it
|
|
121
|
+
* serves anything. Throwing from module scope would surface as a bare stack
|
|
122
|
+
* trace from an import, which is not a useful thing to hand an operator.
|
|
123
|
+
*/
|
|
124
|
+
let resolutionError;
|
|
125
|
+
let resolved;
|
|
126
|
+
try {
|
|
127
|
+
resolved = resolveNetwork();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
resolutionError = error;
|
|
131
|
+
resolved = { network: PI_TESTNET };
|
|
132
|
+
}
|
|
133
|
+
export const NETWORK = resolved.network;
|
|
134
|
+
export const NETWORK_WARNING = resolved.warning;
|
|
135
|
+
export const NETWORK_ERROR = resolutionError;
|