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 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
- > ⚠️ Testnet only. Read-only by default; `send_payment` moves real funds and
7
- > must be explicitly armed.
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 a non-testnet
64
- Horizon URL is refused outright:
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
- | `PION_HORIZON_URL` | `https://api.testnet.minepi.com` | Horizon base URL |
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. The mainnet Horizon URL is still an open
134
- question — see the TODO in [`docs/pi-sdk-notes.md`](https://github.com/jleeblack/pion-mcp/blob/main/docs/pi-sdk-notes.md).
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 # end-to-end: drives the built server against live testnet
142
- npm run arming # Tier C guards and spend cap (no credentials needed)
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, and that neither secret leaks into
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 a testnet Horizon URL. Holding the credentials
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
- /** Horizon base URL. Defaults to Pi testnet; override with PION_HORIZON_URL. */
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
- const DEFAULT_HORIZON_URL = "https://api.testnet.minepi.com";
7
+ import { NETWORK } from "./networks.js";
8
8
  const REQUEST_TIMEOUT_MS = 15_000;
9
- /** Horizon base URL. Defaults to Pi testnet; override with PION_HORIZON_URL. */
10
- export const HORIZON_URL = (process.env.PION_HORIZON_URL ?? DEFAULT_HORIZON_URL).replace(/\/+$/, "");
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
- return `Not found on Horizon (${HORIZON_URL}${path}). The account or transaction does not exist on this network, or has never been funded.`;
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
- " PION_HORIZON_URL Horizon base URL (default: https://api.testnet.minepi.com)",
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. Amounts are decimal strings; Pi itself is ` +
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
- const payments = checkPaymentsArming(HORIZON_URL);
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
- console.error(`pion-mcp ${VERSION} ready on stdio Horizon: ${HORIZON_URL} (${NETWORK})`);
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 (${payments.reason})`);
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;
@@ -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;
@@ -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(horizonUrl: string): PaymentsArming;
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(horizonUrl) {
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
- if (!horizonUrl.includes("testnet")) {
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: `Horizon is set to ${horizonUrl}, which is not testnet. Pi restricts ` +
123
- "App-to-User payments to testnet, and Pion will not attempt them elsewhere.",
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;
@@ -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. */
@@ -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
- export declare function registerGetAccountPayments(server: McpServer, network: string): void;
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
- export declare function registerGetWalletBalance(server: McpServer, network: string): void;
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
- export declare function registerQueryTransaction(server: McpServer, network: string): void;
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
- /** Pi testnet's Stellar network passphrase (docs/pi-sdk-notes.md, Layer 3). */
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
- const account = await horizon.loadAccount(keypair.publicKey());
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.3.0",
4
- "description": "Pion — Model Context Protocol (MCP) server for Pi Network. Read-only chain queries against Pi testnet Horizon.",
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
- "arming": "node scripts/arming-test.mjs",
38
- "u2a": "node scripts/u2a-test.mjs",
39
- "incomplete": "node scripts/incomplete.mjs",
40
- "probe:a2u": "node scripts/probe-a2u.mjs",
41
- "diagnose:a2u": "node scripts/diagnose-a2u.mjs",
42
- "identify:app": "node scripts/identify-app.mjs",
43
- "browser-auth": "node scripts/browser-auth/serve.mjs",
44
- "wallet": "node scripts/app-wallet.mjs",
45
- "signin": "node scripts/pi-signin.mjs",
46
- "prepack": "npm run clean && tsc --sourceMap false",
47
- "send": "node scripts/send.mjs"
48
- },
49
- "dependencies": {
50
- "@modelcontextprotocol/sdk": "^1.30.0",
51
- "@stellar/stellar-sdk": "^16.2.0",
52
- "zod": "^4.4.3"
53
- },
54
- "devDependencies": {
55
- "@types/node": "^26.1.2",
56
- "typescript": "^7.0.2"
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
+ }