@molpha/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/dist/cli/doctor.js +27 -0
- package/dist/cli/provision.js +84 -0
- package/dist/src/apiconfig.js +47 -0
- package/dist/src/artifacts.js +80 -0
- package/dist/src/clients.js +51 -0
- package/dist/src/config.js +102 -0
- package/dist/src/determinism.js +31 -0
- package/dist/src/env.js +14 -0
- package/dist/src/errors.js +104 -0
- package/dist/src/feed.js +53 -0
- package/dist/src/guardrails.js +62 -0
- package/dist/src/hex.js +45 -0
- package/dist/src/mcp.js +82 -0
- package/dist/src/sdk.js +12 -0
- package/dist/src/server.js +17 -0
- package/dist/src/setup-validation.js +340 -0
- package/dist/src/signer/backends/memory.js +41 -0
- package/dist/src/signer/backends/privy.js +48 -0
- package/dist/src/signer/backends/turnkey.js +53 -0
- package/dist/src/signer/factory.js +37 -0
- package/dist/src/signer/types.js +1 -0
- package/dist/src/solana-address.js +29 -0
- package/dist/src/solana-compat.js +22 -0
- package/dist/src/submit.js +46 -0
- package/dist/src/subscription.js +48 -0
- package/dist/src/tools/agent_status.js +26 -0
- package/dist/src/tools/derive_feed.js +39 -0
- package/dist/src/tools/describe_feed.js +44 -0
- package/dist/src/tools/execute.js +60 -0
- package/dist/src/tools/fetch_verified.js +116 -0
- package/dist/src/tools/get_capabilities.js +45 -0
- package/dist/src/tools/get_latest.js +22 -0
- package/dist/src/tools/index.js +19 -0
- package/dist/src/tools/schemas.js +9 -0
- package/dist/src/tools/types.js +1 -0
- package/dist/src/tools/verify.js +28 -0
- package/dist/src/verifiers.js +95 -0
- package/dist/src/x402.js +552 -0
- package/package.json +81 -0
package/dist/src/x402.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 self-funded pay-per-request client for `POST /v1/agent/execute` and
|
|
3
|
+
* `GET /v1/agent/{payer}/status`. The SDK has no x402 support (it only
|
|
4
|
+
* covers the subscription round path), so this talks to the gateway
|
|
5
|
+
* directly, mirroring the gateway's own Go implementation
|
|
6
|
+
* (gateway/internal/gateway/features/agentexec).
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { address, getAddressEncoder, getProgramDerivedAddress } from "@solana/kit";
|
|
10
|
+
import { findAssociatedTokenPda, getCreateAssociatedTokenIdempotentInstruction, getTransferInstruction, TOKEN_PROGRAM_ADDRESS } from "@solana-program/token";
|
|
11
|
+
import { Transaction } from "@solana/web3.js";
|
|
12
|
+
import { canonicalizeApiConfig, deriveApiConfigHash, deriveFeedIdString } from "./apiconfig.js";
|
|
13
|
+
import { getMolphaProgramId, requireMethod } from "./clients.js";
|
|
14
|
+
import {} from "./config.js";
|
|
15
|
+
import { checkX402DailySpendCap, checkX402PerRoundCap, checkX402SpendCap, recordX402Spend } from "./guardrails.js";
|
|
16
|
+
import { normalizeFeedId } from "./hex.js";
|
|
17
|
+
import { requireSdkExport } from "./sdk.js";
|
|
18
|
+
import { toLegacyInstruction, toLegacyPublicKey } from "./solana-compat.js";
|
|
19
|
+
const AGENT_REQAUTH_PREFIX = "MOLPHA_AGENT_REQAUTH_V1";
|
|
20
|
+
const MAX_ROUND_ATTEMPTS = 5;
|
|
21
|
+
export class X402PaymentRequiredError extends Error {
|
|
22
|
+
status = 402;
|
|
23
|
+
x402;
|
|
24
|
+
constructor(message, x402) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "X402PaymentRequiredError";
|
|
27
|
+
this.x402 = x402;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** In-memory cache of the settling gateway PDA per endpoint — revealed via status or a 402 body. */
|
|
31
|
+
const gatewayPdaCache = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* Escrow PDA: `["molpha_agent", payer, gateway]`.
|
|
34
|
+
* Escrows are per (payer, gateway); the gateway seed is the settling gateway PDA.
|
|
35
|
+
*/
|
|
36
|
+
export async function deriveAgentEscrow(payer, gateway, programId) {
|
|
37
|
+
const encoder = getAddressEncoder();
|
|
38
|
+
const [pda] = await getProgramDerivedAddress({
|
|
39
|
+
programAddress: programId,
|
|
40
|
+
seeds: [Buffer.from("molpha_agent"), Buffer.from(encoder.encode(payer)), Buffer.from(encoder.encode(gateway))]
|
|
41
|
+
});
|
|
42
|
+
return pda;
|
|
43
|
+
}
|
|
44
|
+
/** Escrow USDC ATA (associated token account owned by the agent escrow PDA). */
|
|
45
|
+
export async function deriveAgentEscrowAta(escrow, usdcMint) {
|
|
46
|
+
const [ata] = await findAssociatedTokenPda({
|
|
47
|
+
owner: escrow,
|
|
48
|
+
mint: usdcMint,
|
|
49
|
+
tokenProgram: TOKEN_PROGRAM_ADDRESS
|
|
50
|
+
});
|
|
51
|
+
return ata;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Treat a 402 `accepts[0]` as untrusted: derive escrow + USDC ATA, pin the mint
|
|
55
|
+
* to on-chain protocol config, and cap the transfer amount before signing.
|
|
56
|
+
*/
|
|
57
|
+
export async function verifyX402FundingAccept(accept, ctx) {
|
|
58
|
+
if (accept.extra.payer !== String(ctx.payer)) {
|
|
59
|
+
throw new Error(`x402 402 response payer mismatch: expected ${ctx.payer}, got ${accept.extra.payer}`);
|
|
60
|
+
}
|
|
61
|
+
const gateway = address(accept.extra.gateway);
|
|
62
|
+
if (ctx.knownGateway !== undefined && String(ctx.knownGateway) !== String(gateway)) {
|
|
63
|
+
throw new Error(`x402 402 response gateway mismatch: expected ${ctx.knownGateway}, got ${gateway}`);
|
|
64
|
+
}
|
|
65
|
+
const escrow = await deriveAgentEscrow(ctx.payer, gateway, ctx.programId);
|
|
66
|
+
if (accept.extra.agent !== String(escrow)) {
|
|
67
|
+
throw new Error(`x402 402 response agent (escrow) mismatch: expected derived ${escrow}, got ${accept.extra.agent}`);
|
|
68
|
+
}
|
|
69
|
+
if (accept.asset !== String(ctx.usdcMint)) {
|
|
70
|
+
throw new Error(`x402 402 response asset mismatch: expected protocol USDC mint ${ctx.usdcMint}, got ${accept.asset}`);
|
|
71
|
+
}
|
|
72
|
+
const escrowAta = await deriveAgentEscrowAta(escrow, ctx.usdcMint);
|
|
73
|
+
if (accept.payTo !== String(escrowAta)) {
|
|
74
|
+
throw new Error(`x402 402 response payTo mismatch: expected derived escrow ATA ${escrowAta}, got ${accept.payTo}`);
|
|
75
|
+
}
|
|
76
|
+
const roundPrice = BigInt(accept.extra.amount);
|
|
77
|
+
const fundAmount = BigInt(accept.maxAmountRequired);
|
|
78
|
+
checkX402PerRoundCap(roundPrice, ctx.maxPriceUsdcAtomic);
|
|
79
|
+
if (fundAmount > 0n) {
|
|
80
|
+
checkX402DailySpendCap(fundAmount, ctx.maxSpendPerDayUsdcAtomic);
|
|
81
|
+
}
|
|
82
|
+
if (fundAmount > roundPrice) {
|
|
83
|
+
throw new Error(`x402 402 response maxAmountRequired (${fundAmount}) exceeds round price extra.amount (${roundPrice})`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
gateway,
|
|
87
|
+
escrow,
|
|
88
|
+
escrowAta,
|
|
89
|
+
usdcMint: ctx.usdcMint,
|
|
90
|
+
fundAmount,
|
|
91
|
+
roundPrice
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Advisory status for one payer at this gateway. Escrow is derived server-side. */
|
|
95
|
+
export async function fetchAgentStatus(config, payer, signaturesRequired) {
|
|
96
|
+
const path = `/v1/agent/${payer}/status?signatures_required=${signaturesRequired}`;
|
|
97
|
+
return getFirstReachable(config.gatewayEndpoints, path);
|
|
98
|
+
}
|
|
99
|
+
export async function agentFetch(ctx, opts) {
|
|
100
|
+
const { config, connection, signer, solana } = ctx;
|
|
101
|
+
const payer = signer.publicKey;
|
|
102
|
+
const apiConfig = canonicalizeApiConfig(opts.apiConfig);
|
|
103
|
+
const apiConfigHash = deriveApiConfigHash(apiConfig);
|
|
104
|
+
const expectedFeedId = deriveFeedIdString(payer, apiConfigHash, opts.signaturesRequired);
|
|
105
|
+
assertFeedIdMatch(expectedFeedId, opts.feedId);
|
|
106
|
+
const endpointKey = config.gatewayEndpoints[0] ?? "";
|
|
107
|
+
const programId = getMolphaProgramId();
|
|
108
|
+
const { usdcMint: usdcMintRaw } = await requireMethod(solana, "fetchProtocolTokens")();
|
|
109
|
+
const usdcMint = address(String(usdcMintRaw));
|
|
110
|
+
const registryVersion = await requireMethod(solana, "getRegistryVersion")();
|
|
111
|
+
// Status is advisory, so a failure must not abort the round — but it is the
|
|
112
|
+
// only source of the settling gateway PDA when nothing is pinned or cached,
|
|
113
|
+
// so keep the reason around for the error message that needs it.
|
|
114
|
+
let statusError;
|
|
115
|
+
const status = await fetchAgentStatus(config, payer, opts.signaturesRequired).catch((error) => {
|
|
116
|
+
statusError = error instanceof Error ? error.message : String(error);
|
|
117
|
+
return undefined;
|
|
118
|
+
});
|
|
119
|
+
let gatewayPda = config.x402.gatewayPda ?? gatewayPdaCache.get(endpointKey) ?? status?.gateway ?? undefined;
|
|
120
|
+
if (gatewayPda)
|
|
121
|
+
cacheGatewayPda(endpointKey, gatewayPda);
|
|
122
|
+
let priceAtomic = status ? BigInt(status.quotedNextPrice) : undefined;
|
|
123
|
+
let availableAtomic = status
|
|
124
|
+
? bigIntMax(0n, BigInt(status.ataBalance) - BigInt(status.committedAmount))
|
|
125
|
+
: 0n;
|
|
126
|
+
let escrowStr = gatewayPda !== undefined
|
|
127
|
+
? String(await deriveAgentEscrow(payer, address(gatewayPda), programId))
|
|
128
|
+
: "";
|
|
129
|
+
const fundingVerifyCtx = (knownGateway) => ({
|
|
130
|
+
payer,
|
|
131
|
+
programId,
|
|
132
|
+
usdcMint,
|
|
133
|
+
knownGateway,
|
|
134
|
+
maxPriceUsdcAtomic: config.x402.maxPriceUsdcAtomic,
|
|
135
|
+
maxSpendPerDayUsdcAtomic: config.x402.maxSpendPerDayUsdcAtomic
|
|
136
|
+
});
|
|
137
|
+
if (opts.dryRun) {
|
|
138
|
+
if (priceAtomic === undefined || gatewayPda === undefined) {
|
|
139
|
+
const discovery = await discover(config, {
|
|
140
|
+
payer,
|
|
141
|
+
apiConfig,
|
|
142
|
+
signaturesRequired: opts.signaturesRequired,
|
|
143
|
+
registryVersion,
|
|
144
|
+
timestamp: Math.floor(Date.now() / 1000)
|
|
145
|
+
});
|
|
146
|
+
if (discovery.kind === "already_funded") {
|
|
147
|
+
// The escrow covers the round; the 400 quote carries no addresses, so
|
|
148
|
+
// report whatever the pinned/cached gateway PDA can still derive.
|
|
149
|
+
const escrow = gatewayPda
|
|
150
|
+
? String(await deriveAgentEscrow(payer, address(gatewayPda), programId))
|
|
151
|
+
: "unknown";
|
|
152
|
+
const ata = escrow !== "unknown"
|
|
153
|
+
? String(await deriveAgentEscrowAta(address(escrow), usdcMint))
|
|
154
|
+
: "unknown";
|
|
155
|
+
checkX402PerRoundCap(discovery.roundPrice, config.x402.maxPriceUsdcAtomic);
|
|
156
|
+
return dryRunPreview(expectedFeedId, escrow, ata, discovery.roundPrice, discovery.roundPrice);
|
|
157
|
+
}
|
|
158
|
+
const verified = await verifyX402FundingAccept(discovery.accept, fundingVerifyCtx(gatewayPda));
|
|
159
|
+
assertFeedIdMatch(expectedFeedId, discovery.accept.extra.feedId);
|
|
160
|
+
cacheGatewayPda(endpointKey, String(verified.gateway));
|
|
161
|
+
availableAtomic = bigIntMax(0n, BigInt(discovery.accept.extra.currentAtaBalance) -
|
|
162
|
+
BigInt(discovery.accept.extra.committedAmount));
|
|
163
|
+
return dryRunPreview(expectedFeedId, String(verified.escrow), String(verified.escrowAta), verified.roundPrice, availableAtomic);
|
|
164
|
+
}
|
|
165
|
+
const escrowAta = escrowStr !== ""
|
|
166
|
+
? String(await deriveAgentEscrowAta(address(escrowStr), usdcMint))
|
|
167
|
+
: (status?.ataAddress ?? "unknown");
|
|
168
|
+
return dryRunPreview(expectedFeedId, escrowStr || "unknown", escrowAta, priceAtomic, availableAtomic);
|
|
169
|
+
}
|
|
170
|
+
let timestamp = Math.floor(Date.now() / 1000);
|
|
171
|
+
let feedIdHex = expectedFeedId;
|
|
172
|
+
let lastPaymentRequired;
|
|
173
|
+
let walletSpendRecordedForRound = false;
|
|
174
|
+
for (let attempt = 0; attempt < MAX_ROUND_ATTEMPTS; attempt++) {
|
|
175
|
+
const needsQuoteOrFunding = priceAtomic === undefined || availableAtomic < priceAtomic;
|
|
176
|
+
const needsGateway = gatewayPda === undefined;
|
|
177
|
+
if (needsQuoteOrFunding || needsGateway) {
|
|
178
|
+
// Unsigned discovery (amount: 0) only works while underfunded. If the
|
|
179
|
+
// escrow already covers the quoted price but we lack the gateway PDA,
|
|
180
|
+
// refresh status instead — a funded amount:0 request returns 400.
|
|
181
|
+
if (!needsQuoteOrFunding && needsGateway) {
|
|
182
|
+
const refreshed = await fetchAgentStatus(config, payer, opts.signaturesRequired).catch(() => undefined);
|
|
183
|
+
if (!refreshed?.gateway) {
|
|
184
|
+
throw new Error(gatewayPdaUnknownMessage(statusError));
|
|
185
|
+
}
|
|
186
|
+
gatewayPda = refreshed.gateway;
|
|
187
|
+
cacheGatewayPda(endpointKey, gatewayPda);
|
|
188
|
+
escrowStr = String(await deriveAgentEscrow(payer, address(gatewayPda), programId));
|
|
189
|
+
priceAtomic = BigInt(refreshed.quotedNextPrice);
|
|
190
|
+
availableAtomic = bigIntMax(0n, BigInt(refreshed.ataBalance) - BigInt(refreshed.committedAmount));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
timestamp = Math.floor(Date.now() / 1000);
|
|
194
|
+
const discovery = await discover(config, {
|
|
195
|
+
payer,
|
|
196
|
+
apiConfig,
|
|
197
|
+
signaturesRequired: opts.signaturesRequired,
|
|
198
|
+
registryVersion,
|
|
199
|
+
timestamp
|
|
200
|
+
});
|
|
201
|
+
if (discovery.kind === "already_funded") {
|
|
202
|
+
// Status was unavailable so we assumed an empty escrow, but the
|
|
203
|
+
// gateway says it is funded. Nothing to fund — take the quote and
|
|
204
|
+
// sign, provided the gateway PDA is known from a pin or the cache.
|
|
205
|
+
if (!gatewayPda) {
|
|
206
|
+
throw new Error(gatewayPdaUnknownMessage(statusError));
|
|
207
|
+
}
|
|
208
|
+
priceAtomic = discovery.roundPrice;
|
|
209
|
+
availableAtomic = discovery.roundPrice;
|
|
210
|
+
escrowStr = String(await deriveAgentEscrow(payer, address(gatewayPda), programId));
|
|
211
|
+
feedIdHex = expectedFeedId;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
const verified = await verifyX402FundingAccept(discovery.accept, fundingVerifyCtx(gatewayPda));
|
|
215
|
+
assertFeedIdMatch(expectedFeedId, discovery.accept.extra.feedId);
|
|
216
|
+
gatewayPda = String(verified.gateway);
|
|
217
|
+
cacheGatewayPda(endpointKey, gatewayPda);
|
|
218
|
+
priceAtomic = verified.roundPrice;
|
|
219
|
+
if (verified.fundAmount > 0n) {
|
|
220
|
+
await fundEscrow(connection, signer, {
|
|
221
|
+
mint: verified.usdcMint,
|
|
222
|
+
escrow: verified.escrow,
|
|
223
|
+
escrowAta: verified.escrowAta,
|
|
224
|
+
amountAtomic: verified.fundAmount
|
|
225
|
+
});
|
|
226
|
+
recordX402Spend(verified.fundAmount);
|
|
227
|
+
walletSpendRecordedForRound = true;
|
|
228
|
+
}
|
|
229
|
+
timestamp = discovery.accept.extra.canonicalTimestamp;
|
|
230
|
+
feedIdHex = expectedFeedId;
|
|
231
|
+
escrowStr = String(verified.escrow);
|
|
232
|
+
availableAtomic = priceAtomic;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!gatewayPda || !escrowStr || priceAtomic === undefined) {
|
|
237
|
+
throw new Error("x402 agent execute missing gateway PDA, escrow, or round price after discovery");
|
|
238
|
+
}
|
|
239
|
+
if (walletSpendRecordedForRound) {
|
|
240
|
+
checkX402PerRoundCap(priceAtomic, config.x402.maxPriceUsdcAtomic);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
checkX402SpendCap(priceAtomic, config.x402.maxPriceUsdcAtomic, config.x402.maxSpendPerDayUsdcAtomic);
|
|
244
|
+
}
|
|
245
|
+
const sig = await signAgentRequestAuth(signer, {
|
|
246
|
+
agent: address(escrowStr),
|
|
247
|
+
gateway: address(gatewayPda),
|
|
248
|
+
feedId: hexToBytesLocal(feedIdHex),
|
|
249
|
+
canonicalTimestamp: timestamp,
|
|
250
|
+
amount: priceAtomic
|
|
251
|
+
});
|
|
252
|
+
const outcome = await postAgentExecute(config.gatewayEndpoints, {
|
|
253
|
+
payer,
|
|
254
|
+
canonical_timestamp: timestamp,
|
|
255
|
+
signatures_required: opts.signaturesRequired,
|
|
256
|
+
amount: Number(priceAtomic),
|
|
257
|
+
registry_version: registryVersion,
|
|
258
|
+
agent_request_auth_sig: bytesToHex0xLocal(sig),
|
|
259
|
+
apiConfig
|
|
260
|
+
});
|
|
261
|
+
if (outcome.kind === "ok") {
|
|
262
|
+
return mapAgentResponse(outcome.body);
|
|
263
|
+
}
|
|
264
|
+
if (outcome.kind === "payment_required") {
|
|
265
|
+
lastPaymentRequired = outcome.body;
|
|
266
|
+
const accept = outcome.body.accepts[0];
|
|
267
|
+
if (accept?.extra.gateway) {
|
|
268
|
+
if (gatewayPda !== undefined && accept.extra.gateway !== gatewayPda) {
|
|
269
|
+
throw new Error(`x402 402 response gateway mismatch: expected ${gatewayPda}, got ${accept.extra.gateway}`);
|
|
270
|
+
}
|
|
271
|
+
gatewayPda = accept.extra.gateway;
|
|
272
|
+
cacheGatewayPda(endpointKey, gatewayPda);
|
|
273
|
+
escrowStr = String(await deriveAgentEscrow(payer, address(gatewayPda), programId));
|
|
274
|
+
}
|
|
275
|
+
priceAtomic = undefined;
|
|
276
|
+
availableAtomic = 0n;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (outcome.kind === "amount_mismatch") {
|
|
280
|
+
// Price moved between the quote and the signed request. The rejection
|
|
281
|
+
// carries the current price, so re-sign against it directly; only fall
|
|
282
|
+
// back to status when the gateway did not name one.
|
|
283
|
+
if (outcome.quotedPrice !== undefined) {
|
|
284
|
+
priceAtomic = outcome.quotedPrice;
|
|
285
|
+
availableAtomic = outcome.quotedPrice;
|
|
286
|
+
timestamp = Math.floor(Date.now() / 1000);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const refreshed = await fetchAgentStatus(config, payer, opts.signaturesRequired).catch(() => undefined);
|
|
290
|
+
if (refreshed) {
|
|
291
|
+
priceAtomic = BigInt(refreshed.quotedNextPrice);
|
|
292
|
+
availableAtomic = bigIntMax(0n, BigInt(refreshed.ataBalance) - BigInt(refreshed.committedAmount));
|
|
293
|
+
if (refreshed.gateway) {
|
|
294
|
+
gatewayPda = refreshed.gateway;
|
|
295
|
+
cacheGatewayPda(endpointKey, gatewayPda);
|
|
296
|
+
}
|
|
297
|
+
if (gatewayPda) {
|
|
298
|
+
escrowStr = String(await deriveAgentEscrow(payer, address(gatewayPda), programId));
|
|
299
|
+
}
|
|
300
|
+
timestamp = Math.floor(Date.now() / 1000);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
priceAtomic = undefined;
|
|
304
|
+
availableAtomic = 0n;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (outcome.kind === "bad_request") {
|
|
308
|
+
// Stale registry version, clock skew, malformed apiConfig — none of
|
|
309
|
+
// these are fixed by retrying the same request.
|
|
310
|
+
throw new Error(`x402 agent execute rejected: ${outcome.message}`);
|
|
311
|
+
}
|
|
312
|
+
if (outcome.kind === "conflict") {
|
|
313
|
+
// canonical_timestamp already reserved — bump and re-sign.
|
|
314
|
+
timestamp += 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (outcome.kind === "unavailable") {
|
|
318
|
+
timestamp = Math.floor(Date.now() / 1000);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
throw new Error(`x402 agent execute failed: ${outcome.message}`);
|
|
322
|
+
}
|
|
323
|
+
if (lastPaymentRequired) {
|
|
324
|
+
throw new X402PaymentRequiredError(lastPaymentRequired.error || "payment required: escrow ATA underfunded after retries", lastPaymentRequired);
|
|
325
|
+
}
|
|
326
|
+
throw new Error("x402 agent round failed after retries (payment/timestamp conflicts did not resolve)");
|
|
327
|
+
}
|
|
328
|
+
function dryRunPreview(feedId, escrow, ata, priceAtomic, availableAtomic) {
|
|
329
|
+
const shortfall = bigIntMax(0n, priceAtomic - availableAtomic);
|
|
330
|
+
return {
|
|
331
|
+
dryRun: true,
|
|
332
|
+
action: "x402_agent_execute",
|
|
333
|
+
feedId,
|
|
334
|
+
escrow,
|
|
335
|
+
ata,
|
|
336
|
+
priceAtomicUsdc: priceAtomic.toString(),
|
|
337
|
+
availableAtomicUsdc: availableAtomic.toString(),
|
|
338
|
+
shortfallAtomicUsdc: shortfall.toString(),
|
|
339
|
+
note: shortfall > 0n
|
|
340
|
+
? "Escrow is underfunded for this round; a live call would fund the ATA before requesting data."
|
|
341
|
+
: "Escrow already covers this round's price; a live call would request data immediately."
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function gatewayPdaUnknownMessage(statusError) {
|
|
345
|
+
return [
|
|
346
|
+
"x402 escrow appears funded but the settling gateway PDA is unknown; set MOLPHA_X402_GATEWAY_PDA or ensure GET /v1/agent/{payer}/status returns gateway.",
|
|
347
|
+
statusError ? ` (status lookup failed: ${statusError})` : ""
|
|
348
|
+
].join("");
|
|
349
|
+
}
|
|
350
|
+
function assertFeedIdMatch(expected, provided) {
|
|
351
|
+
if (provided === undefined)
|
|
352
|
+
return;
|
|
353
|
+
if (normalizeFeedId(provided) !== normalizeFeedId(expected)) {
|
|
354
|
+
throw new Error(`feedId does not match apiConfig + signaturesRequired for this signer: expected ${expected}, got ${provided}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function hexToBytesLocal(hex) {
|
|
358
|
+
return requireSdkExport("hexToBytes")(hex);
|
|
359
|
+
}
|
|
360
|
+
function bytesToHex0xLocal(bytes) {
|
|
361
|
+
return requireSdkExport("bytesToHex0x")(bytes);
|
|
362
|
+
}
|
|
363
|
+
function u64le(value) {
|
|
364
|
+
return requireSdkExport("u64le")(value);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* `sha256("MOLPHA_AGENT_REQAUTH_V1" || agent(32) || gateway(32) || feedId(32) || canonicalTimestamp_le(8) || amount_le(8))`.
|
|
368
|
+
* Must stay byte-identical with the gateway's `agentauth.AgentRequestAuth.Hash()`
|
|
369
|
+
* (tmp/gateway/internal/gateway/features/agentexec/agentauth/agentauth.go) and
|
|
370
|
+
* the Solana program's `state/receipt.rs::hash_agent_request_auth`.
|
|
371
|
+
*/
|
|
372
|
+
export function agentRequestAuthMessage(params) {
|
|
373
|
+
const addressEncoder = getAddressEncoder();
|
|
374
|
+
const borsh = Buffer.concat([
|
|
375
|
+
Buffer.from(addressEncoder.encode(params.agent)),
|
|
376
|
+
Buffer.from(addressEncoder.encode(params.gateway)),
|
|
377
|
+
Buffer.from(params.feedId),
|
|
378
|
+
Buffer.from(u64le(params.canonicalTimestamp)),
|
|
379
|
+
Buffer.from(u64le(params.amount))
|
|
380
|
+
]);
|
|
381
|
+
return new Uint8Array(createHash("sha256").update(Buffer.concat([Buffer.from(AGENT_REQAUTH_PREFIX, "utf8"), borsh])).digest());
|
|
382
|
+
}
|
|
383
|
+
async function signAgentRequestAuth(signer, params) {
|
|
384
|
+
return signer.signMessage(agentRequestAuthMessage(params));
|
|
385
|
+
}
|
|
386
|
+
function addressSigner(addr) {
|
|
387
|
+
// Only `.address` is read when Codama builds the instruction meta — actual
|
|
388
|
+
// signing goes through MolphaSigner.signTransaction, never through Kit.
|
|
389
|
+
return { address: addr };
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Build + send the escrow ATA create/transfer. Callers must pass addresses and
|
|
393
|
+
* amounts already verified by {@link verifyX402FundingAccept} — never raw 402 fields.
|
|
394
|
+
*/
|
|
395
|
+
async function fundEscrow(connection, signer, args) {
|
|
396
|
+
const [payerAta] = await findAssociatedTokenPda({
|
|
397
|
+
owner: signer.publicKey,
|
|
398
|
+
mint: args.mint,
|
|
399
|
+
tokenProgram: TOKEN_PROGRAM_ADDRESS
|
|
400
|
+
});
|
|
401
|
+
const createAtaIx = getCreateAssociatedTokenIdempotentInstruction({
|
|
402
|
+
payer: addressSigner(signer.publicKey),
|
|
403
|
+
ata: args.escrowAta,
|
|
404
|
+
owner: args.escrow,
|
|
405
|
+
mint: args.mint
|
|
406
|
+
});
|
|
407
|
+
const transferIx = getTransferInstruction({
|
|
408
|
+
source: payerAta,
|
|
409
|
+
destination: args.escrowAta,
|
|
410
|
+
authority: addressSigner(signer.publicKey),
|
|
411
|
+
amount: args.amountAtomic
|
|
412
|
+
});
|
|
413
|
+
const tx = new Transaction().add(toLegacyInstruction(createAtaIx), toLegacyInstruction(transferIx));
|
|
414
|
+
tx.feePayer = toLegacyPublicKey(signer.publicKey);
|
|
415
|
+
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
|
|
416
|
+
tx.recentBlockhash = blockhash;
|
|
417
|
+
const signed = await signer.signTransaction(tx);
|
|
418
|
+
const signature = await connection.sendRawTransaction(signed.serialize());
|
|
419
|
+
await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed");
|
|
420
|
+
return signature;
|
|
421
|
+
}
|
|
422
|
+
async function discover(config, args) {
|
|
423
|
+
const outcome = await postAgentExecute(config.gatewayEndpoints, {
|
|
424
|
+
payer: args.payer,
|
|
425
|
+
canonical_timestamp: args.timestamp,
|
|
426
|
+
signatures_required: args.signaturesRequired,
|
|
427
|
+
amount: 0,
|
|
428
|
+
registry_version: args.registryVersion,
|
|
429
|
+
apiConfig: args.apiConfig
|
|
430
|
+
});
|
|
431
|
+
if (outcome.kind === "amount_mismatch" && outcome.quotedPrice !== undefined) {
|
|
432
|
+
return { kind: "already_funded", roundPrice: outcome.quotedPrice };
|
|
433
|
+
}
|
|
434
|
+
if (outcome.kind !== "payment_required") {
|
|
435
|
+
throw new Error(`unsigned discovery request failed (expected a 402 quote), got ${outcome.kind}: ${"message" in outcome ? outcome.message : ""}`);
|
|
436
|
+
}
|
|
437
|
+
const accept = outcome.body.accepts[0];
|
|
438
|
+
if (!accept) {
|
|
439
|
+
throw new Error("gateway 402 response is missing accepts[0]");
|
|
440
|
+
}
|
|
441
|
+
return { kind: "quote", accept };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* The gateway returns 400 for several unrelated validation failures
|
|
445
|
+
* (`amount`, `registry_version`, `canonical_timestamp`, …). Only the amount
|
|
446
|
+
* lock is retryable by re-quoting; treating every 400 as an amount mismatch
|
|
447
|
+
* hid stale-registry and clock-skew errors behind five pointless retries.
|
|
448
|
+
*/
|
|
449
|
+
function classifyBadRequest(message) {
|
|
450
|
+
if (!/^amount:/.test(message)) {
|
|
451
|
+
return { kind: "bad_request", message };
|
|
452
|
+
}
|
|
453
|
+
const quoted = /round price (\d+)/.exec(message);
|
|
454
|
+
return quoted
|
|
455
|
+
? { kind: "amount_mismatch", message, quotedPrice: BigInt(quoted[1]) }
|
|
456
|
+
: { kind: "amount_mismatch", message };
|
|
457
|
+
}
|
|
458
|
+
async function postAgentExecute(endpoints, body) {
|
|
459
|
+
let lastError = { kind: "error", message: "no reachable gateway endpoint" };
|
|
460
|
+
for (const endpoint of endpoints) {
|
|
461
|
+
let res;
|
|
462
|
+
try {
|
|
463
|
+
res = await fetch(`${endpoint.replace(/\/$/, "")}/v1/agent/execute`, {
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers: { "content-type": "application/json" },
|
|
466
|
+
body: JSON.stringify(body)
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
lastError = { kind: "unavailable", message: error instanceof Error ? error.message : String(error) };
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (res.status === 200) {
|
|
474
|
+
return { kind: "ok", body: (await res.json()) };
|
|
475
|
+
}
|
|
476
|
+
if (res.status === 402) {
|
|
477
|
+
return { kind: "payment_required", body: (await res.json()) };
|
|
478
|
+
}
|
|
479
|
+
if (res.status === 400) {
|
|
480
|
+
return classifyBadRequest(await readErrorMessage(res));
|
|
481
|
+
}
|
|
482
|
+
if (res.status === 401) {
|
|
483
|
+
return { kind: "unauthorized", message: await readErrorMessage(res) };
|
|
484
|
+
}
|
|
485
|
+
if (res.status === 409) {
|
|
486
|
+
return { kind: "conflict", message: await readErrorMessage(res) };
|
|
487
|
+
}
|
|
488
|
+
if (res.status === 503) {
|
|
489
|
+
lastError = { kind: "unavailable", message: await readErrorMessage(res) };
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
return { kind: "error", message: await readErrorMessage(res) };
|
|
493
|
+
}
|
|
494
|
+
return lastError;
|
|
495
|
+
}
|
|
496
|
+
async function getFirstReachable(endpoints, path) {
|
|
497
|
+
let lastError;
|
|
498
|
+
for (const endpoint of endpoints) {
|
|
499
|
+
try {
|
|
500
|
+
const res = await fetch(`${endpoint.replace(/\/$/, "")}${path}`, { method: "GET" });
|
|
501
|
+
if (res.ok) {
|
|
502
|
+
return (await res.json());
|
|
503
|
+
}
|
|
504
|
+
lastError = new Error(`GET ${path} failed (${res.status})`);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
lastError = error;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
throw lastError instanceof Error ? lastError : new Error(`GET ${path} failed`);
|
|
511
|
+
}
|
|
512
|
+
async function readErrorMessage(res) {
|
|
513
|
+
try {
|
|
514
|
+
const text = (await res.text()).trim();
|
|
515
|
+
if (!text)
|
|
516
|
+
return `HTTP ${res.status}`;
|
|
517
|
+
try {
|
|
518
|
+
const parsed = JSON.parse(text);
|
|
519
|
+
const message = parsed.error ?? parsed.message ?? parsed.detail;
|
|
520
|
+
if (typeof message === "string" && message.trim())
|
|
521
|
+
return message.trim();
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// fall back to raw body
|
|
525
|
+
}
|
|
526
|
+
return text;
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return `HTTP ${res.status}`;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function mapAgentResponse(body) {
|
|
533
|
+
const data = (body.data ?? {});
|
|
534
|
+
return {
|
|
535
|
+
feedId: data.feedId,
|
|
536
|
+
value: data.value,
|
|
537
|
+
valuePacked: data.valuePacked,
|
|
538
|
+
timestamp: data.timestamp,
|
|
539
|
+
registryVersion: data.registryVersion,
|
|
540
|
+
signaturesRequired: data.signaturesRequired,
|
|
541
|
+
signersBitmap: data.signersBitmap,
|
|
542
|
+
s: data.s,
|
|
543
|
+
commitmentAddr: data.commitmentAddr,
|
|
544
|
+
fresh: data.fresh ?? true
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function cacheGatewayPda(endpointKey, gatewayPda) {
|
|
548
|
+
gatewayPdaCache.set(endpointKey, gatewayPda);
|
|
549
|
+
}
|
|
550
|
+
function bigIntMax(a, b) {
|
|
551
|
+
return a > b ? a : b;
|
|
552
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@molpha/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"mcpName": "io.github.molpha/mcp",
|
|
5
|
+
"description": "MCP server exposing Molpha oracle runtime tools over the Molpha SDK.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Molpha/mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Molpha/mcp/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://docs.molpha.io/",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"ai",
|
|
18
|
+
"agents",
|
|
19
|
+
"llm",
|
|
20
|
+
"cursor",
|
|
21
|
+
"claude",
|
|
22
|
+
"codex",
|
|
23
|
+
"x402",
|
|
24
|
+
"web3",
|
|
25
|
+
"model-context-protocol",
|
|
26
|
+
"oracle",
|
|
27
|
+
"solana"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"bin": {
|
|
31
|
+
"molpha-mcp": "dist/src/server.js",
|
|
32
|
+
"molpha-provision": "dist/cli/provision.js",
|
|
33
|
+
"molpha-doctor": "dist/cli/doctor.js"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist/src",
|
|
37
|
+
"dist/cli",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.json",
|
|
42
|
+
"dev": "node --import tsx src/server.ts",
|
|
43
|
+
"doctor": "node --import tsx cli/doctor.ts",
|
|
44
|
+
"provision": "node --import tsx cli/provision.ts",
|
|
45
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"sync-version": "node scripts/sync-version.mjs",
|
|
48
|
+
"version": "node scripts/sync-version.mjs && git add manifest.json server.json"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20.18.0"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@anchor-lang/core": "^1.1.2",
|
|
55
|
+
"@coral-xyz/anchor": "^0.30.1",
|
|
56
|
+
"@modelcontextprotocol/sdk": "^1.13.0",
|
|
57
|
+
"@molpha-oracle/sdk": "0.5.0",
|
|
58
|
+
"@privy-io/node": "^0.26.0",
|
|
59
|
+
"@solana-program/token": "^0.15.0",
|
|
60
|
+
"@solana/kit": "^7.0.0",
|
|
61
|
+
"@solana/spl-token": "0.4.15",
|
|
62
|
+
"@solana/web3.js": "^1.98.4",
|
|
63
|
+
"@turnkey/sdk-server": "7.0.0",
|
|
64
|
+
"@turnkey/solana": "^1.1.31",
|
|
65
|
+
"bn.js": "^5.2.2",
|
|
66
|
+
"dotenv": "^16.6.1",
|
|
67
|
+
"zod": "^3.25.0"
|
|
68
|
+
},
|
|
69
|
+
"overrides": {
|
|
70
|
+
"@privy-io/node": {
|
|
71
|
+
"@solana/kit": "$@solana/kit"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@types/bn.js": "^5.1.6",
|
|
76
|
+
"@types/node": "^22.15.29",
|
|
77
|
+
"tsx": "^4.19.4",
|
|
78
|
+
"typescript": "^5.8.3",
|
|
79
|
+
"vitest": "^3.2.1"
|
|
80
|
+
}
|
|
81
|
+
}
|