@@ -18,7 +18,7 @@ import {
1818 Account ,
1919 getErrorRecoveryHint
2020} from '@atxp/common' ;
21- import type { PaymentMaker , ProspectivePayment , ClientConfig , PaymentFailureContext , ScopedSpendConfig } from './types.js' ;
21+ import type { PaymentMaker , ProspectivePayment , ClientConfig , PaymentFailureContext } from './types.js' ;
2222import { InsufficientFundsError , ATXPPaymentError } from './errors.js' ;
2323import { getIsReactNative , createReactNativeSafeFetch , Destination } from '@atxp/common' ;
2424import { McpError } from '@modelcontextprotocol/sdk/types.js' ;
@@ -48,7 +48,7 @@ export function atxpFetch(config: ClientConfig): FetchLike {
4848 onPaymentFailure : config . onPaymentFailure ,
4949 onPaymentAttemptFailed : config . onPaymentAttemptFailed ,
5050 atxpAccountsServer : config . atxpAccountsServer ,
51- scopedSpendConfig : config . scopedSpendConfig
51+ mcpServer : config . mcpServer
5252 } ) ;
5353 return fetcher . fetch ;
5454}
@@ -71,7 +71,7 @@ export class ATXPFetcher {
7171 protected strict : boolean ;
7272 protected allowInsecureRequests : boolean ;
7373 protected atxpAccountsServer ?: string ;
74- protected scopedSpendConfig ?: ScopedSpendConfig ;
74+ protected mcpServer ?: string ;
7575 constructor ( config : {
7676 account : Account ;
7777 db : OAuthDb ;
@@ -89,7 +89,7 @@ export class ATXPFetcher {
8989 onPaymentFailure ?: ( context : PaymentFailureContext ) => Promise < void > ;
9090 onPaymentAttemptFailed ?: ( args : { network : string , error : Error , remainingNetworks : string [ ] } ) => Promise < void > ;
9191 atxpAccountsServer ?: string ;
92- scopedSpendConfig ?: ScopedSpendConfig ;
92+ mcpServer ?: string ;
9393 } ) {
9494 const {
9595 account,
@@ -108,7 +108,7 @@ export class ATXPFetcher {
108108 onPaymentFailure,
109109 onPaymentAttemptFailed,
110110 atxpAccountsServer,
111- scopedSpendConfig
111+ mcpServer
112112 } = config ;
113113 // Use React Native safe fetch if in React Native environment
114114 this . safeFetchFn = getIsReactNative ( ) ? createReactNativeSafeFetch ( fetchFn ) : fetchFn ;
@@ -130,7 +130,7 @@ export class ATXPFetcher {
130130 this . onPaymentFailure = onPaymentFailure || this . defaultPaymentFailureHandler ;
131131 this . onPaymentAttemptFailed = onPaymentAttemptFailed ;
132132 this . atxpAccountsServer = atxpAccountsServer ;
133- this . scopedSpendConfig = scopedSpendConfig ;
133+ this . mcpServer = mcpServer ;
134134 }
135135
136136 /**
@@ -410,12 +410,77 @@ export class ATXPFetcher {
410410 return this . allowedAuthorizationServers . includes ( baseUrl ) ;
411411 }
412412
413+ /**
414+ * Gets the connection token from the account if available.
415+ * Uses duck typing to check if the account has a token property (like ATXPAccount).
416+ */
417+ protected getAccountConnectionToken ( ) : string | null {
418+ // Check if account has a token property (duck typing for ATXPAccount)
419+ const accountWithToken = this . account as { token ?: string } ;
420+ if ( typeof accountWithToken . token === 'string' && accountWithToken . token . length > 0 ) {
421+ return accountWithToken . token ;
422+ }
423+ return null ;
424+ }
425+
426+ /**
427+ * Creates a spend permission with the accounts service for the given resource URL.
428+ * Returns the spend_permission_token to pass to auth.
429+ */
430+ protected createSpendPermission = async ( resourceUrl : string ) : Promise < string | null > => {
431+ if ( ! this . atxpAccountsServer ) {
432+ this . logger . debug ( `ATXP: No accounts server configured, skipping spend permission creation` ) ;
433+ return null ;
434+ }
435+
436+ // Get connection token from account for authenticating to accounts service
437+ const connectionToken = this . getAccountConnectionToken ( ) ;
438+ if ( ! connectionToken ) {
439+ this . logger . debug ( `ATXP: No connection token available, skipping spend permission creation` ) ;
440+ return null ;
441+ }
442+
443+ try {
444+ const spendPermissionUrl = `${ this . atxpAccountsServer } /spend-permission` ;
445+ this . logger . debug ( `ATXP: Creating spend permission at ${ spendPermissionUrl } for resource ${ resourceUrl } ` ) ;
446+
447+ const response = await this . sideChannelFetch ( spendPermissionUrl , {
448+ method : 'POST' ,
449+ headers : {
450+ 'Authorization' : `Bearer ${ connectionToken } ` ,
451+ 'Content-Type' : 'application/json'
452+ } ,
453+ body : JSON . stringify ( { resourceUrl } )
454+ } ) ;
455+
456+ if ( ! response . ok ) {
457+ this . logger . warn ( `ATXP: Failed to create spend permission: ${ response . status } ${ response . statusText } ` ) ;
458+ return null ;
459+ }
460+
461+ const data = await response . json ( ) as { spendPermissionToken ?: string } ;
462+ if ( data . spendPermissionToken ) {
463+ this . logger . info ( `ATXP: Created spend permission for resource ${ resourceUrl } ` ) ;
464+ return data . spendPermissionToken ;
465+ }
466+
467+ this . logger . warn ( `ATXP: Spend permission response missing token` ) ;
468+ return null ;
469+ } catch ( error ) {
470+ this . logger . warn ( `ATXP: Error creating spend permission: ${ error instanceof Error ? error . message : 'Unknown error' } ` ) ;
471+ return null ;
472+ }
473+ }
474+
413475 protected makeAuthRequestWithPaymentMaker = async ( authorizationUrl : URL , paymentMaker : PaymentMaker ) : Promise < string > => {
414476 const codeChallenge = authorizationUrl . searchParams . get ( 'code_challenge' ) ;
415477 if ( ! codeChallenge ) {
416478 throw new Error ( `Code challenge not provided` ) ;
417479 }
418480
481+ // Debug logging for spend permission configuration
482+ this . logger . debug ( `ATXP: makeAuthRequestWithPaymentMaker - mcpServer: ${ this . mcpServer } , atxpAccountsServer: ${ this . atxpAccountsServer } ` ) ;
483+
419484 if ( ! paymentMaker ) {
420485 const paymentMakerCount = this . account . paymentMakers . length ;
421486 throw new Error ( `Payment maker is null/undefined. Available payment maker count: ${ paymentMakerCount } . This usually indicates a payment maker object was not properly instantiated.` ) ;
@@ -429,76 +494,23 @@ export class ATXPFetcher {
429494
430495 const accountId = await this . account . getAccountId ( ) ;
431496
432- // If scoped spend config is set and we have accounts server, use accounts /sign endpoint
433- // to get both JWT and scoped spend token
434- let authToken : string ;
435- let scopedSpendToken : string | undefined ;
436-
437- if ( this . scopedSpendConfig && this . atxpAccountsServer ) {
438- this . logger . debug ( `ATXP: using scoped spend token flow with limit ${ this . scopedSpendConfig . spendLimit } ` ) ;
439-
440- // First, resolve the destination account ID from auth server
441- const clientId = authorizationUrl . searchParams . get ( 'client_id' ) ;
442- if ( ! clientId ) {
443- throw new Error ( `ATXP: client_id not found in authorization URL - cannot resolve destination for scoped spend token` ) ;
444- }
445-
446- // Call auth's resolve endpoint to get destination account ID
447- const resolveUrl = new URL ( authorizationUrl . origin + '/authorize' ) ;
448- resolveUrl . searchParams . set ( 'client_id' , clientId ) ;
449- resolveUrl . searchParams . set ( 'resolve_only' , 'true' ) ;
450-
451- this . logger . debug ( `ATXP: resolving destination account for client_id ${ clientId } ` ) ;
452- const resolveResponse = await this . sideChannelFetch ( resolveUrl . toString ( ) , {
453- method : 'GET'
454- } ) ;
455-
456- if ( ! resolveResponse . ok ) {
457- const errorBody = await resolveResponse . text ( ) ;
458- throw new Error ( `ATXP: failed to resolve destination account for client_id ${ clientId } : ${ resolveResponse . status } ${ errorBody } ` ) ;
459- }
497+ // Generate JWT locally via paymentMaker
498+ const authToken = await paymentMaker . generateJWT ( { paymentRequestId : '' , codeChallenge : codeChallenge , accountId} ) ;
460499
461- const resolveData = await resolveResponse . json ( ) as { destinationAccountId : string } ;
462- const destinationAccountId = resolveData . destinationAccountId ;
463- this . logger . debug ( `ATXP: resolved destination account ${ destinationAccountId } for client_id ${ clientId } ` ) ;
464-
465- // Call accounts /sign endpoint to get JWT + scoped spend token
466- const signUrl = new URL ( '/sign' , this . atxpAccountsServer ) ;
467- const signBody = {
468- paymentRequestId : '' ,
469- codeChallenge : codeChallenge ,
470- accountId : accountId ,
471- destinationAccountId : destinationAccountId ,
472- spendLimit : this . scopedSpendConfig . spendLimit
473- } ;
500+ // Build the authorization URL with resource parameter
501+ // The resource parameter identifies the MCP server resource URL for spend permission scoping
502+ let finalAuthUrl = authorizationUrl . toString ( ) + '&redirect=false' ;
474503
475- this . logger . debug ( `ATXP: calling accounts /sign for JWT + scoped spend token` ) ;
476- const signResponse = await this . sideChannelFetch ( signUrl . toString ( ) , {
477- method : 'POST' ,
478- headers : {
479- 'Content-Type' : 'application/json'
480- } ,
481- body : JSON . stringify ( signBody )
482- } ) ;
504+ // If we have a resource URL (MCP server), create a spend permission first
505+ let spendPermissionToken : string | null = null ;
506+ if ( this . mcpServer ) {
507+ finalAuthUrl += '&resource=' + encodeURIComponent ( this . mcpServer ) ;
483508
484- if ( ! signResponse . ok ) {
485- const errorBody = await signResponse . text ( ) ;
486- throw new Error ( `ATXP: accounts /sign failed: ${ signResponse . status } ${ errorBody } ` ) ;
509+ // Create spend permission with accounts service using connection token
510+ spendPermissionToken = await this . createSpendPermission ( this . mcpServer ) ;
511+ if ( spendPermissionToken ) {
512+ finalAuthUrl += '&spend_permission_token=' + encodeURIComponent ( spendPermissionToken ) ;
487513 }
488-
489- const signData = await signResponse . json ( ) as { jwt : string ; scopedSpendToken ?: string } ;
490- authToken = signData . jwt ;
491- scopedSpendToken = signData . scopedSpendToken ;
492- this . logger . debug ( `ATXP: got JWT${ scopedSpendToken ? ' and scoped spend token' : '' } from accounts /sign` ) ;
493- } else {
494- // Use original flow - generate JWT locally via paymentMaker
495- authToken = await paymentMaker . generateJWT ( { paymentRequestId : '' , codeChallenge : codeChallenge , accountId} ) ;
496- }
497-
498- // Build the authorization URL with optional scoped spend token
499- let finalAuthUrl = authorizationUrl . toString ( ) + '&redirect=false' ;
500- if ( scopedSpendToken ) {
501- finalAuthUrl += '&scoped_spend_token=' + encodeURIComponent ( scopedSpendToken ) ;
502514 }
503515
504516 // Make a fetch call to the authorization URL with the payment ID
0 commit comments