pion-mcp 0.3.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 +51 -13
- package/dist/horizon.d.ts +7 -1
- package/dist/horizon.js +18 -4
- package/dist/index.js +34 -6
- package/dist/networks.d.ts +69 -0
- package/dist/networks.js +135 -0
- package/dist/payments.d.ts +3 -1
- package/dist/payments.js +31 -4
- package/dist/tools/common.d.ts +15 -0
- package/dist/tools/common.js +21 -0
- package/dist/tools/get-account-payments.d.ts +2 -1
- package/dist/tools/get-account-payments.js +4 -3
- package/dist/tools/get-wallet-balance.d.ts +2 -1
- package/dist/tools/get-wallet-balance.js +4 -3
- package/dist/tools/query-transaction.d.ts +2 -1
- package/dist/tools/query-transaction.js +4 -3
- package/dist/tools/send-payment.js +42 -2
- package/package.json +63 -58
package/README.md
CHANGED
|
@@ -3,8 +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
|
-
> ⚠️
|
|
7
|
-
>
|
|
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.
|
|
8
10
|
|
|
9
11
|
## Why "Pion"?
|
|
10
12
|
The pion is the π meson — the particle physicists named after pi.
|
|
@@ -18,7 +20,8 @@ and move no value. Tier C is the exception and is off unless you arm it.
|
|
|
18
20
|
(Tiers refer to [`docs/tool-mapping.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/tool-mapping.md).)
|
|
19
21
|
|
|
20
22
|
**Tier A — chain reads.** Zero-permission queries against Pi's public Horizon
|
|
21
|
-
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`.
|
|
22
25
|
|
|
23
26
|
| Tool | What it does |
|
|
24
27
|
|---|---|
|
|
@@ -29,6 +32,13 @@ API. No credentials at all.
|
|
|
29
32
|
Amounts are decimal strings. Pi is reported as the asset `PI`, custom tokens as
|
|
30
33
|
`CODE:ISSUER`, and liquidity-pool shares as `pool:ID`.
|
|
31
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
|
+
|
|
32
42
|
**Tier B — identity.**
|
|
33
43
|
|
|
34
44
|
| Tool | What it does |
|
|
@@ -60,8 +70,8 @@ alone is not enough — Pi needs that consent to resolve their wallet, and
|
|
|
60
70
|
refuses payment creation with `missing_scope` otherwise. This is the
|
|
61
71
|
recipient's consent, not your credentials.
|
|
62
72
|
|
|
63
|
-
Arming requires **all four**, and Pi restricts A2U to testnet, so
|
|
64
|
-
|
|
73
|
+
Arming requires **all four**, and Pi restricts A2U to testnet, so anything but
|
|
74
|
+
Pi Testnet is refused outright:
|
|
65
75
|
|
|
66
76
|
```sh
|
|
67
77
|
PION_ENABLE_PAYMENTS=1 # explicit switch, deliberately separate from credentials
|
|
@@ -120,7 +130,8 @@ claude mcp add pion -- node /absolute/path/to/pion-mcp/dist/index.js
|
|
|
120
130
|
|
|
121
131
|
| Variable | Default | Purpose |
|
|
122
132
|
|---|---|---|
|
|
123
|
-
| `
|
|
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 |
|
|
124
135
|
| `PION_PLATFORM_URL` | `https://api.minepi.com` | Platform API base URL |
|
|
125
136
|
| `PION_ENABLE_PAYMENTS` | unset (off) | Arms `send_payment` — see Tier C above |
|
|
126
137
|
| `PION_MAX_PAYMENT_PI` | unset | Required per-payment ceiling when armed |
|
|
@@ -130,16 +141,24 @@ claude mcp add pion -- node /absolute/path/to/pion-mcp/dist/index.js
|
|
|
130
141
|
For read-only use there is nothing to configure — `verify_user` takes its token
|
|
131
142
|
as a call argument, not from the environment. The bottom four are needed only
|
|
132
143
|
if you arm payments, and belong in a secrets manager, never in a committed
|
|
133
|
-
file.
|
|
134
|
-
|
|
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.
|
|
135
152
|
|
|
136
153
|
## Development
|
|
137
154
|
|
|
138
155
|
```sh
|
|
139
156
|
npm run build # compile src/ -> dist/
|
|
140
157
|
npm run typecheck # types only, no emit
|
|
141
|
-
npm run smoke
|
|
142
|
-
npm run
|
|
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)
|
|
143
162
|
```
|
|
144
163
|
|
|
145
164
|
`npm run smoke` spawns the server over stdio as a real MCP client, discovers a
|
|
@@ -150,9 +169,15 @@ It covers `verify_user` only on the **rejection** path — confirming a genuine
|
|
|
150
169
|
token would need a real user credential, which the test deliberately does not
|
|
151
170
|
handle. The success path is unverified; see below.
|
|
152
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
|
+
|
|
153
177
|
`npm run arming` covers Tier C without touching real money: every refusal
|
|
154
178
|
branch, the exact cap boundary, that credentials alone do not arm it, that a
|
|
155
|
-
disarmed server does not advertise the tool,
|
|
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
|
|
156
181
|
a result. It uses a freshly generated, never-funded keypair. The one live call
|
|
157
182
|
it makes is a deliberately-rejected create against the Pi API, which proves the
|
|
158
183
|
first failure stage end to end.
|
|
@@ -177,7 +202,7 @@ first failure stage end to end.
|
|
|
177
202
|
|
|
178
203
|
This is why it ships **off**, and why turning it on takes four separate,
|
|
179
204
|
deliberate acts: `PION_ENABLE_PAYMENTS=1`, a mandatory `PION_MAX_PAYMENT_PI`
|
|
180
|
-
ceiling, both credentials, and
|
|
205
|
+
ceiling, both credentials, and Pi Testnet as the selected network. Holding the credentials
|
|
181
206
|
is not enough on its own. Disarmed, the tool is not registered at all, so an
|
|
182
207
|
agent cannot see that a spending capability exists — that gate is deliberate
|
|
183
208
|
design (see Tier C above), not a placeholder for unfinished work. The
|
|
@@ -195,9 +220,22 @@ wallet Pi will actually spend from.
|
|
|
195
220
|
|
|
196
221
|
## Roadmap
|
|
197
222
|
|
|
198
|
-
The rest of Tier C: `get_payment_status`, `list_incomplete_payments`,
|
|
223
|
+
Done in v0.4: mainnet reads. The rest of Tier C: `get_payment_status`, `list_incomplete_payments`,
|
|
199
224
|
`approve_payment` / `complete_payment` / `cancel_payment` — the U2A backend half
|
|
200
225
|
and the recovery tooling for stranded payments. See
|
|
201
226
|
[`docs/tool-mapping.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/tool-mapping.md).
|
|
202
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.
|
|
236
|
+
|
|
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.
|
|
240
|
+
|
|
203
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
|
package/dist/index.js
CHANGED
|
@@ -12,11 +12,17 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Tiers A and B read no credentials from the environment and cannot move
|
|
14
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.
|
|
15
20
|
*/
|
|
16
21
|
import { createRequire } from "node:module";
|
|
17
22
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
23
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
24
|
import { HORIZON_URL } from "./horizon.js";
|
|
25
|
+
import { NETWORK, NETWORK_ERROR, NETWORK_WARNING } from "./networks.js";
|
|
20
26
|
import { checkPaymentsArming } from "./payments.js";
|
|
21
27
|
import { PLATFORM_URL } from "./platform.js";
|
|
22
28
|
import { registerGetAccountPayments } from "./tools/get-account-payments.js";
|
|
@@ -29,7 +35,6 @@ import { registerVerifyUser } from "./tools/verify-user.js";
|
|
|
29
35
|
// whether running the build or the sources directly. Resolved at runtime
|
|
30
36
|
// rather than imported so the JSON never has to be copied into dist/.
|
|
31
37
|
const { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
32
|
-
const NETWORK = HORIZON_URL.includes("testnet") ? "Pi Testnet" : `custom (${HORIZON_URL})`;
|
|
33
38
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
34
39
|
process.stdout.write(`${VERSION}\n`);
|
|
35
40
|
process.exit(0);
|
|
@@ -45,9 +50,13 @@ if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
|
45
50
|
" send_payment (only when explicitly armed — see below)",
|
|
46
51
|
"",
|
|
47
52
|
"Environment:",
|
|
48
|
-
"
|
|
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",
|
|
49
55
|
" PION_PLATFORM_URL Platform API base URL (default: https://api.minepi.com)",
|
|
50
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
|
+
"",
|
|
51
60
|
"Arming send_payment (all four required; testnet only):",
|
|
52
61
|
" PION_ENABLE_PAYMENTS=1 explicit switch, separate from credentials",
|
|
53
62
|
" PION_MAX_PAYMENT_PI required per-payment ceiling, in Pi",
|
|
@@ -57,10 +66,19 @@ if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
|
57
66
|
].join("\n"));
|
|
58
67
|
process.exit(0);
|
|
59
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
|
+
}
|
|
60
75
|
const server = new McpServer({ name: "pion-mcp", version: VERSION }, {
|
|
61
76
|
instructions: `Pion exposes read-only Pi Network data. get_wallet_balance, get_account_payments, ` +
|
|
62
77
|
`and query_transaction are public ledger reads from Horizon at ${HORIZON_URL} ` +
|
|
63
|
-
`(${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 ` +
|
|
64
82
|
'reported as the asset "PI", custom tokens as "CODE:ISSUER", and liquidity-pool ' +
|
|
65
83
|
'shares as "pool:ID". verify_user is different: it checks a user access token ' +
|
|
66
84
|
`against the Pi Platform API at ${PLATFORM_URL} and requires the caller to supply ` +
|
|
@@ -73,17 +91,27 @@ registerVerifyUser(server);
|
|
|
73
91
|
// Tier C is registered only when fully armed. A disarmed server does not
|
|
74
92
|
// advertise a payment tool at all, so an agent cannot try to spend and cannot
|
|
75
93
|
// be talked into thinking it might succeed.
|
|
76
|
-
|
|
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);
|
|
77
98
|
if (payments.armed) {
|
|
78
99
|
registerSendPayment(server, payments.config);
|
|
79
100
|
}
|
|
80
101
|
async function main() {
|
|
81
102
|
// stdout is the JSON-RPC channel — every log line must go to stderr.
|
|
82
103
|
await server.connect(new StdioServerTransport());
|
|
83
|
-
|
|
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}`);
|
|
84
112
|
console.error(payments.armed
|
|
85
113
|
? `⚠ send_payment ARMED — can spend up to ${payments.config.maxAmountPi} Pi per call from the app wallet`
|
|
86
|
-
: `send_payment disabled
|
|
114
|
+
: `send_payment disabled — ${payments.reason}`);
|
|
87
115
|
}
|
|
88
116
|
main().catch((error) => {
|
|
89
117
|
console.error("pion-mcp failed to start:", error);
|
|
@@ -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;
|
package/dist/payments.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* argument, returned in a result, or logged.
|
|
12
12
|
*/
|
|
13
13
|
import { z } from "zod";
|
|
14
|
+
import { type PiNetwork } from "./networks.js";
|
|
14
15
|
export interface PaymentsConfig {
|
|
15
16
|
serverApiKey: string;
|
|
16
17
|
walletSecret: string;
|
|
@@ -59,6 +60,7 @@ export declare function paymentMetadata(supplied?: Record<string, unknown>): Rec
|
|
|
59
60
|
export declare const createdPaymentSchema: z.ZodObject<{
|
|
60
61
|
identifier: z.ZodString;
|
|
61
62
|
to_address: z.ZodString;
|
|
63
|
+
from_address: z.ZodString;
|
|
62
64
|
amount: z.ZodNumber;
|
|
63
65
|
status: z.ZodObject<{
|
|
64
66
|
developer_approved: z.ZodBoolean;
|
|
@@ -94,4 +96,4 @@ export declare function recordedAmountToStroops(amount: number): bigint;
|
|
|
94
96
|
* Decides whether payments may run at all. Returns a specific reason on
|
|
95
97
|
* refusal so an operator can tell a missing switch from a missing credential.
|
|
96
98
|
*/
|
|
97
|
-
export declare function checkPaymentsArming(
|
|
99
|
+
export declare function checkPaymentsArming(network: PiNetwork): PaymentsArming;
|
package/dist/payments.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* argument, returned in a result, or logged.
|
|
12
12
|
*/
|
|
13
13
|
import { z } from "zod";
|
|
14
|
+
import { PI_TESTNET } from "./networks.js";
|
|
14
15
|
/** Stellar amounts carry 7 decimal places; 1 Pi = 10^7 stroops. */
|
|
15
16
|
const STROOPS_PER_PI = 10000000n;
|
|
16
17
|
const AMOUNT_PATTERN = /^\d+(\.\d{1,7})?$/;
|
|
@@ -65,6 +66,14 @@ export const createdPaymentSchema = z.object({
|
|
|
65
66
|
identifier: z.string().min(1),
|
|
66
67
|
/** The recipient's wallet. Present on create — no separate lookup needed. */
|
|
67
68
|
to_address: z.string().regex(STELLAR_ADDRESS, "is not a Stellar public key"),
|
|
69
|
+
/**
|
|
70
|
+
* The wallet Pi expects to send from — the app wallet *selected* in the
|
|
71
|
+
* Developer Portal, which is not necessarily the one `PI_WALLET_SECRET`
|
|
72
|
+
* unlocks. Observed 2026-08-01: Pi returns the selected wallet here
|
|
73
|
+
* regardless of what key the app actually holds, so this is the only place
|
|
74
|
+
* the two can be compared before signing.
|
|
75
|
+
*/
|
|
76
|
+
from_address: z.string().regex(STELLAR_ADDRESS, "is not a Stellar public key"),
|
|
68
77
|
/** Pi's record of the amount, to be cross-checked against what was asked. */
|
|
69
78
|
amount: z.number().finite(),
|
|
70
79
|
status: z.object({
|
|
@@ -106,7 +115,7 @@ export function recordedAmountToStroops(amount) {
|
|
|
106
115
|
* Decides whether payments may run at all. Returns a specific reason on
|
|
107
116
|
* refusal so an operator can tell a missing switch from a missing credential.
|
|
108
117
|
*/
|
|
109
|
-
export function checkPaymentsArming(
|
|
118
|
+
export function checkPaymentsArming(network) {
|
|
110
119
|
const enable = process.env.PION_ENABLE_PAYMENTS;
|
|
111
120
|
if (enable !== "1" && enable?.toLowerCase() !== "true") {
|
|
112
121
|
return {
|
|
@@ -116,11 +125,29 @@ export function checkPaymentsArming(horizonUrl) {
|
|
|
116
125
|
}
|
|
117
126
|
// A2U is testnet-only per Pi's payments_advanced.md. Refuse anything else
|
|
118
127
|
// rather than discovering the restriction mid-flow with a created payment.
|
|
119
|
-
|
|
128
|
+
//
|
|
129
|
+
// Two conditions, deliberately. Until v0.4 this was a single substring test
|
|
130
|
+
// for "testnet" in the Horizon URL, which any string containing that word
|
|
131
|
+
// satisfied — https://api.mainnet.minepi.com/#testnet included. Arming now
|
|
132
|
+
// requires the resolved network to *be* Pi Testnet, and a custom endpoint
|
|
133
|
+
// resolves to `custom` however testnet-ish its URL looks.
|
|
134
|
+
if (!network.isTestnet) {
|
|
120
135
|
return {
|
|
121
136
|
armed: false,
|
|
122
|
-
reason: `
|
|
123
|
-
"App-to-User payments to testnet, and Pion will not attempt them
|
|
137
|
+
reason: `the selected network is ${network.label} (${network.horizonUrl}), not Pi Testnet. ` +
|
|
138
|
+
"Pi restricts App-to-User payments to testnet, and Pion will not attempt them " +
|
|
139
|
+
"elsewhere. Payments cannot be armed while PION_NETWORK=mainnet or while " +
|
|
140
|
+
"PION_HORIZON_URL points at an endpoint Pion cannot identify.",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// Self-consistency assertion, not a second guess at the network. It cannot
|
|
144
|
+
// fire from any environment variable — only from someone editing the table in
|
|
145
|
+
// networks.ts so that `isTestnet` and the URL disagree.
|
|
146
|
+
if (network.horizonUrl !== PI_TESTNET.horizonUrl) {
|
|
147
|
+
return {
|
|
148
|
+
armed: false,
|
|
149
|
+
reason: `network table inconsistency: a network marked testnet points at ` +
|
|
150
|
+
`${network.horizonUrl}, not ${PI_TESTNET.horizonUrl}. Refusing to arm.`,
|
|
124
151
|
};
|
|
125
152
|
}
|
|
126
153
|
const serverApiKey = process.env.PI_SERVER_API_KEY;
|
package/dist/tools/common.d.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import type { PiNetwork } from "../networks.js";
|
|
4
|
+
/**
|
|
5
|
+
* The sentence every Tier A tool description ends with.
|
|
6
|
+
*
|
|
7
|
+
* Named once so all three read tools say the same thing about the same fact.
|
|
8
|
+
* An agent that only ever sees a tool description — never the startup banner —
|
|
9
|
+
* still learns which chain it is reading and why that matters.
|
|
10
|
+
*
|
|
11
|
+
* The wording is deliberate about what a wrong-chain read looks like. Measured
|
|
12
|
+
* 2026-08-14: some Pi addresses hold a balance on *both* chains, with different
|
|
13
|
+
* amounts (docs/FINDINGS.md, finding 5). So querying the wrong network does not
|
|
14
|
+
* reliably produce a not-found error — it can produce a well-formed, plausible,
|
|
15
|
+
* wrong number. That is why the network is stated rather than implied.
|
|
16
|
+
*/
|
|
17
|
+
export declare function networkNote(network: PiNetwork): string;
|
|
3
18
|
/** Stellar/Pi public key: 56 base32 characters beginning with G. */
|
|
4
19
|
export declare const walletAddress: z.ZodString;
|
|
5
20
|
/** Stellar transaction hash: 64 hex characters. */
|
package/dist/tools/common.js
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { HorizonError } from "../horizon.js";
|
|
3
3
|
import { PlatformError } from "../platform.js";
|
|
4
|
+
/**
|
|
5
|
+
* The sentence every Tier A tool description ends with.
|
|
6
|
+
*
|
|
7
|
+
* Named once so all three read tools say the same thing about the same fact.
|
|
8
|
+
* An agent that only ever sees a tool description — never the startup banner —
|
|
9
|
+
* still learns which chain it is reading and why that matters.
|
|
10
|
+
*
|
|
11
|
+
* The wording is deliberate about what a wrong-chain read looks like. Measured
|
|
12
|
+
* 2026-08-14: some Pi addresses hold a balance on *both* chains, with different
|
|
13
|
+
* amounts (docs/FINDINGS.md, finding 5). So querying the wrong network does not
|
|
14
|
+
* reliably produce a not-found error — it can produce a well-formed, plausible,
|
|
15
|
+
* wrong number. That is why the network is stated rather than implied.
|
|
16
|
+
*/
|
|
17
|
+
export function networkNote(network) {
|
|
18
|
+
return (`This server reads ${network.label}, and every result repeats that in its ` +
|
|
19
|
+
'"network" field — always report which chain a figure came from. Pi Mainnet ' +
|
|
20
|
+
"and Pi Testnet are separate ledgers sharing one address format, and the same " +
|
|
21
|
+
"address can hold different balances on each, so a result from the wrong chain " +
|
|
22
|
+
"looks entirely normal. Testnet Pi has no monetary value: never present a " +
|
|
23
|
+
"testnet balance as real holdings.");
|
|
24
|
+
}
|
|
4
25
|
/** Stellar/Pi public key: 56 base32 characters beginning with G. */
|
|
5
26
|
export const walletAddress = z
|
|
6
27
|
.string()
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
|
|
2
|
+
import type { PiNetwork } from "../networks.js";
|
|
3
|
+
export declare function registerGetAccountPayments(server: McpServer, network: PiNetwork): void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { cursorFromLink, formatAsset, horizonGet } from "../horizon.js";
|
|
3
|
-
import { fail, ok, pagingCursor, pagingLimit, pagingOrder, walletAddress } from "./common.js";
|
|
3
|
+
import { fail, networkNote, ok, pagingCursor, pagingLimit, pagingOrder, walletAddress, } from "./common.js";
|
|
4
4
|
const paymentShape = z.object({
|
|
5
5
|
id: z.string(),
|
|
6
6
|
type: z.string(),
|
|
@@ -72,7 +72,8 @@ export function registerGetAccountPayments(server, network) {
|
|
|
72
72
|
"a payment arrived, who funded an account, or what it recently sent. Covers " +
|
|
73
73
|
"payments, account creations, path payments, and account merges. Results are " +
|
|
74
74
|
"paginated: pass the returned `next_cursor` back as `cursor` for the next page. " +
|
|
75
|
-
"Reads public ledger data only."
|
|
75
|
+
"Reads public ledger data only. " +
|
|
76
|
+
networkNote(network),
|
|
76
77
|
inputSchema: {
|
|
77
78
|
address: walletAddress,
|
|
78
79
|
limit: pagingLimit,
|
|
@@ -93,7 +94,7 @@ export function registerGetAccountPayments(server, network) {
|
|
|
93
94
|
// set. Only surface a cursor when the page came back full.
|
|
94
95
|
const nextCursor = payments.length === limit ? cursorFromLink(page._links?.next?.href) : undefined;
|
|
95
96
|
return ok({
|
|
96
|
-
network,
|
|
97
|
+
network: network.label,
|
|
97
98
|
account_id: address,
|
|
98
99
|
count: payments.length,
|
|
99
100
|
...(nextCursor !== undefined ? { next_cursor: nextCursor } : {}),
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
|
|
2
|
+
import type { PiNetwork } from "../networks.js";
|
|
3
|
+
export declare function registerGetWalletBalance(server: McpServer, network: PiNetwork): void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatAsset, horizonGet } from "../horizon.js";
|
|
3
|
-
import { fail, ok, walletAddress } from "./common.js";
|
|
3
|
+
import { fail, networkNote, ok, walletAddress } from "./common.js";
|
|
4
4
|
const outputSchema = {
|
|
5
5
|
network: z.string(),
|
|
6
6
|
account_id: z.string(),
|
|
@@ -21,7 +21,8 @@ export function registerGetWalletBalance(server, network) {
|
|
|
21
21
|
description: "Read the current Pi and custom-token balances of a Pi wallet address. " +
|
|
22
22
|
"Call this whenever you need to know how much Pi an address holds, whether it " +
|
|
23
23
|
"holds a particular token, or whether the account exists on-chain at all. " +
|
|
24
|
-
"Reads public ledger data only — it cannot move funds and needs no credentials."
|
|
24
|
+
"Reads public ledger data only — it cannot move funds and needs no credentials. " +
|
|
25
|
+
networkNote(network),
|
|
25
26
|
inputSchema: { address: walletAddress },
|
|
26
27
|
outputSchema,
|
|
27
28
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
@@ -29,7 +30,7 @@ export function registerGetWalletBalance(server, network) {
|
|
|
29
30
|
try {
|
|
30
31
|
const account = await horizonGet(`/accounts/${address}`);
|
|
31
32
|
return ok({
|
|
32
|
-
network,
|
|
33
|
+
network: network.label,
|
|
33
34
|
account_id: account.account_id,
|
|
34
35
|
sequence: account.sequence,
|
|
35
36
|
subentry_count: account.subentry_count,
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
|
|
2
|
+
import type { PiNetwork } from "../networks.js";
|
|
3
|
+
export declare function registerQueryTransaction(server: McpServer, network: PiNetwork): void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { horizonGet } from "../horizon.js";
|
|
3
|
-
import { fail, ok, transactionHash } from "./common.js";
|
|
3
|
+
import { fail, networkNote, ok, transactionHash } from "./common.js";
|
|
4
4
|
const outputSchema = {
|
|
5
5
|
network: z.string(),
|
|
6
6
|
hash: z.string(),
|
|
@@ -23,7 +23,8 @@ export function registerQueryTransaction(server, network) {
|
|
|
23
23
|
"which ledger it landed in, who submitted it, the fee charged, and its memo. " +
|
|
24
24
|
"Call this to verify that a specific transaction actually went through — a user " +
|
|
25
25
|
"or another service claiming a payment was made is not proof; this is. " +
|
|
26
|
-
"Reads public ledger data only."
|
|
26
|
+
"Reads public ledger data only. " +
|
|
27
|
+
networkNote(network),
|
|
27
28
|
inputSchema: { hash: transactionHash },
|
|
28
29
|
outputSchema,
|
|
29
30
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
@@ -31,7 +32,7 @@ export function registerQueryTransaction(server, network) {
|
|
|
31
32
|
try {
|
|
32
33
|
const tx = await horizonGet(`/transactions/${hash.toLowerCase()}`);
|
|
33
34
|
return ok({
|
|
34
|
-
network,
|
|
35
|
+
network: network.label,
|
|
35
36
|
hash: tx.hash,
|
|
36
37
|
successful: tx.successful,
|
|
37
38
|
ledger: tx.ledger,
|
|
@@ -3,7 +3,23 @@ import { HORIZON_URL } from "../horizon.js";
|
|
|
3
3
|
import { platformPostAsApp } from "../platform.js";
|
|
4
4
|
import { parseCreatedPayment, paymentMetadata, recordedAmountToStroops, toStroops, } from "../payments.js";
|
|
5
5
|
import { ok } from "./common.js";
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Pi testnet's Stellar network passphrase (docs/pi-sdk-notes.md, Layer 3).
|
|
8
|
+
*
|
|
9
|
+
* Hardcoded on purpose, and deliberately NOT read from the resolved network in
|
|
10
|
+
* networks.ts even though that object now carries a passphrase for each chain.
|
|
11
|
+
*
|
|
12
|
+
* This is the last barrier. Arming already refuses anything but Pi Testnet, so
|
|
13
|
+
* this constant should be unreachable on another chain — but "should be
|
|
14
|
+
* unreachable" is a claim about code that can be wrong. A passphrase is hashed
|
|
15
|
+
* into every signature, so a transaction signed with "Pi Testnet" is rejected
|
|
16
|
+
* outright by Pi Mainnet rather than moving funds on it. Wiring this to the
|
|
17
|
+
* network table would turn a bug in the arming logic from a rejected
|
|
18
|
+
* transaction into a real mainnet spend.
|
|
19
|
+
*
|
|
20
|
+
* The cost of keeping it fixed is that mainnet A2U would need a deliberate edit
|
|
21
|
+
* here. That is the intended cost, and Pi does not permit mainnet A2U anyway.
|
|
22
|
+
*/
|
|
7
23
|
const NETWORK_PASSPHRASE = "Pi Testnet";
|
|
8
24
|
/** Stellar text memos are capped at 28 bytes. */
|
|
9
25
|
const MAX_MEMO_BYTES = 28;
|
|
@@ -167,10 +183,34 @@ export function registerSendPayment(server, config) {
|
|
|
167
183
|
`${payment.status.developer_approved}). A2U is normally approved on ` +
|
|
168
184
|
"creation. Nothing was signed.", { paymentId, amount, uid });
|
|
169
185
|
}
|
|
186
|
+
// Pi will only match a transaction sent from the wallet it selected.
|
|
187
|
+
// Signing with a different key produces a transfer Pi cannot verify:
|
|
188
|
+
// funds leave and the payment stays incomplete. The two are genuinely
|
|
189
|
+
// independent — Pi reports its selected wallet here whatever key we
|
|
190
|
+
// hold — so this is the one place the mismatch is catchable, and it is
|
|
191
|
+
// catchable only before signing.
|
|
192
|
+
if (payment.from_address !== keypair.publicKey()) {
|
|
193
|
+
return strandedReport("submit", `Pi expects this payment to be sent from ${payment.from_address}, but ` +
|
|
194
|
+
`PI_WALLET_SECRET unlocks ${keypair.publicKey()}. Signing with the wrong ` +
|
|
195
|
+
"wallet would move funds Pi could not match to this payment. Nothing was " +
|
|
196
|
+
"signed. Select the right app wallet in the Developer Portal, or load the " +
|
|
197
|
+
"secret for the wallet already selected.", { paymentId, amount, uid });
|
|
198
|
+
}
|
|
170
199
|
// ---- Step 2: sign and submit on-chain. Irreversible. ----
|
|
171
200
|
stage = "submit";
|
|
172
201
|
const horizon = new Horizon.Server(HORIZON_URL);
|
|
173
|
-
|
|
202
|
+
let account;
|
|
203
|
+
try {
|
|
204
|
+
account = await horizon.loadAccount(keypair.publicKey());
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Horizon says "Not Found" here, which names neither the account nor
|
|
208
|
+
// the reason. On Stellar an account exists only once funded, so this
|
|
209
|
+
// is nearly always an unfunded app wallet.
|
|
210
|
+
return strandedReport("submit", `the app wallet ${keypair.publicKey()} does not exist on ${HORIZON_URL}. ` +
|
|
211
|
+
"A Stellar account only exists once it has been funded, and an unfunded " +
|
|
212
|
+
"account cannot send. Fund it and retry. Nothing was signed.", { paymentId, amount, uid });
|
|
213
|
+
}
|
|
174
214
|
const baseFee = await horizon.fetchBaseFee();
|
|
175
215
|
const tx = new TransactionBuilder(account, {
|
|
176
216
|
fee: String(baseFee),
|
package/package.json
CHANGED
|
@@ -1,58 +1,63 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pion-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pion — Model Context Protocol (MCP) server for Pi Network. Read-only chain queries against Pi testnet
|
|
5
|
-
"keywords": [
|
|
6
|
-
"mcp",
|
|
7
|
-
"model-context-protocol",
|
|
8
|
-
"pi-network",
|
|
9
|
-
"ai-agents",
|
|
10
|
-
"blockchain",
|
|
11
|
-
"stellar",
|
|
12
|
-
"horizon"
|
|
13
|
-
],
|
|
14
|
-
"license": "Apache-2.0",
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/jleeblack/pion-mcp.git"
|
|
18
|
-
},
|
|
19
|
-
"type": "module",
|
|
20
|
-
"main": "./dist/index.js",
|
|
21
|
-
"types": "./dist/index.d.ts",
|
|
22
|
-
"bin": {
|
|
23
|
-
"pion-mcp": "dist/index.js"
|
|
24
|
-
},
|
|
25
|
-
"files": [
|
|
26
|
-
"dist"
|
|
27
|
-
],
|
|
28
|
-
"engines": {
|
|
29
|
-
"node": ">=18.17"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "tsc",
|
|
33
|
-
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
34
|
-
"typecheck": "tsc --noEmit",
|
|
35
|
-
"start": "node dist/index.js",
|
|
36
|
-
"smoke": "node scripts/smoke.mjs",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
},
|
|
54
|
-
"
|
|
55
|
-
"@
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pion-mcp",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Pion — Model Context Protocol (MCP) server for Pi Network. Read-only chain queries against Pi Mainnet and Testnet; payments are testnet-only.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"pi-network",
|
|
9
|
+
"ai-agents",
|
|
10
|
+
"blockchain",
|
|
11
|
+
"stellar",
|
|
12
|
+
"horizon"
|
|
13
|
+
],
|
|
14
|
+
"license": "Apache-2.0",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/jleeblack/pion-mcp.git"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"bin": {
|
|
23
|
+
"pion-mcp": "dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.17"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"start": "node dist/index.js",
|
|
36
|
+
"smoke": "node scripts/smoke.mjs",
|
|
37
|
+
"smoke:mainnet": "node scripts/smoke.mjs --network=mainnet",
|
|
38
|
+
"crossnet": "node scripts/crossnet-test.mjs",
|
|
39
|
+
"arming": "node scripts/arming-test.mjs",
|
|
40
|
+
"u2a": "node scripts/u2a-test.mjs",
|
|
41
|
+
"incomplete": "node scripts/incomplete.mjs",
|
|
42
|
+
"probe:a2u": "node scripts/probe-a2u.mjs",
|
|
43
|
+
"diagnose:a2u": "node scripts/diagnose-a2u.mjs",
|
|
44
|
+
"identify:app": "node scripts/identify-app.mjs",
|
|
45
|
+
"browser-auth": "node scripts/browser-auth/serve.mjs",
|
|
46
|
+
"wallet": "node scripts/app-wallet.mjs",
|
|
47
|
+
"signin": "node scripts/pi-signin.mjs",
|
|
48
|
+
"prepack": "npm run clean && tsc --sourceMap false",
|
|
49
|
+
"send": "node scripts/send.mjs",
|
|
50
|
+
"get-payment": "node scripts/get-payment.mjs",
|
|
51
|
+
"cancel": "node scripts/cancel.mjs",
|
|
52
|
+
"complete": "node scripts/complete.mjs"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
56
|
+
"@stellar/stellar-sdk": "^16.2.0",
|
|
57
|
+
"zod": "^4.4.3"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^26.1.2",
|
|
61
|
+
"typescript": "^7.0.2"
|
|
62
|
+
}
|
|
63
|
+
}
|