pion-mcp 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,6 +3,8 @@
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
+ [![pion-mcp MCP server](https://glama.ai/mcp/servers/jleeblack/pion-mcp/badges/card.svg)](https://glama.ai/mcp/servers/jleeblack/pion-mcp)
7
+
6
8
  > ⚠️ **Reads: both networks. Payments: testnet only.** The chain tools query Pi
7
9
  > Mainnet or Pi Testnet, selected with `PION_NETWORK`. `send_payment` moves real
8
10
  > funds, must be explicitly armed, and *cannot* be armed on mainnet — Pi
@@ -130,8 +132,8 @@ claude mcp add pion -- node /absolute/path/to/pion-mcp/dist/index.js
130
132
 
131
133
  | Variable | Default | Purpose |
132
134
  |---|---|---|
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 |
135
+ | `PION_NETWORK` | `testnet` | Which chain the read tools query — `testnet` or `mainnet`. Mainnet is echoed as **REAL VALUE** in the startup banner |
136
+ | `PION_HORIZON_URL` | derived from `PION_NETWORK` | Override for the Horizon base URL. Optional. If set alongside `PION_NETWORK` the two must name the same chain — a contradiction is a startup error, not a silent winner |
135
137
  | `PION_PLATFORM_URL` | `https://api.minepi.com` | Platform API base URL |
136
138
  | `PION_ENABLE_PAYMENTS` | unset (off) | Arms `send_payment` — see Tier C above |
137
139
  | `PION_MAX_PAYMENT_PI` | unset | Required per-payment ceiling when armed |
package/dist/index.js CHANGED
@@ -50,8 +50,11 @@ if (process.argv.includes("--help") || process.argv.includes("-h")) {
50
50
  " send_payment (only when explicitly armed — see below)",
51
51
  "",
52
52
  "Environment:",
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",
53
+ " PION_NETWORK testnet (default) or mainnet — which chain the read tools",
54
+ " query. mainnet is echoed as REAL VALUE in the startup banner.",
55
+ " PION_HORIZON_URL Override for the Horizon base URL. Optional; derived from",
56
+ " PION_NETWORK when unset. If both are set they must name the",
57
+ " same chain — a contradiction is a startup error, not a guess.",
55
58
  " PION_PLATFORM_URL Platform API base URL (default: https://api.minepi.com)",
56
59
  "",
57
60
  "Reads work on both Pi chains. Payments do not: Pi restricts App-to-User",
@@ -72,29 +75,54 @@ if (NETWORK_ERROR) {
72
75
  console.error(`pion-mcp: ${NETWORK_ERROR.message}`);
73
76
  process.exit(1);
74
77
  }
75
- const server = new McpServer({ name: "pion-mcp", version: VERSION }, {
76
- instructions: `Pion exposes read-only Pi Network data. get_wallet_balance, get_account_payments, ` +
77
- `and query_transaction are public ledger reads from Horizon at ${HORIZON_URL} ` +
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 ` +
82
- 'reported as the asset "PI", custom tokens as "CODE:ISSUER", and liquidity-pool ' +
83
- 'shares as "pool:ID". verify_user is different: it checks a user access token ' +
84
- `against the Pi Platform API at ${PLATFORM_URL} and requires the caller to supply ` +
85
- "that token. No tool here can send payments, sign anything, or spend from a wallet.",
86
- });
87
- registerGetWalletBalance(server, NETWORK);
88
- registerGetAccountPayments(server, NETWORK);
89
- registerQueryTransaction(server, NETWORK);
90
- registerVerifyUser(server);
91
78
  // Tier C is registered only when fully armed. A disarmed server does not
92
79
  // advertise a payment tool at all, so an agent cannot try to spend and cannot
93
80
  // be talked into thinking it might succeed.
94
81
  //
95
82
  // Passed the resolved network, not the URL: arming turns on what chain this
96
83
  // *is*, and a string containing "testnet" is not the same claim.
84
+ //
85
+ // Resolved BEFORE the server is constructed because the instructions below
86
+ // state whether a spend tool exists, and that claim has to be built from the
87
+ // same answer that decides whether one is registered. Until 0.4.2 the check
88
+ // ran afterwards and the instructions said "no tool here can send payments"
89
+ // unconditionally — false on an armed server, which is the one configuration
90
+ // where being wrong about it costs money.
97
91
  const payments = checkPaymentsArming(NETWORK);
92
+ /**
93
+ * Server instructions — the text a client places ahead of the tool catalog.
94
+ *
95
+ * Every session pays for this in context, so it carries only what changes a
96
+ * decision an agent is about to make, and nothing recoverable from a tool
97
+ * description it will read anyway.
98
+ *
99
+ * The cross-chain sentence is the reason this exists. It previously said an
100
+ * address funded on one chain "does not exist on the other" — measured false
101
+ * on 2026-08-14 (docs/FINDINGS.md finding 5): one address held 2.06 Pi on
102
+ * mainnet and 32.29938 Pi on testnet simultaneously. That wording invited
103
+ * exactly the wrong inference, that a wrong-chain read fails loudly. It does
104
+ * not; it returns a plausible number. An agent needs the true version before
105
+ * it reports a figure, not after.
106
+ */
107
+ const instructions = `Pion reads Pi Network chain data. get_wallet_balance, get_account_payments and ` +
108
+ `query_transaction are public Horizon reads at ${HORIZON_URL}, needing no ` +
109
+ `credentials. verify_user checks a caller-supplied user access token against the ` +
110
+ `Pi Platform API at ${PLATFORM_URL}. ` +
111
+ `This server reads ${NETWORK.label}, and every result carries a "network" field — ` +
112
+ `check it before trusting a figure. The two Pi chains are separate ledgers sharing ` +
113
+ `one address format, and the same address can hold different balances on each: a ` +
114
+ `wrong-chain read returns a plausible wrong number, not an error. Testnet Pi has no ` +
115
+ `monetary value. Amounts are decimal strings; Pi is "PI", tokens "CODE:ISSUER", ` +
116
+ `pool shares "pool:ID". ` +
117
+ (payments.armed
118
+ ? `send_payment is ARMED and can spend up to ${payments.config.maxAmountPi} Pi per ` +
119
+ `call from the app wallet on ${NETWORK.label}; every other tool is read-only.`
120
+ : "No tool here can send payments, sign anything, or spend from a wallet.");
121
+ const server = new McpServer({ name: "pion-mcp", version: VERSION }, { instructions });
122
+ registerGetWalletBalance(server, NETWORK);
123
+ registerGetAccountPayments(server, NETWORK);
124
+ registerQueryTransaction(server, NETWORK);
125
+ registerVerifyUser(server);
98
126
  if (payments.armed) {
99
127
  registerSendPayment(server, payments.config);
100
128
  }
@@ -15,9 +15,33 @@ import type { PiNetwork } from "../networks.js";
15
15
  * wrong number. That is why the network is stated rather than implied.
16
16
  */
17
17
  export declare function networkNote(network: PiNetwork): string;
18
- /** Stellar/Pi public key: 56 base32 characters beginning with G. */
18
+ /**
19
+ * Stellar/Pi public key: 56 base32 characters beginning with G.
20
+ *
21
+ * The description states the alphabet, not just the length, because base32
22
+ * excludes 0/1/8/9 — the characters a human is most likely to introduce when
23
+ * retyping an address. It also carries an example, and a warning against
24
+ * reusing it: the regex here proves the *shape*, and nothing more. A different
25
+ * well-formed address is not rejected, it is answered, so a copied example
26
+ * returns a real balance belonging to someone else. That is the same silent
27
+ * wrong answer the network stamp exists to prevent (see `networkNote`).
28
+ *
29
+ * Note the checksum is deliberately not verified here. Strkey carries a CRC16
30
+ * that this regex cannot see, so a mistyped-but-well-formed address reaches
31
+ * Horizon and comes back as a 400 rather than a local validation error.
32
+ */
19
33
  export declare const walletAddress: z.ZodString;
20
- /** Stellar transaction hash: 64 hex characters. */
34
+ /**
35
+ * Stellar transaction hash: 64 hex characters.
36
+ *
37
+ * Either case is accepted and `query_transaction` lowercases before the request,
38
+ * so the description says so rather than implying lower-case-only input.
39
+ *
40
+ * The "not a payment id" clause is load-bearing. Pi's A2U flow hands out a
41
+ * payment identifier and a txid that are different things at different layers
42
+ * (docs/runbook.md), and an agent holding one of them has no format cue telling
43
+ * it which endpoint wants which.
44
+ */
21
45
  export declare const transactionHash: z.ZodString;
22
46
  export declare const pagingLimit: z.ZodDefault<z.ZodNumber>;
23
47
  export declare const pagingCursor: z.ZodOptional<z.ZodString>;
@@ -22,16 +22,48 @@ export function networkNote(network) {
22
22
  "looks entirely normal. Testnet Pi has no monetary value: never present a " +
23
23
  "testnet balance as real holdings.");
24
24
  }
25
- /** Stellar/Pi public key: 56 base32 characters beginning with G. */
25
+ /**
26
+ * Stellar/Pi public key: 56 base32 characters beginning with G.
27
+ *
28
+ * The description states the alphabet, not just the length, because base32
29
+ * excludes 0/1/8/9 — the characters a human is most likely to introduce when
30
+ * retyping an address. It also carries an example, and a warning against
31
+ * reusing it: the regex here proves the *shape*, and nothing more. A different
32
+ * well-formed address is not rejected, it is answered, so a copied example
33
+ * returns a real balance belonging to someone else. That is the same silent
34
+ * wrong answer the network stamp exists to prevent (see `networkNote`).
35
+ *
36
+ * Note the checksum is deliberately not verified here. Strkey carries a CRC16
37
+ * that this regex cannot see, so a mistyped-but-well-formed address reaches
38
+ * Horizon and comes back as a 400 rather than a local validation error.
39
+ */
26
40
  export const walletAddress = z
27
41
  .string()
28
- .regex(/^G[A-Z2-7]{55}$/, "must be a 56-character Pi wallet address starting with G (e.g. GABC...XYZ)")
29
- .describe("Pi wallet address (Stellar public key, 56 characters, starts with G)");
30
- /** Stellar transaction hash: 64 hex characters. */
42
+ .regex(/^G[A-Z2-7]{55}$/, "must be a Pi wallet address: exactly 56 characters, starting with G, the rest " +
43
+ "base32 (A-Z and 2-7 only never 0, 1, 8 or 9)")
44
+ .describe("Pi wallet address (a Stellar public key): exactly 56 upper-case characters, " +
45
+ "starting with G, the rest base32 — A-Z and 2-7 only, never 0, 1, 8 or 9. " +
46
+ "Example: GATQBZLIAUVMND2OCPOKWGPUCNXIGKMNUU7E67YQI2MODSMCMLXBAIJA — that is a " +
47
+ "format sample, not a default. Pass the address you were actually given: a valid " +
48
+ "address that is not the intended one returns someone else's balance, not an error.");
49
+ /**
50
+ * Stellar transaction hash: 64 hex characters.
51
+ *
52
+ * Either case is accepted and `query_transaction` lowercases before the request,
53
+ * so the description says so rather than implying lower-case-only input.
54
+ *
55
+ * The "not a payment id" clause is load-bearing. Pi's A2U flow hands out a
56
+ * payment identifier and a txid that are different things at different layers
57
+ * (docs/runbook.md), and an agent holding one of them has no format cue telling
58
+ * it which endpoint wants which.
59
+ */
31
60
  export const transactionHash = z
32
61
  .string()
33
- .regex(/^[0-9a-fA-F]{64}$/, "must be a 64-character hex transaction hash")
34
- .describe("Transaction hash (64 hex characters)");
62
+ .regex(/^[0-9a-fA-F]{64}$/, "must be a transaction hash: exactly 64 hexadecimal characters (0-9 and a-f)")
63
+ .describe("On-chain transaction hash: exactly 64 hexadecimal characters (0-9 and a-f). " +
64
+ "Upper case is accepted and normalized to lower case. This is the Stellar " +
65
+ "transaction hash — not a Pi payment id, not a ledger sequence number, not a memo. " +
66
+ "Example: f8b6d6c83dfb32452330b677d901748fb6cece6c36d9b2deff64bead6e1c6925");
35
67
  export const pagingLimit = z
36
68
  .number()
37
69
  .int()
@@ -72,7 +72,9 @@ 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. An address never funded on this chain returns a " +
76
+ "not-found error rather than an empty list, so an empty `payments` array means " +
77
+ "you have paged past the end of the history — not that the account is unused. " +
76
78
  networkNote(network),
77
79
  inputSchema: {
78
80
  address: walletAddress,
@@ -22,6 +22,9 @@ export function registerGetWalletBalance(server, network) {
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
24
  "Reads public ledger data only — it cannot move funds and needs no credentials. " +
25
+ "An address that has never been funded on this chain is not an account there: the " +
26
+ "call returns a not-found error rather than a zero balance, so 'absent' and " +
27
+ "'holds nothing' stay distinguishable. " +
25
28
  networkNote(network),
26
29
  inputSchema: { address: walletAddress },
27
30
  outputSchema,
@@ -23,7 +23,9 @@ 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. A hash this chain has no record of returns a " +
27
+ "not-found error, which is not the same answer as `successful: false` — that " +
28
+ "means the transaction did reach a ledger and was rejected there. " +
27
29
  networkNote(network),
28
30
  inputSchema: { hash: transactionHash },
29
31
  outputSchema,
@@ -16,16 +16,23 @@ export function registerVerifyUser(server) {
16
16
  description: "Check whether a Pi user access token is genuine and, if so, who it belongs to. " +
17
17
  "Call this to authenticate someone who claims a Pi identity — never trust a " +
18
18
  "client-supplied uid or username on its own; this is the only thing that proves it. " +
19
- "Returns `valid: false` with a reason for a rejected token rather than failing. " +
19
+ "An invalid, expired, or wrong-app token returns `valid: false` with a reason " +
20
+ "rather than failing; only a transport or server fault is reported as an error. " +
20
21
  `Sends the token to the Pi Platform API (${PLATFORM_URL}/v2/me) and nothing else; ` +
21
22
  "it is not stored or logged. Note the uid is app-specific — the same person has a " +
22
- "different uid under a different Pi app.",
23
+ "different uid under a different Pi app. Unlike this server's ledger reads, this " +
24
+ "is an identity check against the Platform API rather than a chain query, so the " +
25
+ "result carries no `network` field and does not depend on which Pi chain is " +
26
+ "being read.",
23
27
  inputSchema: {
24
28
  access_token: z
25
29
  .string()
26
30
  .min(1)
27
- .describe("The user's Pi access token, obtained from Pi Browser authentication or Pi " +
28
- "Sign-in OAuth. This is a credential pass the token itself, not a uid."),
31
+ .describe("The user's Pi access token: an opaque bearer credential with no fixed " +
32
+ "length or prefix, obtained from Pi Browser authentication " +
33
+ "(`Pi.authenticate`) or Pi Sign-in OAuth. Pass the token string itself — " +
34
+ "not a uid, not a username, and not the app's server API key, which is a " +
35
+ "different credential this tool never accepts."),
29
36
  },
30
37
  outputSchema,
31
38
  annotations: { readOnlyHint: true, openWorldHint: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pion-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
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
5
  "keywords": [
6
6
  "mcp",