@@ -36,7 +36,13 @@ import {
3636 ListToolsRequestSchema ,
3737 type CallToolRequest ,
3838} from "@modelcontextprotocol/sdk/types.js" ;
39+ import { AddressType , type NetworkId } from "@phantom/client" ;
40+ import { PhantomApiClient , PaymentRequiredError , RateLimitError } from "@phantom/phantom-api-client" ;
41+ import { base64urlEncode } from "@phantom/base64url" ;
42+ import { ANALYTICS_HEADERS } from "@phantom/constants" ;
3943import { SessionManager } from "./session/manager.js" ;
44+ import * as packageJson from "../package.json" ;
45+ import { normalizeNetworkId } from "./utils/network.js" ;
4046import { tools , getTool } from "./tools/index.js" ;
4147import { Logger } from "./utils/logger.js" ;
4248
@@ -60,7 +66,7 @@ export interface PhantomMCPServerOptions {
6066 session ?: {
6167 authBaseUrl ?: string ;
6268 connectBaseUrl ?: string ;
63- apiBaseUrl ?: string ;
69+ walletsApiBaseUrl ?: string ;
6470 callbackPort ?: number ;
6571 appId ?: string ;
6672 sessionDir ?: string ;
@@ -92,6 +98,7 @@ export class PhantomMCPServer {
9298 private readonly server : Server ;
9399 private readonly sessionManager : SessionManager ;
94100 private readonly logger : Logger ;
101+ private readonly apiClient : PhantomApiClient ;
95102 /**
96103 * Resolves when the startup initialization attempt finishes (success or failure).
97104 * Tool call handlers await this so they don't race with the OAuth browser flow.
@@ -107,6 +114,13 @@ export class PhantomMCPServer {
107114 constructor ( options : PhantomMCPServerOptions = { } ) {
108115 this . logger = new Logger ( "PhantomMCPServer" ) ;
109116
117+ // Initialize shared API client — points at api.phantom.app by default.
118+ // Override with PHANTOM_API_BASE_URL to point at a different proxy or local server.
119+ this . apiClient = new PhantomApiClient ( {
120+ baseUrl : process . env . PHANTOM_API_BASE_URL ?? "https://api.phantom.app" ,
121+ logger : this . logger . child ( "api-client" ) ,
122+ } ) ;
123+
110124 // Initialize MCP Server
111125 // The server name and instructions are surfaced to agents during the MCP handshake,
112126 // giving them immediate context about Phantom and available capabilities.
@@ -206,6 +220,12 @@ export class PhantomMCPServer {
206220 try {
207221 await this . sessionManager . resetSession ( ) ;
208222 const session = this . sessionManager . getSession ( ) ;
223+ try {
224+ await this . wirePaymentHandler ( ) ;
225+ } catch ( err ) {
226+ const msg = err instanceof Error ? err . message : String ( err ) ;
227+ this . logger . error ( `Failed to wire payment handler after login: ${ msg } ` ) ;
228+ }
209229 return {
210230 content : [
211231 {
@@ -242,6 +262,7 @@ export class PhantomMCPServer {
242262 client,
243263 session,
244264 logger : this . logger . child ( toolName ) ,
265+ apiClient : this . apiClient ,
245266 } ;
246267
247268 // Step 4: Execute tool handler
@@ -283,6 +304,59 @@ export class PhantomMCPServer {
283304 } ;
284305 }
285306
307+ // Payment required: auto-pay failed (insufficient CASH, signing error, etc.)
308+ // Return a structured response so the agent knows exactly what happened and what to do.
309+ if ( error instanceof PaymentRequiredError ) {
310+ this . logger . warn ( `Payment required on tool ${ toolName } : auto-pay failed — ${ error . message } ` ) ;
311+ return {
312+ content : [
313+ {
314+ type : "text" as const ,
315+ text : JSON . stringify (
316+ {
317+ error : "API_PAYMENT_REQUIRED" ,
318+ message :
319+ `Daily API limit reached. Call pay_api_access with the preparedTx below to pay ` +
320+ `${ error . payment . amount } ${ error . payment . token } and unlock access, then retry ${ toolName } .` ,
321+ preparedTx : error . payment . preparedTx ,
322+ payment : {
323+ amount : error . payment . amount ,
324+ token : error . payment . token ,
325+ network : error . payment . network ,
326+ description : error . payment . description ,
327+ } ,
328+ } ,
329+ null ,
330+ 2 ,
331+ ) ,
332+ } ,
333+ ] ,
334+ isError : true ,
335+ } ;
336+ }
337+
338+ // Rate limited: tell the agent to back off
339+ if ( error instanceof RateLimitError ) {
340+ this . logger . warn ( `Rate limited on tool ${ toolName } : retry in ${ error . retryAfterMs } ms` ) ;
341+ return {
342+ content : [
343+ {
344+ type : "text" as const ,
345+ text : JSON . stringify (
346+ {
347+ error : "RATE_LIMITED" ,
348+ message : `Too many requests. Wait ${ Math . ceil ( error . retryAfterMs / 1000 ) } seconds before retrying.` ,
349+ retryAfterMs : error . retryAfterMs ,
350+ } ,
351+ null ,
352+ 2 ,
353+ ) ,
354+ } ,
355+ ] ,
356+ isError : true ,
357+ } ;
358+ }
359+
286360 // Non-auth error: return as-is
287361 const errorMessage = error instanceof Error ? error . message : String ( error ) ;
288362
@@ -352,5 +426,71 @@ export class PhantomMCPServer {
352426 } ) ;
353427
354428 await this . initPromise ;
429+
430+ // Wire payment handler after init completes so any failure here is clearly visible
431+ // and not silently swallowed by the session-init catch block above.
432+ try {
433+ await this . wirePaymentHandler ( ) ;
434+ } catch ( err ) {
435+ const msg = err instanceof Error ? err . message : String ( err ) ;
436+ this . logger . error ( `Failed to wire payment handler: ${ msg } — API payment auto-pay will be disabled` ) ;
437+ }
438+ }
439+
440+ /**
441+ * Wires a payment handler into the shared apiClient so any 402 from the proxy
442+ * is automatically paid and the original request retried — no tool changes needed.
443+ * Called after every session init/reset so the handler always uses the current client.
444+ */
445+ private async wirePaymentHandler ( ) : Promise < void > {
446+ // getClient/getSession throw if no session — don't wire if unauthenticated
447+ let client : ReturnType < typeof this . sessionManager . getClient > ;
448+ let session : ReturnType < typeof this . sessionManager . getSession > ;
449+ try {
450+ client = this . sessionManager . getClient ( ) ;
451+ session = this . sessionManager . getSession ( ) ;
452+ } catch {
453+ this . logger . warn ( "wirePaymentHandler: no active session — skipping (will retry after login)" ) ;
454+ return ;
455+ }
456+ this . logger . info ( "Wiring payment handler into apiClient" ) ;
457+
458+ // Set static headers — wallet address and app id are known after session init
459+ const appId = process . env . PHANTOM_APP_ID ?? process . env . PHANTOM_CLIENT_ID ;
460+ const addresses = await client . getWalletAddresses ( session . walletId ) ;
461+ const solanaAddress = addresses . find ( a => a . addressType === AddressType . solana ) ?. address ;
462+ const staticHeaders : Record < string , string > = {
463+ [ ANALYTICS_HEADERS . PLATFORM ] : "ext-sdk" ,
464+ [ ANALYTICS_HEADERS . CLIENT ] : "mcp" ,
465+ [ ANALYTICS_HEADERS . SDK_VERSION ] : process . env . PHANTOM_VERSION ?? packageJson . version ?? "unknown" ,
466+ } ;
467+ if ( appId ) {
468+ staticHeaders [ "x-api-key" ] = appId ;
469+ staticHeaders [ "X-App-Id" ] = appId ;
470+ }
471+ if ( solanaAddress ) {
472+ staticHeaders [ "X-Wallet-Address" ] = solanaAddress ;
473+ }
474+ this . apiClient . setHeaders ( staticHeaders ) ;
475+
476+ this . apiClient . setPaymentHandler ( async payment => {
477+ this . logger . info ( `Paying ${ payment . amount } ${ payment . token } to unlock API access` ) ;
478+
479+ // Re-resolve Solana address in case session was refreshed
480+ const paymentAddresses = await client . getWalletAddresses ( session . walletId ) ;
481+ const solanaAddress = paymentAddresses . find ( a => a . addressType === AddressType . solana ) ?. address ;
482+ if ( ! solanaAddress ) throw new Error ( "No Solana address found for payment" ) ;
483+
484+ const txBytes = Buffer . from ( payment . preparedTx , "base64" ) ;
485+ const result = await client . signAndSendTransaction ( {
486+ walletId : session . walletId ,
487+ transaction : base64urlEncode ( txBytes ) ,
488+ networkId : normalizeNetworkId ( "solana:mainnet" ) as NetworkId ,
489+ account : solanaAddress ,
490+ } ) ;
491+ if ( ! result . hash ) throw new Error ( "Payment tx submitted but no signature returned" ) ;
492+ this . logger . info ( `Payment signature: ${ result . hash } ` ) ;
493+ return result . hash ;
494+ } ) ;
355495 }
356496}
0 commit comments