From 2174ea2313015847a0514d6a53a3a3f8eb66288d Mon Sep 17 00:00:00 2001 From: bdj Date: Wed, 7 Jan 2026 14:54:06 -0800 Subject: [PATCH 01/10] feat: Add scoped spend token support to SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables SDK clients to use scoped spend tokens during authorization, allowing async charging instead of blocking on payment before each operation. Changes: - Add ScopedSpendConfig type with spendLimit field - Add scopedSpendConfig option to ClientConfig - Modify makeAuthRequestWithPaymentMaker to: 1. Resolve destinationAccountId from auth server (new resolve_only endpoint) 2. Call accounts /sign with destinationAccountId + spendLimit 3. Pass scopedSpendToken to auth /authorize - Add comprehensive tests for scoped spend flow Usage: ```typescript const client = await atxpClient({ mcpServer: 'https://example.com/mcp', account: myAccount, scopedSpendConfig: { spendLimit: '50.00' } }); ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/atxp-client/src/atxpClient.ts | 3 +- .../src/atxpFetcher.scopedSpend.test.ts | 269 ++++++++++++++++++ packages/atxp-client/src/atxpFetcher.ts | 95 ++++++- packages/atxp-client/src/types.ts | 19 ++ 4 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts diff --git a/packages/atxp-client/src/atxpClient.ts b/packages/atxp-client/src/atxpClient.ts index 9e6554f..1bc242e 100644 --- a/packages/atxp-client/src/atxpClient.ts +++ b/packages/atxp-client/src/atxpClient.ts @@ -8,7 +8,8 @@ import { DEFAULT_ATXP_ACCOUNTS_SERVER, ATXPAccount } from "@atxp/common"; type RequiredClientConfigFields = 'mcpServer' | 'account'; type OptionalClientConfig = Omit; -type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers'; +// BuildableClientConfigFields are excluded from DEFAULT_CLIENT_CONFIG - they're either truly optional or built at runtime +type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers' | 'scopedSpendConfig'; // Detect if we're in a browser environment and bind fetch appropriately const getFetch = (): typeof fetch => { diff --git a/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts new file mode 100644 index 0000000..c830dce --- /dev/null +++ b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts @@ -0,0 +1,269 @@ +import { MemoryOAuthDb, Account, DEFAULT_AUTHORIZATION_SERVER } from '@atxp/common'; +import { describe, it, expect, vi } from 'vitest'; +import fetchMock from 'fetch-mock'; +import { mockResourceServer, mockAuthorizationServer } from './clientTestHelpers.js'; +import { ATXPFetcher } from './atxpFetcher.js'; +import { OAuthDb, FetchLike } from '@atxp/common'; +import { PaymentMaker, ScopedSpendConfig } from './types.js'; + +function mockPaymentMakers(solanaPaymentMaker?: PaymentMaker) { + solanaPaymentMaker = solanaPaymentMaker ?? { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('testJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + return [solanaPaymentMaker]; +} + +function atxpFetcher( + fetchFn: FetchLike, + paymentMakers?: PaymentMaker[], + db?: OAuthDb, + options?: { + atxpAccountsServer?: string; + scopedSpendConfig?: ScopedSpendConfig; + } +) { + const account: Account = { + getAccountId: async () => "bdj" as any, + paymentMakers: paymentMakers ?? mockPaymentMakers(), + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }] + }; + + return new ATXPFetcher({ + account, + db: db ?? new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn, + atxpAccountsServer: options?.atxpAccountsServer, + scopedSpendConfig: options?.scopedSpendConfig + }); +} + +describe('atxpFetcher scoped spend token', () => { + it('should use standard auth flow when scopedSpendConfig is not set', async () => { + const f = fetchMock.createInstance(); + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as any).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('standardJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker]); + await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + + // Should use local generateJWT + expect(paymentMaker.generateJWT).toHaveBeenCalled(); + + // Ensure no calls to resolve endpoint + const resolveCalls = f.callHistory.callLogs.filter(call => + call.url.includes('resolve_only=true') + ); + expect(resolveCalls.length).toBe(0); + }); + + it('should call resolve endpoint and accounts /sign when scopedSpendConfig is set', async () => { + const f = fetchMock.createInstance(); + + // Mock the resource server + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + + // Mock auth server with all required endpoints + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const url = new URL(req.args[0] as any); + const resolveOnly = url.searchParams.get('resolve_only'); + const state = url.searchParams.get('state'); + + if (resolveOnly === 'true') { + // Resolve endpoint - return destination account ID + return { destinationAccountId: 'atxp_acct_destination123' }; + } + + // Normal authorize - return redirect with code + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + // Mock accounts /sign endpoint + f.post('https://accounts.atxp.ai/sign', { + jwt: 'jwtFromAccounts', + scopedSpendToken: 'scopedSpendTokenXYZ', + scopedSpendTokenId: 'sst_test123', + scopedSpendDestinationAccountId: 'atxp_acct_destination123' + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('localJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + atxpAccountsServer: 'https://accounts.atxp.ai', + scopedSpendConfig: { spendLimit: '100.00' } + }); + + await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + + // Should NOT use local generateJWT + expect(paymentMaker.generateJWT).not.toHaveBeenCalled(); + + // Should have called resolve endpoint + const resolveCalls = f.callHistory.callLogs.filter(call => + call.url.includes('resolve_only=true') + ); + expect(resolveCalls.length).toBe(1); + + // Should have called accounts /sign + const signCalls = f.callHistory.callLogs.filter(call => + call.url === 'https://accounts.atxp.ai/sign' + ); + expect(signCalls.length).toBe(1); + + // Verify the sign request body + const signBody = JSON.parse(signCalls[0].options?.body as string); + expect(signBody.destinationAccountId).toBe('atxp_acct_destination123'); + expect(signBody.spendLimit).toBe('100.00'); + + // Should have passed scoped_spend_token to authorize + const authCalls = f.callHistory.callLogs.filter(call => + call.url.includes('/authorize') && !call.url.includes('resolve_only=true') + ); + expect(authCalls.length).toBeGreaterThan(0); + const authUrl = authCalls[0].url; + expect(authUrl).toContain('scoped_spend_token=scopedSpendTokenXYZ'); + }); + + it('should throw error when resolve endpoint fails', async () => { + const f = fetchMock.createInstance(); + + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401); + + // Mock auth server with all required endpoints + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const url = new URL(req.args[0] as any); + const resolveOnly = url.searchParams.get('resolve_only'); + + if (resolveOnly === 'true') { + return { + status: 404, + body: JSON.stringify({ error: 'client_not_found' }) + }; + } + + return { status: 500 }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('localJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + atxpAccountsServer: 'https://accounts.atxp.ai', + scopedSpendConfig: { spendLimit: '100.00' } + }); + + await expect( + fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) + ).rejects.toThrow('failed to resolve destination account'); + }); + + it('should throw error when accounts /sign fails', async () => { + const f = fetchMock.createInstance(); + + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401); + + // Mock auth server with all required endpoints + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const url = new URL(req.args[0] as any); + const resolveOnly = url.searchParams.get('resolve_only'); + + if (resolveOnly === 'true') { + return { destinationAccountId: 'atxp_acct_destination123' }; + } + + return { status: 500 }; + }); + + // Mock accounts /sign endpoint - fails + f.post('https://accounts.atxp.ai/sign', { + status: 500, + body: JSON.stringify({ error: 'Internal server error' }) + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('localJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + atxpAccountsServer: 'https://accounts.atxp.ai', + scopedSpendConfig: { spendLimit: '100.00' } + }); + + await expect( + fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) + ).rejects.toThrow('accounts /sign failed'); + }); + + it('should use standard auth when atxpAccountsServer is not set even with scopedSpendConfig', async () => { + const f = fetchMock.createInstance(); + + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as any).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('standardJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + // Set scopedSpendConfig but NOT atxpAccountsServer + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + scopedSpendConfig: { spendLimit: '100.00' } + // Note: atxpAccountsServer not set + }); + + await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + + // Should fall back to local generateJWT + expect(paymentMaker.generateJWT).toHaveBeenCalled(); + }); +}); diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index ec6320d..5a8bab7 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -18,7 +18,7 @@ import { Account, getErrorRecoveryHint } from '@atxp/common'; -import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext } from './types.js'; +import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext, ScopedSpendConfig } from './types.js'; import { InsufficientFundsError, ATXPPaymentError } from './errors.js'; import { getIsReactNative, createReactNativeSafeFetch, Destination } from '@atxp/common'; import { McpError } from '@modelcontextprotocol/sdk/types.js'; @@ -46,7 +46,9 @@ export function atxpFetch(config: ClientConfig): FetchLike { onAuthorizeFailure: config.onAuthorizeFailure, onPayment: config.onPayment, onPaymentFailure: config.onPaymentFailure, - onPaymentAttemptFailed: config.onPaymentAttemptFailed + onPaymentAttemptFailed: config.onPaymentAttemptFailed, + atxpAccountsServer: config.atxpAccountsServer, + scopedSpendConfig: config.scopedSpendConfig }); return fetcher.fetch; } @@ -68,6 +70,8 @@ export class ATXPFetcher { protected onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; protected strict: boolean; protected allowInsecureRequests: boolean; + protected atxpAccountsServer?: string; + protected scopedSpendConfig?: ScopedSpendConfig; constructor(config: { account: Account; db: OAuthDb; @@ -84,6 +88,8 @@ export class ATXPFetcher { onPayment?: (args: { payment: ProspectivePayment, transactionHash: string, network: string }) => Promise; onPaymentFailure?: (context: PaymentFailureContext) => Promise; onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; + atxpAccountsServer?: string; + scopedSpendConfig?: ScopedSpendConfig; }) { const { account, @@ -100,7 +106,9 @@ export class ATXPFetcher { onAuthorizeFailure = async () => {}, onPayment = async () => {}, onPaymentFailure, - onPaymentAttemptFailed + onPaymentAttemptFailed, + atxpAccountsServer, + scopedSpendConfig } = config; // Use React Native safe fetch if in React Native environment this.safeFetchFn = getIsReactNative() ? createReactNativeSafeFetch(fetchFn) : fetchFn; @@ -121,6 +129,8 @@ export class ATXPFetcher { this.onPayment = onPayment; this.onPaymentFailure = onPaymentFailure || this.defaultPaymentFailureHandler; this.onPaymentAttemptFailed = onPaymentAttemptFailed; + this.atxpAccountsServer = atxpAccountsServer; + this.scopedSpendConfig = scopedSpendConfig; } /** @@ -418,25 +428,96 @@ export class ATXPFetcher { } const accountId = await this.account.getAccountId(); - const authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId}); + + // If scoped spend config is set and we have accounts server, use accounts /sign endpoint + // to get both JWT and scoped spend token + let authToken: string; + let scopedSpendToken: string | undefined; + + if (this.scopedSpendConfig && this.atxpAccountsServer) { + this.logger.debug(`ATXP: using scoped spend token flow with limit ${this.scopedSpendConfig.spendLimit}`); + + // First, resolve the destination account ID from auth server + const clientId = authorizationUrl.searchParams.get('client_id'); + if (!clientId) { + throw new Error(`ATXP: client_id not found in authorization URL - cannot resolve destination for scoped spend token`); + } + + // Call auth's resolve endpoint to get destination account ID + const resolveUrl = new URL(authorizationUrl.origin + '/authorize'); + resolveUrl.searchParams.set('client_id', clientId); + resolveUrl.searchParams.set('resolve_only', 'true'); + + this.logger.debug(`ATXP: resolving destination account for client_id ${clientId}`); + const resolveResponse = await this.sideChannelFetch(resolveUrl.toString(), { + method: 'GET' + }); + + if (!resolveResponse.ok) { + const errorBody = await resolveResponse.text(); + throw new Error(`ATXP: failed to resolve destination account for client_id ${clientId}: ${resolveResponse.status} ${errorBody}`); + } + + const resolveData = await resolveResponse.json() as { destinationAccountId: string }; + const destinationAccountId = resolveData.destinationAccountId; + this.logger.debug(`ATXP: resolved destination account ${destinationAccountId} for client_id ${clientId}`); + + // Call accounts /sign endpoint to get JWT + scoped spend token + const signUrl = new URL('/sign', this.atxpAccountsServer); + const signBody = { + paymentRequestId: '', + codeChallenge: codeChallenge, + accountId: accountId, + destinationAccountId: destinationAccountId, + spendLimit: this.scopedSpendConfig.spendLimit + }; + + this.logger.debug(`ATXP: calling accounts /sign for JWT + scoped spend token`); + const signResponse = await this.sideChannelFetch(signUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(signBody) + }); + + if (!signResponse.ok) { + const errorBody = await signResponse.text(); + throw new Error(`ATXP: accounts /sign failed: ${signResponse.status} ${errorBody}`); + } + + const signData = await signResponse.json() as { jwt: string; scopedSpendToken?: string }; + authToken = signData.jwt; + scopedSpendToken = signData.scopedSpendToken; + this.logger.debug(`ATXP: got JWT${scopedSpendToken ? ' and scoped spend token' : ''} from accounts /sign`); + } else { + // Use original flow - generate JWT locally via paymentMaker + authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId}); + } + + // Build the authorization URL with optional scoped spend token + let finalAuthUrl = authorizationUrl.toString() + '&redirect=false'; + if (scopedSpendToken) { + finalAuthUrl += '&scoped_spend_token=' + encodeURIComponent(scopedSpendToken); + } // Make a fetch call to the authorization URL with the payment ID // redirect=false is a hack // The OAuth spec calls for the authorization url to return with a redirect, but fetch // on mobile will automatically follow the redirect (it doesn't support the redirect=manual option) - // We want the redirect URL so we can extract the code from it, not the contents of the + // We want the redirect URL so we can extract the code from it, not the contents of the // redirect URL (which might not even exist for agentic ATXP clients) // So ATXP servers are set up to instead return a 200 with the redirect URL in the body // if we pass redirect=false. // TODO: Remove the redirect=false hack once we have a way to handle the redirect on mobile - const response = await this.sideChannelFetch(authorizationUrl.toString()+'&redirect=false', { + const response = await this.sideChannelFetch(finalAuthUrl, { method: 'GET', redirect: 'manual', headers: { 'Authorization': `Bearer ${authToken}` } }); - // Check if we got a redirect response (301, 302, etc.) in case the server follows + // Check if we got a redirect response (301, 302, etc.) in case the server follows // the OAuth spec if (response.status >= 300 && response.status < 400) { const location = response.headers.get('Location'); diff --git a/packages/atxp-client/src/types.ts b/packages/atxp-client/src/types.ts index c5040c2..595f132 100644 --- a/packages/atxp-client/src/types.ts +++ b/packages/atxp-client/src/types.ts @@ -36,6 +36,19 @@ export interface PaymentFailureContext { timestamp: Date; } +/** + * Configuration for scoped spend tokens. + * When configured, the SDK will request scoped spend tokens during authorization, + * enabling async charging instead of blocking on payment before each operation. + */ +export interface ScopedSpendConfig { + /** + * Maximum amount the SDK is authorized to spend on behalf of the user. + * This is the spend limit for the scoped spend token, in USD (e.g., "50.00"). + */ + spendLimit: string; +} + export type ClientConfig = { mcpServer: string; account: Account; @@ -56,6 +69,12 @@ export type ClientConfig = { onPaymentFailure: (context: PaymentFailureContext) => Promise; /** Optional callback when a single payment attempt fails (before trying other networks) */ onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; + /** + * Optional scoped spend configuration. + * When provided, the SDK will request scoped spend tokens during authorization, + * enabling async charging instead of blocking on payment before each operation. + */ + scopedSpendConfig?: ScopedSpendConfig; } // ClientArgs for creating clients - required fields plus optional overrides From f39c3e43e15478709d39bdd065141b949f9bdfe9 Mon Sep 17 00:00:00 2001 From: bdj Date: Fri, 9 Jan 2026 16:52:20 -0800 Subject: [PATCH 02/10] feat: Use resource_url-based spend permissions Updates SDK to use the new spend permissions flow: - Replace scopedSpendConfig with mcpServer for resource URL tracking - Add createSpendPermission() to create spend permissions via accounts /spend-permission - Pass spend_permission_token to auth during OAuth flow - Remove old ScopedSpendConfig type The new flow: 1. SDK calls accounts /spend-permission with resource_url to create permission 2. SDK passes spend_permission_token to auth during authorization 3. Auth stores token with OAuth token for charge strategy selection Co-Authored-By: Claude Opus 4.5 --- packages/atxp-client/src/atxpClient.ts | 6 +- packages/atxp-client/src/atxpFetcher.ts | 156 +++++++++++++----------- packages/atxp-client/src/types.ts | 19 --- src/dev/cli.ts | 5 +- src/dev/resource.ts | 2 +- 5 files changed, 92 insertions(+), 96 deletions(-) diff --git a/packages/atxp-client/src/atxpClient.ts b/packages/atxp-client/src/atxpClient.ts index 1bc242e..4699fd2 100644 --- a/packages/atxp-client/src/atxpClient.ts +++ b/packages/atxp-client/src/atxpClient.ts @@ -9,7 +9,7 @@ import { DEFAULT_ATXP_ACCOUNTS_SERVER, ATXPAccount } from "@atxp/common"; type RequiredClientConfigFields = 'mcpServer' | 'account'; type OptionalClientConfig = Omit; // BuildableClientConfigFields are excluded from DEFAULT_CLIENT_CONFIG - they're either truly optional or built at runtime -type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers' | 'scopedSpendConfig'; +type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers'; // Detect if we're in a browser environment and bind fetch appropriately const getFetch = (): typeof fetch => { @@ -69,8 +69,8 @@ export function buildClientConfig(args: ClientArgs): ClientConfig { atxpAccountsServer: accountsServer, fetchFn }); - - const built = { oAuthDb, logger, destinationMakers }; + + const built = { oAuthDb, logger, destinationMakers, atxpAccountsServer: accountsServer }; return Object.freeze({ ...withDefaults, ...built }); }; diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index 5a8bab7..dd4b468 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -18,7 +18,7 @@ import { Account, getErrorRecoveryHint } from '@atxp/common'; -import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext, ScopedSpendConfig } from './types.js'; +import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext } from './types.js'; import { InsufficientFundsError, ATXPPaymentError } from './errors.js'; import { getIsReactNative, createReactNativeSafeFetch, Destination } from '@atxp/common'; import { McpError } from '@modelcontextprotocol/sdk/types.js'; @@ -48,7 +48,7 @@ export function atxpFetch(config: ClientConfig): FetchLike { onPaymentFailure: config.onPaymentFailure, onPaymentAttemptFailed: config.onPaymentAttemptFailed, atxpAccountsServer: config.atxpAccountsServer, - scopedSpendConfig: config.scopedSpendConfig + mcpServer: config.mcpServer }); return fetcher.fetch; } @@ -71,7 +71,7 @@ export class ATXPFetcher { protected strict: boolean; protected allowInsecureRequests: boolean; protected atxpAccountsServer?: string; - protected scopedSpendConfig?: ScopedSpendConfig; + protected mcpServer?: string; constructor(config: { account: Account; db: OAuthDb; @@ -89,7 +89,7 @@ export class ATXPFetcher { onPaymentFailure?: (context: PaymentFailureContext) => Promise; onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; atxpAccountsServer?: string; - scopedSpendConfig?: ScopedSpendConfig; + mcpServer?: string; }) { const { account, @@ -108,7 +108,7 @@ export class ATXPFetcher { onPaymentFailure, onPaymentAttemptFailed, atxpAccountsServer, - scopedSpendConfig + mcpServer } = config; // Use React Native safe fetch if in React Native environment this.safeFetchFn = getIsReactNative() ? createReactNativeSafeFetch(fetchFn) : fetchFn; @@ -130,7 +130,7 @@ export class ATXPFetcher { this.onPaymentFailure = onPaymentFailure || this.defaultPaymentFailureHandler; this.onPaymentAttemptFailed = onPaymentAttemptFailed; this.atxpAccountsServer = atxpAccountsServer; - this.scopedSpendConfig = scopedSpendConfig; + this.mcpServer = mcpServer; } /** @@ -410,12 +410,77 @@ export class ATXPFetcher { return this.allowedAuthorizationServers.includes(baseUrl); } + /** + * Gets the connection token from the account if available. + * Uses duck typing to check if the account has a token property (like ATXPAccount). + */ + protected getAccountConnectionToken(): string | null { + // Check if account has a token property (duck typing for ATXPAccount) + const accountWithToken = this.account as { token?: string }; + if (typeof accountWithToken.token === 'string' && accountWithToken.token.length > 0) { + return accountWithToken.token; + } + return null; + } + + /** + * Creates a spend permission with the accounts service for the given resource URL. + * Returns the spend_permission_token to pass to auth. + */ + protected createSpendPermission = async (resourceUrl: string): Promise => { + if (!this.atxpAccountsServer) { + this.logger.debug(`ATXP: No accounts server configured, skipping spend permission creation`); + return null; + } + + // Get connection token from account for authenticating to accounts service + const connectionToken = this.getAccountConnectionToken(); + if (!connectionToken) { + this.logger.debug(`ATXP: No connection token available, skipping spend permission creation`); + return null; + } + + try { + const spendPermissionUrl = `${this.atxpAccountsServer}/spend-permission`; + this.logger.debug(`ATXP: Creating spend permission at ${spendPermissionUrl} for resource ${resourceUrl}`); + + const response = await this.sideChannelFetch(spendPermissionUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${connectionToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ resourceUrl }) + }); + + if (!response.ok) { + this.logger.warn(`ATXP: Failed to create spend permission: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json() as { spendPermissionToken?: string }; + if (data.spendPermissionToken) { + this.logger.info(`ATXP: Created spend permission for resource ${resourceUrl}`); + return data.spendPermissionToken; + } + + this.logger.warn(`ATXP: Spend permission response missing token`); + return null; + } catch (error) { + this.logger.warn(`ATXP: Error creating spend permission: ${error instanceof Error ? error.message : 'Unknown error'}`); + return null; + } + } + protected makeAuthRequestWithPaymentMaker = async (authorizationUrl: URL, paymentMaker: PaymentMaker): Promise => { const codeChallenge = authorizationUrl.searchParams.get('code_challenge'); if (!codeChallenge) { throw new Error(`Code challenge not provided`); } + // Debug logging for spend permission configuration + this.logger.debug(`ATXP: makeAuthRequestWithPaymentMaker - mcpServer: ${this.mcpServer}, atxpAccountsServer: ${this.atxpAccountsServer}`); + if (!paymentMaker) { const paymentMakerCount = this.account.paymentMakers.length; 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 { const accountId = await this.account.getAccountId(); - // If scoped spend config is set and we have accounts server, use accounts /sign endpoint - // to get both JWT and scoped spend token - let authToken: string; - let scopedSpendToken: string | undefined; - - if (this.scopedSpendConfig && this.atxpAccountsServer) { - this.logger.debug(`ATXP: using scoped spend token flow with limit ${this.scopedSpendConfig.spendLimit}`); - - // First, resolve the destination account ID from auth server - const clientId = authorizationUrl.searchParams.get('client_id'); - if (!clientId) { - throw new Error(`ATXP: client_id not found in authorization URL - cannot resolve destination for scoped spend token`); - } - - // Call auth's resolve endpoint to get destination account ID - const resolveUrl = new URL(authorizationUrl.origin + '/authorize'); - resolveUrl.searchParams.set('client_id', clientId); - resolveUrl.searchParams.set('resolve_only', 'true'); - - this.logger.debug(`ATXP: resolving destination account for client_id ${clientId}`); - const resolveResponse = await this.sideChannelFetch(resolveUrl.toString(), { - method: 'GET' - }); - - if (!resolveResponse.ok) { - const errorBody = await resolveResponse.text(); - throw new Error(`ATXP: failed to resolve destination account for client_id ${clientId}: ${resolveResponse.status} ${errorBody}`); - } + // Generate JWT locally via paymentMaker + const authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId}); - const resolveData = await resolveResponse.json() as { destinationAccountId: string }; - const destinationAccountId = resolveData.destinationAccountId; - this.logger.debug(`ATXP: resolved destination account ${destinationAccountId} for client_id ${clientId}`); - - // Call accounts /sign endpoint to get JWT + scoped spend token - const signUrl = new URL('/sign', this.atxpAccountsServer); - const signBody = { - paymentRequestId: '', - codeChallenge: codeChallenge, - accountId: accountId, - destinationAccountId: destinationAccountId, - spendLimit: this.scopedSpendConfig.spendLimit - }; + // Build the authorization URL with resource parameter + // The resource parameter identifies the MCP server resource URL for spend permission scoping + let finalAuthUrl = authorizationUrl.toString() + '&redirect=false'; - this.logger.debug(`ATXP: calling accounts /sign for JWT + scoped spend token`); - const signResponse = await this.sideChannelFetch(signUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(signBody) - }); + // If we have a resource URL (MCP server), create a spend permission first + let spendPermissionToken: string | null = null; + if (this.mcpServer) { + finalAuthUrl += '&resource=' + encodeURIComponent(this.mcpServer); - if (!signResponse.ok) { - const errorBody = await signResponse.text(); - throw new Error(`ATXP: accounts /sign failed: ${signResponse.status} ${errorBody}`); + // Create spend permission with accounts service using connection token + spendPermissionToken = await this.createSpendPermission(this.mcpServer); + if (spendPermissionToken) { + finalAuthUrl += '&spend_permission_token=' + encodeURIComponent(spendPermissionToken); } - - const signData = await signResponse.json() as { jwt: string; scopedSpendToken?: string }; - authToken = signData.jwt; - scopedSpendToken = signData.scopedSpendToken; - this.logger.debug(`ATXP: got JWT${scopedSpendToken ? ' and scoped spend token' : ''} from accounts /sign`); - } else { - // Use original flow - generate JWT locally via paymentMaker - authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId}); - } - - // Build the authorization URL with optional scoped spend token - let finalAuthUrl = authorizationUrl.toString() + '&redirect=false'; - if (scopedSpendToken) { - finalAuthUrl += '&scoped_spend_token=' + encodeURIComponent(scopedSpendToken); } // Make a fetch call to the authorization URL with the payment ID diff --git a/packages/atxp-client/src/types.ts b/packages/atxp-client/src/types.ts index 595f132..c5040c2 100644 --- a/packages/atxp-client/src/types.ts +++ b/packages/atxp-client/src/types.ts @@ -36,19 +36,6 @@ export interface PaymentFailureContext { timestamp: Date; } -/** - * Configuration for scoped spend tokens. - * When configured, the SDK will request scoped spend tokens during authorization, - * enabling async charging instead of blocking on payment before each operation. - */ -export interface ScopedSpendConfig { - /** - * Maximum amount the SDK is authorized to spend on behalf of the user. - * This is the spend limit for the scoped spend token, in USD (e.g., "50.00"). - */ - spendLimit: string; -} - export type ClientConfig = { mcpServer: string; account: Account; @@ -69,12 +56,6 @@ export type ClientConfig = { onPaymentFailure: (context: PaymentFailureContext) => Promise; /** Optional callback when a single payment attempt fails (before trying other networks) */ onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; - /** - * Optional scoped spend configuration. - * When provided, the SDK will request scoped spend tokens during authorization, - * enabling async charging instead of blocking on payment before each operation. - */ - scopedSpendConfig?: ScopedSpendConfig; } // ClientArgs for creating clients - required fields plus optional overrides diff --git a/src/dev/cli.ts b/src/dev/cli.ts index a9124ef..472feb9 100644 --- a/src/dev/cli.ts +++ b/src/dev/cli.ts @@ -39,12 +39,15 @@ async function main() { try { const account = new ATXPAccount(process.env.ATXP_CONNECTION_STRING!); + // Use local accounts server for development if MCP server is localhost + const isLocalDev = url.includes('localhost'); const mcpClient = await atxpClient({ mcpServer: url, account, allowedAuthorizationServers: ['http://localhost:3010', 'https://auth.atxp.ai', 'https://atpx-auth-staging.onrender.com'], allowHttp: true, - logger: new ConsoleLogger({level: LogLevel.DEBUG}) + logger: new ConsoleLogger({level: LogLevel.DEBUG}), + ...(isLocalDev && { atxpAccountsServer: 'http://localhost:8016' }) }); const res = await mcpClient.callTool({ name: toolName, diff --git a/src/dev/resource.ts b/src/dev/resource.ts index d8925aa..445fec1 100644 --- a/src/dev/resource.ts +++ b/src/dev/resource.ts @@ -554,7 +554,7 @@ console.log('Starting MCP server with destination', destinationAccountId); app.use(atxpExpress({ destination: destination, - //server: 'http://localhost:3010', + server: 'http://localhost:3010', payeeName: 'ATXP Client Example Resource Server', minimumPayment: BigNumber(0.01), allowHttp: true, From f849eb41d5c0c921b5d508fed20843201406a13a Mon Sep 17 00:00:00 2001 From: bdj Date: Tue, 13 Jan 2026 12:29:28 -0800 Subject: [PATCH 03/10] fix: Remove nested @atxp/common entries from package-lock.json The package-lock.json had nested node_modules entries for @atxp/common that pointed to the npm registry version (0.10.3) instead of using the local workspace version. This caused TypeScript to fail because the published version doesn't have the new `iss` property on PaymentRequest. Removed nested entries for: - packages/atxp-client/node_modules/@atxp/common - packages/atxp-base/node_modules/@atxp/common - packages/atxp-express/node_modules/@atxp/common Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index c1bee79..7941815 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25518,19 +25518,6 @@ "react-native-url-polyfill": "^2.0.0" } }, - "packages/atxp-base/node_modules/@atxp/common": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@atxp/common/-/common-0.10.3.tgz", - "integrity": "sha512-UeFb4yQHnZ0T4ar9Y6qedaN37DcVup2RIxOwv1AhDz9gtf6kAPdWBCnii+yoUk5H8BlzyLZQgzr1yoj4alv4Zw==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.3.0", - "jose": "^6.0.11", - "oauth4webapi": "^3.8.3", - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" - } - }, "packages/atxp-base/node_modules/react-native-url-polyfill": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz", @@ -25573,19 +25560,6 @@ "react-native-url-polyfill": "^3.0.0" } }, - "packages/atxp-client/node_modules/@atxp/common": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@atxp/common/-/common-0.10.3.tgz", - "integrity": "sha512-UeFb4yQHnZ0T4ar9Y6qedaN37DcVup2RIxOwv1AhDz9gtf6kAPdWBCnii+yoUk5H8BlzyLZQgzr1yoj4alv4Zw==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.3.0", - "jose": "^6.0.11", - "oauth4webapi": "^3.8.3", - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" - } - }, "packages/atxp-cloudflare": { "name": "@atxp/cloudflare", "version": "0.10.4", @@ -25653,19 +25627,6 @@ "express": "^5.0.0" } }, - "packages/atxp-express/node_modules/@atxp/common": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@atxp/common/-/common-0.10.3.tgz", - "integrity": "sha512-UeFb4yQHnZ0T4ar9Y6qedaN37DcVup2RIxOwv1AhDz9gtf6kAPdWBCnii+yoUk5H8BlzyLZQgzr1yoj4alv4Zw==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.3.0", - "jose": "^6.0.11", - "oauth4webapi": "^3.8.3", - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" - } - }, "packages/atxp-polygon": { "name": "@atxp/polygon", "version": "0.10.4", From 1a71c3c9d5428c1266d9d0cb2163b3d3756434bb Mon Sep 17 00:00:00 2001 From: bdj Date: Tue, 13 Jan 2026 12:43:11 -0800 Subject: [PATCH 04/10] fix: Update scoped spend tests to match implementation - Fix tests to use mcpServer instead of scopedSpendConfig - Test /spend-permission endpoint instead of /authorize?resolve_only - Update error handling tests for graceful fallback behavior - Add CLAUDE.md with testing instructions The tests were testing an outdated design that used resolve_only and /sign endpoints. Updated to test the actual implementation which uses /spend-permission endpoint and graceful fallback on errors. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 73 +++++++ .../src/atxpFetcher.scopedSpend.test.ts | 205 ++++++++++-------- 2 files changed, 193 insertions(+), 85 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3514ee3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with code in this repository. + +## Project Overview + +ATXP SDK is a TypeScript monorepo providing client and server libraries for the ATXP payment protocol. It supports multiple blockchain networks (Solana, Base, World Chain, Polygon) and integrates with MCP (Model Context Protocol) for AI agent payments. + +## Common Commands + +```bash +# Install dependencies +npm ci + +# Build all packages +npm run build + +# Type check all packages +npm run typecheck + +# Lint all packages +npm run lint +``` + +## Testing + +**IMPORTANT:** Run tests package-by-package because the root level `npm run test` tends to hide test failures. + +```bash +# Run tests for a specific package +npm test -w packages/atxp-common +npm test -w packages/atxp-client +npm test -w packages/atxp-server +npm test -w packages/atxp-express +npm test -w packages/atxp-x402 +# etc. + +# Run all package tests individually (recommended for CI validation) +for pkg in packages/*/; do + echo "Testing $pkg..." + npm test -w "$pkg" || exit 1 +done +``` + +## Architecture + +This is an npm workspaces monorepo with the following packages: + +- `packages/atxp-common` - Shared types and utilities +- `packages/atxp-client` - Client-side SDK for making payments +- `packages/atxp-server` - Server-side SDK for receiving payments +- `packages/atxp-express` - Express.js middleware integration +- `packages/atxp-cloudflare` - Cloudflare Workers integration +- `packages/atxp-base` - Base chain support +- `packages/atxp-solana` - Solana chain support +- `packages/atxp-polygon` - Polygon chain support +- `packages/atxp-worldchain` - World Chain support +- `packages/atxp-x402` - X402 payment protocol support +- `packages/atxp-sqlite` - SQLite storage for OAuth tokens +- `packages/atxp-redis` - Redis storage for OAuth tokens + +## Key Patterns + +### Workspace Dependencies + +Packages depend on each other via workspace references. When adding new types or exports to `@atxp/common`, ensure: +1. The type is exported from `src/index.ts` +2. Run `npm run build -w packages/atxp-common` to regenerate dist files +3. Other packages will pick up changes via TypeScript project references + +### Package Lock Issues + +If you see TypeScript errors about missing properties in workspace packages, check `package-lock.json` for nested `node_modules/@atxp/*` entries that point to npm registry versions instead of local workspace versions. Remove these entries and run `npm ci` again. diff --git a/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts index c830dce..f61ea0d 100644 --- a/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts +++ b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts @@ -4,7 +4,7 @@ import fetchMock from 'fetch-mock'; import { mockResourceServer, mockAuthorizationServer } from './clientTestHelpers.js'; import { ATXPFetcher } from './atxpFetcher.js'; import { OAuthDb, FetchLike } from '@atxp/common'; -import { PaymentMaker, ScopedSpendConfig } from './types.js'; +import { PaymentMaker } from './types.js'; function mockPaymentMakers(solanaPaymentMaker?: PaymentMaker) { solanaPaymentMaker = solanaPaymentMaker ?? { @@ -21,17 +21,19 @@ function atxpFetcher( db?: OAuthDb, options?: { atxpAccountsServer?: string; - scopedSpendConfig?: ScopedSpendConfig; + mcpServer?: string; } ) { - const account: Account = { + // Create account with optional token for spend permission tests + const account: Account & { token?: string } = { getAccountId: async () => "bdj" as any, paymentMakers: paymentMakers ?? mockPaymentMakers(), getSources: async () => [{ address: 'SolAddress123', chain: 'solana' as any, walletType: 'eoa' as any - }] + }], + token: options?.atxpAccountsServer ? 'test_connection_token' : undefined }; return new ATXPFetcher({ @@ -40,7 +42,7 @@ function atxpFetcher( destinationMakers: new Map(), fetchFn, atxpAccountsServer: options?.atxpAccountsServer, - scopedSpendConfig: options?.scopedSpendConfig + mcpServer: options?.mcpServer }); } @@ -78,7 +80,7 @@ describe('atxpFetcher scoped spend token', () => { expect(resolveCalls.length).toBe(0); }); - it('should call resolve endpoint and accounts /sign when scopedSpendConfig is set', async () => { + it('should call spend-permission endpoint when mcpServer is set', async () => { const f = fetchMock.createInstance(); // Mock the resource server @@ -89,28 +91,16 @@ describe('atxpFetcher scoped spend token', () => { // Mock auth server with all required endpoints mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { - const url = new URL(req.args[0] as any); - const resolveOnly = url.searchParams.get('resolve_only'); - const state = url.searchParams.get('state'); - - if (resolveOnly === 'true') { - // Resolve endpoint - return destination account ID - return { destinationAccountId: 'atxp_acct_destination123' }; - } - - // Normal authorize - return redirect with code + const state = new URL(req.args[0] as any).searchParams.get('state'); return { status: 301, headers: {location: `https://atxp.ai?state=${state}&code=testCode`} }; }); - // Mock accounts /sign endpoint - f.post('https://accounts.atxp.ai/sign', { - jwt: 'jwtFromAccounts', - scopedSpendToken: 'scopedSpendTokenXYZ', - scopedSpendTokenId: 'sst_test123', - scopedSpendDestinationAccountId: 'atxp_acct_destination123' + // Mock accounts /spend-permission endpoint + f.post('https://accounts.atxp.ai/spend-permission', { + spendPermissionToken: 'spendPermissionTokenXYZ' }); const paymentMaker = { @@ -121,62 +111,57 @@ describe('atxpFetcher scoped spend token', () => { const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { atxpAccountsServer: 'https://accounts.atxp.ai', - scopedSpendConfig: { spendLimit: '100.00' } + mcpServer: 'https://example.com/mcp' }); await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - // Should NOT use local generateJWT - expect(paymentMaker.generateJWT).not.toHaveBeenCalled(); - - // Should have called resolve endpoint - const resolveCalls = f.callHistory.callLogs.filter(call => - call.url.includes('resolve_only=true') - ); - expect(resolveCalls.length).toBe(1); + // Should still use local generateJWT for the authorize request + expect(paymentMaker.generateJWT).toHaveBeenCalled(); - // Should have called accounts /sign - const signCalls = f.callHistory.callLogs.filter(call => - call.url === 'https://accounts.atxp.ai/sign' + // Should have called spend-permission endpoint + const spendPermissionCalls = f.callHistory.callLogs.filter(call => + call.url === 'https://accounts.atxp.ai/spend-permission' ); - expect(signCalls.length).toBe(1); + expect(spendPermissionCalls.length).toBe(1); - // Verify the sign request body - const signBody = JSON.parse(signCalls[0].options?.body as string); - expect(signBody.destinationAccountId).toBe('atxp_acct_destination123'); - expect(signBody.spendLimit).toBe('100.00'); + // Verify the spend-permission request body + const spendPermissionBody = JSON.parse(spendPermissionCalls[0].options?.body as string); + expect(spendPermissionBody.resourceUrl).toBe('https://example.com/mcp'); - // Should have passed scoped_spend_token to authorize + // Should have passed spend_permission_token to authorize const authCalls = f.callHistory.callLogs.filter(call => - call.url.includes('/authorize') && !call.url.includes('resolve_only=true') + call.url.includes('/authorize') ); expect(authCalls.length).toBeGreaterThan(0); const authUrl = authCalls[0].url; - expect(authUrl).toContain('scoped_spend_token=scopedSpendTokenXYZ'); + expect(authUrl).toContain('spend_permission_token=spendPermissionTokenXYZ'); + expect(authUrl).toContain('resource=https%3A%2F%2Fexample.com%2Fmcp'); }); - it('should throw error when resolve endpoint fails', async () => { + it('should gracefully continue when spend-permission endpoint fails', async () => { const f = fetchMock.createInstance(); mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) - .postOnce('https://example.com/mcp', 401); + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); // Mock auth server with all required endpoints mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { - const url = new URL(req.args[0] as any); - const resolveOnly = url.searchParams.get('resolve_only'); - - if (resolveOnly === 'true') { - return { - status: 404, - body: JSON.stringify({ error: 'client_not_found' }) - }; - } - - return { status: 500 }; + const state = new URL(req.args[0] as any).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; }); + // Mock accounts /spend-permission endpoint - return error + f.post('https://accounts.atxp.ai/spend-permission', { + status: 500, + body: JSON.stringify({ error: 'Internal server error' }) + }); + const paymentMaker = { makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), generateJWT: vi.fn().mockResolvedValue('localJWT'), @@ -185,56 +170,92 @@ describe('atxpFetcher scoped spend token', () => { const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { atxpAccountsServer: 'https://accounts.atxp.ai', - scopedSpendConfig: { spendLimit: '100.00' } + mcpServer: 'https://example.com/mcp' }); - await expect( - fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) - ).rejects.toThrow('failed to resolve destination account'); + // Should not throw - should gracefully continue without spend permission token + const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + expect(response).toBeDefined(); + + // Should have attempted spend-permission endpoint + const spendPermissionCalls = f.callHistory.callLogs.filter(call => + call.url === 'https://accounts.atxp.ai/spend-permission' + ); + expect(spendPermissionCalls.length).toBe(1); + + // Should still have made authorize call (without spend_permission_token) + const authCalls = f.callHistory.callLogs.filter(call => + call.url.includes('/authorize') + ); + expect(authCalls.length).toBeGreaterThan(0); + // Should NOT have spend_permission_token since it failed + expect(authCalls[0].url).not.toContain('spend_permission_token='); }); - it('should throw error when accounts /sign fails', async () => { + it('should skip spend-permission when no connection token available', async () => { const f = fetchMock.createInstance(); mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) - .postOnce('https://example.com/mcp', 401); + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); // Mock auth server with all required endpoints mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { - const url = new URL(req.args[0] as any); - const resolveOnly = url.searchParams.get('resolve_only'); - - if (resolveOnly === 'true') { - return { destinationAccountId: 'atxp_acct_destination123' }; - } - - return { status: 500 }; + const state = new URL(req.args[0] as any).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; }); - // Mock accounts /sign endpoint - fails - f.post('https://accounts.atxp.ai/sign', { - status: 500, - body: JSON.stringify({ error: 'Internal server error' }) - }); - const paymentMaker = { makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), generateJWT: vi.fn().mockResolvedValue('localJWT'), getSourceAddress: vi.fn().mockReturnValue('SolAddress123') }; - const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + // Create account WITHOUT token property + const account: Account = { + getAccountId: async () => "bdj" as any, + paymentMakers: [paymentMaker], + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }] + }; + + const fetcher = new ATXPFetcher({ + account, + db: new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn: f.fetchHandler, atxpAccountsServer: 'https://accounts.atxp.ai', - scopedSpendConfig: { spendLimit: '100.00' } + mcpServer: 'https://example.com/mcp' }); - await expect( - fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) - ).rejects.toThrow('accounts /sign failed'); + const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + expect(response).toBeDefined(); + + // Should NOT have called spend-permission endpoint (no token) + const spendPermissionCalls = f.callHistory.callLogs.filter(call => + call.url === 'https://accounts.atxp.ai/spend-permission' + ); + expect(spendPermissionCalls.length).toBe(0); + + // Should still have made authorize call + const authCalls = f.callHistory.callLogs.filter(call => + call.url.includes('/authorize') + ); + expect(authCalls.length).toBeGreaterThan(0); + // Should NOT have spend_permission_token since we didn't call the endpoint + expect(authCalls[0].url).not.toContain('spend_permission_token='); + // Should still have resource parameter + expect(authCalls[0].url).toContain('resource=https%3A%2F%2Fexample.com%2Fmcp'); }); - it('should use standard auth when atxpAccountsServer is not set even with scopedSpendConfig', async () => { + it('should skip spend-permission when mcpServer is not set even with atxpAccountsServer', async () => { const f = fetchMock.createInstance(); mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) @@ -255,15 +276,29 @@ describe('atxpFetcher scoped spend token', () => { getSourceAddress: vi.fn().mockReturnValue('SolAddress123') }; - // Set scopedSpendConfig but NOT atxpAccountsServer + // Set atxpAccountsServer but NOT mcpServer const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { - scopedSpendConfig: { spendLimit: '100.00' } - // Note: atxpAccountsServer not set + atxpAccountsServer: 'https://accounts.atxp.ai' + // Note: mcpServer not set }); await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - // Should fall back to local generateJWT + // Should use local generateJWT expect(paymentMaker.generateJWT).toHaveBeenCalled(); + + // Should NOT have called spend-permission endpoint (no mcpServer) + const spendPermissionCalls = f.callHistory.callLogs.filter(call => + call.url.includes('/spend-permission') + ); + expect(spendPermissionCalls.length).toBe(0); + + // Should NOT have resource or spend_permission_token params + const authCalls = f.callHistory.callLogs.filter(call => + call.url.includes('/authorize') + ); + expect(authCalls.length).toBeGreaterThan(0); + expect(authCalls[0].url).not.toContain('resource='); + expect(authCalls[0].url).not.toContain('spend_permission_token='); }); }); From 8f1986d3315064bc7df385e8ad15060e1acd1e9f Mon Sep 17 00:00:00 2001 From: bdj Date: Tue, 13 Jan 2026 14:37:25 -0800 Subject: [PATCH 05/10] fix: Add client credentials to /charge requests - PaymentServer now accepts oAuthDb to look up credentials - Sends Basic auth header on /charge for resource_url validation - serverConfig passes oAuthDb to PaymentServer constructor Co-Authored-By: Claude Opus 4.5 --- packages/atxp-server/src/paymentServer.ts | 41 +++++++++++++++++------ packages/atxp-server/src/serverConfig.ts | 3 +- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/atxp-server/src/paymentServer.ts b/packages/atxp-server/src/paymentServer.ts index ae9c6d5..a1778af 100644 --- a/packages/atxp-server/src/paymentServer.ts +++ b/packages/atxp-server/src/paymentServer.ts @@ -1,5 +1,5 @@ import { PaymentServer, Charge } from "./types.js"; -import { AuthorizationServerUrl, FetchLike, Logger } from "@atxp/common"; +import { AuthorizationServerUrl, FetchLike, Logger, OAuthDb } from "@atxp/common"; /** * Expected error response format from ATXP payment server @@ -15,14 +15,16 @@ interface PaymentServerErrorResponse { /** * ATXP Payment Server implementation - * + * * This class handles payment operations with the ATXP authorization server. - * + * * @example * ```typescript * const paymentServer = new ATXPPaymentServer( * 'https://auth.atxp.ai', - * logger + * logger, + * fetch, + * oAuthDb // For looking up client credentials * ); * ``` */ @@ -30,7 +32,8 @@ export class ATXPPaymentServer implements PaymentServer { constructor( private readonly server: AuthorizationServerUrl, private readonly logger: Logger, - private readonly fetchFn: FetchLike = fetch.bind(globalThis)) { + private readonly fetchFn: FetchLike = fetch.bind(globalThis), + private readonly oAuthDb?: OAuthDb) { } charge = async(chargeRequest: Charge): Promise => { @@ -106,12 +109,12 @@ export class ATXPPaymentServer implements PaymentServer { /** * Makes authenticated requests to the ATXP authorization server - * + * * @param method - HTTP method ('GET' or 'POST') * @param path - API endpoint path * @param body - Request body (for POST requests) * @returns Promise - The HTTP response from the server - * + * * @example * ```typescript * const response = await paymentServer.makeRequest('POST', '/charge', { @@ -123,11 +126,29 @@ export class ATXPPaymentServer implements PaymentServer { */ protected makeRequest = async(method: 'GET' | 'POST', path: string, body: unknown): Promise => { const url = new URL(path, this.server); + + const headers: Record = { + 'Content-Type': 'application/json' + }; + + // Add Basic auth header with client credentials if available + // This authenticates the MCP server to enable resource_url validation + if (this.oAuthDb) { + try { + const credentials = await this.oAuthDb.getClientCredentials(this.server); + if (credentials?.clientId && credentials?.clientSecret) { + const credentialString = `${credentials.clientId}:${credentials.clientSecret}`; + const base64Credentials = Buffer.from(credentialString).toString('base64'); + headers['Authorization'] = `Basic ${base64Credentials}`; + } + } catch (error) { + this.logger.warn('Failed to get client credentials for /charge authentication', error); + } + } + const response = await this.fetchFn(url, { method, - headers: { - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify(body) }); return response; diff --git a/packages/atxp-server/src/serverConfig.ts b/packages/atxp-server/src/serverConfig.ts index d2ce8de..79e0397 100644 --- a/packages/atxp-server/src/serverConfig.ts +++ b/packages/atxp-server/src/serverConfig.ts @@ -46,7 +46,8 @@ export function buildServerConfig(args: ATXPArgs): ATXPConfig { atxpConnectionToken }); const logger = withDefaults.logger ?? new ConsoleLogger(); - const paymentServer = withDefaults.paymentServer ?? new ATXPPaymentServer(withDefaults.server, logger) + // Pass oAuthDb to PaymentServer so it can authenticate /charge requests with client credentials + const paymentServer = withDefaults.paymentServer ?? new ATXPPaymentServer(withDefaults.server, logger, fetch.bind(globalThis), oAuthDb) const built = { oAuthDb, oAuthClient, paymentServer, logger}; return Object.freeze({ ...withDefaults, ...built }); From b0cd672a827c4a7a874b991f5a501cc0a627ddc6 Mon Sep 17 00:00:00 2001 From: bdj Date: Tue, 13 Jan 2026 15:14:43 -0800 Subject: [PATCH 06/10] refactor: Revert dev script changes Keep src/dev/ files aligned with main branch - these are for local development only and shouldn't be part of the PR. Co-Authored-By: Claude Opus 4.5 --- src/dev/cli.ts | 5 +---- src/dev/resource.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/dev/cli.ts b/src/dev/cli.ts index 472feb9..a9124ef 100644 --- a/src/dev/cli.ts +++ b/src/dev/cli.ts @@ -39,15 +39,12 @@ async function main() { try { const account = new ATXPAccount(process.env.ATXP_CONNECTION_STRING!); - // Use local accounts server for development if MCP server is localhost - const isLocalDev = url.includes('localhost'); const mcpClient = await atxpClient({ mcpServer: url, account, allowedAuthorizationServers: ['http://localhost:3010', 'https://auth.atxp.ai', 'https://atpx-auth-staging.onrender.com'], allowHttp: true, - logger: new ConsoleLogger({level: LogLevel.DEBUG}), - ...(isLocalDev && { atxpAccountsServer: 'http://localhost:8016' }) + logger: new ConsoleLogger({level: LogLevel.DEBUG}) }); const res = await mcpClient.callTool({ name: toolName, diff --git a/src/dev/resource.ts b/src/dev/resource.ts index 445fec1..d8925aa 100644 --- a/src/dev/resource.ts +++ b/src/dev/resource.ts @@ -554,7 +554,7 @@ console.log('Starting MCP server with destination', destinationAccountId); app.use(atxpExpress({ destination: destination, - server: 'http://localhost:3010', + //server: 'http://localhost:3010', payeeName: 'ATXP Client Example Resource Server', minimumPayment: BigNumber(0.01), allowHttp: true, From e6e2754790f85a9fe468ef7ddb9b20cc1485b6e5 Mon Sep 17 00:00:00 2001 From: bdj Date: Tue, 13 Jan 2026 15:55:20 -0800 Subject: [PATCH 07/10] Refactor: derive resource URL from OAuth error, require client credentials - Remove mcpServer from ATXPFetcher config - resource URL now derived dynamically from OAuthAuthenticationRequiredError.resourceServerUrl - Remove atxpAccountsServer from ClientConfig - derived from account.origin - paymentServer: default oAuthDb to MemoryOAuthDb, hard error if credentials unavailable instead of silently dropping - Update tests to reflect new behavior Co-Authored-By: Claude Opus 4.5 --- packages/atxp-client/src/atxpClient.ts | 16 ++- .../src/atxpFetcher.scopedSpend.test.ts | 110 ++++++------------ packages/atxp-client/src/atxpFetcher.ts | 54 +++++---- packages/atxp-client/src/types.ts | 8 +- .../atxp-server/src/paymentServer.test.ts | 86 +++++++------- packages/atxp-server/src/paymentServer.ts | 30 ++--- 6 files changed, 130 insertions(+), 174 deletions(-) diff --git a/packages/atxp-client/src/atxpClient.ts b/packages/atxp-client/src/atxpClient.ts index 4699fd2..82b1bc8 100644 --- a/packages/atxp-client/src/atxpClient.ts +++ b/packages/atxp-client/src/atxpClient.ts @@ -6,7 +6,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { createDestinationMakers } from './destinationMakers/index.js'; import { DEFAULT_ATXP_ACCOUNTS_SERVER, ATXPAccount } from "@atxp/common"; -type RequiredClientConfigFields = 'mcpServer' | 'account'; +type RequiredClientConfigFields = 'account'; type OptionalClientConfig = Omit; // BuildableClientConfigFields are excluded from DEFAULT_CLIENT_CONFIG - they're either truly optional or built at runtime type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers'; @@ -23,7 +23,6 @@ const getFetch = (): typeof fetch => { export const DEFAULT_CLIENT_CONFIG: Required> = { allowedAuthorizationServers: [DEFAULT_AUTHORIZATION_SERVER], - atxpAccountsServer: DEFAULT_ATXP_ACCOUNTS_SERVER, approvePayment: async (_p) => true, fetchFn: getFetch(), oAuthChannelFetch: getFetch(), @@ -58,11 +57,9 @@ export function buildClientConfig(args: ClientArgs): ClientConfig { const fetchFn = withDefaults.fetchFn; // Build destination makers if not provided - let accountsServer = withDefaults.atxpAccountsServer; - // QoL hack for unspecified accounts server - if the caller is passing an atxpAccount, then assume the origin for that - // is what we should use for the accounts server. In practice, the only option is accounts.atxp.ai, - // but this supports staging environment - if (args.atxpAccountsServer === undefined && withDefaults.account && withDefaults.account instanceof ATXPAccount) { + // Derive accounts server from account's origin if available + let accountsServer = DEFAULT_ATXP_ACCOUNTS_SERVER; + if (withDefaults.account && withDefaults.account instanceof ATXPAccount) { accountsServer = withDefaults.account.origin; } const destinationMakers = withDefaults.destinationMakers ?? createDestinationMakers({ @@ -70,7 +67,7 @@ export function buildClientConfig(args: ClientArgs): ClientConfig { fetchFn }); - const built = { oAuthDb, logger, destinationMakers, atxpAccountsServer: accountsServer }; + const built = { oAuthDb, logger, destinationMakers }; return Object.freeze({ ...withDefaults, ...built }); }; @@ -86,7 +83,8 @@ export function buildStreamableTransport(args: ClientArgs): StreamableHTTPClient export async function atxpClient(args: ClientArgs): Promise { const config = buildClientConfig(args); - const transport = buildStreamableTransport(config); + // Pass args (not config) to buildStreamableTransport since it needs mcpServer + const transport = buildStreamableTransport(args); const client = new Client(config.clientInfo, config.clientOptions); await client.connect(transport); diff --git a/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts index f61ea0d..1bba406 100644 --- a/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts +++ b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts @@ -20,12 +20,12 @@ function atxpFetcher( paymentMakers?: PaymentMaker[], db?: OAuthDb, options?: { - atxpAccountsServer?: string; - mcpServer?: string; + accountsServer?: string; } ) { - // Create account with optional token for spend permission tests - const account: Account & { token?: string } = { + // Create account with optional token and origin for spend permission tests + // origin is derived from the connection string in ATXPAccount + const account: Account & { token?: string; origin?: string } = { getAccountId: async () => "bdj" as any, paymentMakers: paymentMakers ?? mockPaymentMakers(), getSources: async () => [{ @@ -33,21 +33,20 @@ function atxpFetcher( chain: 'solana' as any, walletType: 'eoa' as any }], - token: options?.atxpAccountsServer ? 'test_connection_token' : undefined + token: options?.accountsServer ? 'test_connection_token' : undefined, + origin: options?.accountsServer }; return new ATXPFetcher({ account, db: db ?? new MemoryOAuthDb(), destinationMakers: new Map(), - fetchFn, - atxpAccountsServer: options?.atxpAccountsServer, - mcpServer: options?.mcpServer + fetchFn }); } describe('atxpFetcher scoped spend token', () => { - it('should use standard auth flow when scopedSpendConfig is not set', async () => { + it('should use standard auth flow without spend permission when account has no origin/token', async () => { const f = fetchMock.createInstance(); mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) .postOnce('https://example.com/mcp', 401) @@ -67,20 +66,25 @@ describe('atxpFetcher scoped spend token', () => { getSourceAddress: vi.fn().mockReturnValue('SolAddress123') }; + // No accountsServer configured - account has no origin/token const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker]); await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); // Should use local generateJWT expect(paymentMaker.generateJWT).toHaveBeenCalled(); - // Ensure no calls to resolve endpoint - const resolveCalls = f.callHistory.callLogs.filter(call => - call.url.includes('resolve_only=true') + // Should have resource parameter (from OAuth error's resourceServerUrl) + const authCalls = f.callHistory.callLogs.filter(call => + call.url.includes('/authorize') ); - expect(resolveCalls.length).toBe(0); + expect(authCalls.length).toBeGreaterThan(0); + // Resource URL should be present (derived from the request) + expect(authCalls[0].url).toContain('resource='); + // But no spend_permission_token since account has no origin/token + expect(authCalls[0].url).not.toContain('spend_permission_token='); }); - it('should call spend-permission endpoint when mcpServer is set', async () => { + it('should call spend-permission endpoint when account has origin and token', async () => { const f = fetchMock.createInstance(); // Mock the resource server @@ -109,9 +113,9 @@ describe('atxpFetcher scoped spend token', () => { getSourceAddress: vi.fn().mockReturnValue('SolAddress123') }; + // Account has origin and token configured const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { - atxpAccountsServer: 'https://accounts.atxp.ai', - mcpServer: 'https://example.com/mcp' + accountsServer: 'https://accounts.atxp.ai' }); await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); @@ -125,9 +129,9 @@ describe('atxpFetcher scoped spend token', () => { ); expect(spendPermissionCalls.length).toBe(1); - // Verify the spend-permission request body + // Verify the spend-permission request body contains resource URL from OAuth error const spendPermissionBody = JSON.parse(spendPermissionCalls[0].options?.body as string); - expect(spendPermissionBody.resourceUrl).toBe('https://example.com/mcp'); + expect(spendPermissionBody.resourceUrl).toBeDefined(); // Should have passed spend_permission_token to authorize const authCalls = f.callHistory.callLogs.filter(call => @@ -136,7 +140,7 @@ describe('atxpFetcher scoped spend token', () => { expect(authCalls.length).toBeGreaterThan(0); const authUrl = authCalls[0].url; expect(authUrl).toContain('spend_permission_token=spendPermissionTokenXYZ'); - expect(authUrl).toContain('resource=https%3A%2F%2Fexample.com%2Fmcp'); + expect(authUrl).toContain('resource='); }); it('should gracefully continue when spend-permission endpoint fails', async () => { @@ -169,8 +173,7 @@ describe('atxpFetcher scoped spend token', () => { }; const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { - atxpAccountsServer: 'https://accounts.atxp.ai', - mcpServer: 'https://example.com/mcp' + accountsServer: 'https://accounts.atxp.ai' }); // Should not throw - should gracefully continue without spend permission token @@ -215,24 +218,24 @@ describe('atxpFetcher scoped spend token', () => { getSourceAddress: vi.fn().mockReturnValue('SolAddress123') }; - // Create account WITHOUT token property - const account: Account = { + // Create account with origin but WITHOUT token property - tests that + // spend-permission is skipped when there's no connection token even if origin is set + const account: Account & { origin?: string } = { getAccountId: async () => "bdj" as any, paymentMakers: [paymentMaker], getSources: async () => [{ address: 'SolAddress123', chain: 'solana' as any, walletType: 'eoa' as any - }] + }], + origin: 'https://accounts.atxp.ai' }; const fetcher = new ATXPFetcher({ account, db: new MemoryOAuthDb(), destinationMakers: new Map(), - fetchFn: f.fetchHandler, - atxpAccountsServer: 'https://accounts.atxp.ai', - mcpServer: 'https://example.com/mcp' + fetchFn: f.fetchHandler }); const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); @@ -244,61 +247,14 @@ describe('atxpFetcher scoped spend token', () => { ); expect(spendPermissionCalls.length).toBe(0); - // Should still have made authorize call + // Should still have made authorize call with resource parameter const authCalls = f.callHistory.callLogs.filter(call => call.url.includes('/authorize') ); expect(authCalls.length).toBeGreaterThan(0); // Should NOT have spend_permission_token since we didn't call the endpoint expect(authCalls[0].url).not.toContain('spend_permission_token='); - // Should still have resource parameter - expect(authCalls[0].url).toContain('resource=https%3A%2F%2Fexample.com%2Fmcp'); - }); - - it('should skip spend-permission when mcpServer is not set even with atxpAccountsServer', async () => { - const f = fetchMock.createInstance(); - - mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) - .postOnce('https://example.com/mcp', 401) - .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); - mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) - .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { - const state = new URL(req.args[0] as any).searchParams.get('state'); - return { - status: 301, - headers: {location: `https://atxp.ai?state=${state}&code=testCode`} - }; - }); - - const paymentMaker = { - makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), - generateJWT: vi.fn().mockResolvedValue('standardJWT'), - getSourceAddress: vi.fn().mockReturnValue('SolAddress123') - }; - - // Set atxpAccountsServer but NOT mcpServer - const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { - atxpAccountsServer: 'https://accounts.atxp.ai' - // Note: mcpServer not set - }); - - await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - - // Should use local generateJWT - expect(paymentMaker.generateJWT).toHaveBeenCalled(); - - // Should NOT have called spend-permission endpoint (no mcpServer) - const spendPermissionCalls = f.callHistory.callLogs.filter(call => - call.url.includes('/spend-permission') - ); - expect(spendPermissionCalls.length).toBe(0); - - // Should NOT have resource or spend_permission_token params - const authCalls = f.callHistory.callLogs.filter(call => - call.url.includes('/authorize') - ); - expect(authCalls.length).toBeGreaterThan(0); - expect(authCalls[0].url).not.toContain('resource='); - expect(authCalls[0].url).not.toContain('spend_permission_token='); + // Should still have resource parameter from OAuth error + expect(authCalls[0].url).toContain('resource='); }); }); diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index dd4b468..2bd148e 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -46,9 +46,7 @@ export function atxpFetch(config: ClientConfig): FetchLike { onAuthorizeFailure: config.onAuthorizeFailure, onPayment: config.onPayment, onPaymentFailure: config.onPaymentFailure, - onPaymentAttemptFailed: config.onPaymentAttemptFailed, - atxpAccountsServer: config.atxpAccountsServer, - mcpServer: config.mcpServer + onPaymentAttemptFailed: config.onPaymentAttemptFailed }); return fetcher.fetch; } @@ -70,8 +68,6 @@ export class ATXPFetcher { protected onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; protected strict: boolean; protected allowInsecureRequests: boolean; - protected atxpAccountsServer?: string; - protected mcpServer?: string; constructor(config: { account: Account; db: OAuthDb; @@ -88,8 +84,6 @@ export class ATXPFetcher { onPayment?: (args: { payment: ProspectivePayment, transactionHash: string, network: string }) => Promise; onPaymentFailure?: (context: PaymentFailureContext) => Promise; onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; - atxpAccountsServer?: string; - mcpServer?: string; }) { const { account, @@ -106,9 +100,7 @@ export class ATXPFetcher { onAuthorizeFailure = async () => {}, onPayment = async () => {}, onPaymentFailure, - onPaymentAttemptFailed, - atxpAccountsServer, - mcpServer + onPaymentAttemptFailed } = config; // Use React Native safe fetch if in React Native environment this.safeFetchFn = getIsReactNative() ? createReactNativeSafeFetch(fetchFn) : fetchFn; @@ -129,8 +121,6 @@ export class ATXPFetcher { this.onPayment = onPayment; this.onPaymentFailure = onPaymentFailure || this.defaultPaymentFailureHandler; this.onPaymentAttemptFailed = onPaymentAttemptFailed; - this.atxpAccountsServer = atxpAccountsServer; - this.mcpServer = mcpServer; } /** @@ -423,13 +413,29 @@ export class ATXPFetcher { return null; } + /** + * Gets the origin URL from the account if available. + * Uses duck typing to check if the account has an origin property (like ATXPAccount). + * This is the accounts service URL derived from the connection string. + */ + protected getAccountOrigin(): string | null { + // Check if account has an origin property (duck typing for ATXPAccount) + const accountWithOrigin = this.account as { origin?: string }; + if (typeof accountWithOrigin.origin === 'string' && accountWithOrigin.origin.length > 0) { + return accountWithOrigin.origin; + } + return null; + } + /** * Creates a spend permission with the accounts service for the given resource URL. * Returns the spend_permission_token to pass to auth. */ protected createSpendPermission = async (resourceUrl: string): Promise => { - if (!this.atxpAccountsServer) { - this.logger.debug(`ATXP: No accounts server configured, skipping spend permission creation`); + // Get accounts server URL from account's origin (derived from connection string) + const accountsServer = this.getAccountOrigin(); + if (!accountsServer) { + this.logger.debug(`ATXP: No accounts server available from account, skipping spend permission creation`); return null; } @@ -441,7 +447,7 @@ export class ATXPFetcher { } try { - const spendPermissionUrl = `${this.atxpAccountsServer}/spend-permission`; + const spendPermissionUrl = `${accountsServer}/spend-permission`; this.logger.debug(`ATXP: Creating spend permission at ${spendPermissionUrl} for resource ${resourceUrl}`); const response = await this.sideChannelFetch(spendPermissionUrl, { @@ -472,15 +478,12 @@ export class ATXPFetcher { } } - protected makeAuthRequestWithPaymentMaker = async (authorizationUrl: URL, paymentMaker: PaymentMaker): Promise => { + protected makeAuthRequestWithPaymentMaker = async (authorizationUrl: URL, paymentMaker: PaymentMaker, resourceUrl?: string): Promise => { const codeChallenge = authorizationUrl.searchParams.get('code_challenge'); if (!codeChallenge) { throw new Error(`Code challenge not provided`); } - // Debug logging for spend permission configuration - this.logger.debug(`ATXP: makeAuthRequestWithPaymentMaker - mcpServer: ${this.mcpServer}, atxpAccountsServer: ${this.atxpAccountsServer}`); - if (!paymentMaker) { const paymentMakerCount = this.account.paymentMakers.length; throw new Error(`Payment maker is null/undefined. Available payment maker count: ${paymentMakerCount}. This usually indicates a payment maker object was not properly instantiated.`); @@ -498,16 +501,16 @@ export class ATXPFetcher { const authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId}); // Build the authorization URL with resource parameter - // The resource parameter identifies the MCP server resource URL for spend permission scoping + // The resource parameter identifies the resource server URL for spend permission scoping let finalAuthUrl = authorizationUrl.toString() + '&redirect=false'; - // If we have a resource URL (MCP server), create a spend permission first + // If we have a resource URL, create a spend permission first let spendPermissionToken: string | null = null; - if (this.mcpServer) { - finalAuthUrl += '&resource=' + encodeURIComponent(this.mcpServer); + if (resourceUrl) { + finalAuthUrl += '&resource=' + encodeURIComponent(resourceUrl); // Create spend permission with accounts service using connection token - spendPermissionToken = await this.createSpendPermission(this.mcpServer); + spendPermissionToken = await this.createSpendPermission(resourceUrl); if (spendPermissionToken) { finalAuthUrl += '&spend_permission_token=' + encodeURIComponent(spendPermissionToken); } @@ -578,7 +581,8 @@ export class ATXPFetcher { } try { - const redirectUrl = await this.makeAuthRequestWithPaymentMaker(authorizationUrl, paymentMaker); + // Pass the resource server URL for spend permission scoping + const redirectUrl = await this.makeAuthRequestWithPaymentMaker(authorizationUrl, paymentMaker, error.resourceServerUrl); // Handle the OAuth callback const oauthClient = await this.getOAuthClient(); await oauthClient.handleCallback(redirectUrl); diff --git a/packages/atxp-client/src/types.ts b/packages/atxp-client/src/types.ts index c5040c2..e916569 100644 --- a/packages/atxp-client/src/types.ts +++ b/packages/atxp-client/src/types.ts @@ -37,9 +37,7 @@ export interface PaymentFailureContext { } export type ClientConfig = { - mcpServer: string; account: Account; - atxpAccountsServer: string; destinationMakers: Map; allowedAuthorizationServers: AuthorizationServerUrl[]; approvePayment: (payment: ProspectivePayment) => Promise; @@ -59,10 +57,12 @@ export type ClientConfig = { } // ClientArgs for creating clients - required fields plus optional overrides -type RequiredClientConfigFields = 'mcpServer' | 'account'; +// mcpServer is required for atxpClient to know which MCP server to connect to +// but it's not part of ClientConfig since the fetcher derives resource URLs from OAuth errors +type RequiredClientConfigFields = 'account'; type RequiredClientConfig = Pick; type OptionalClientConfig = Omit; -export type ClientArgs = RequiredClientConfig & Partial; +export type ClientArgs = RequiredClientConfig & Partial & { mcpServer: string }; // Type for a fetch wrapper function that takes ClientArgs and returns wrapped fetch export type FetchWrapper = (config: ClientArgs) => FetchLike; diff --git a/packages/atxp-server/src/paymentServer.test.ts b/packages/atxp-server/src/paymentServer.test.ts index c66dd07..cecea49 100644 --- a/packages/atxp-server/src/paymentServer.test.ts +++ b/packages/atxp-server/src/paymentServer.test.ts @@ -2,17 +2,28 @@ import { describe, it, expect } from 'vitest'; import { ATXPPaymentServer } from './paymentServer.js'; import * as TH from './serverTestHelpers.js'; import fetchMock from 'fetch-mock'; +import { MemoryOAuthDb, AuthorizationServerUrl } from '@atxp/common'; + +// Helper to create OAuthDb with credentials stored +async function createOAuthDbWithCredentials(server: AuthorizationServerUrl, clientId: string, clientSecret: string): Promise { + const db = new MemoryOAuthDb(); + await db.saveClientCredentials(server, { clientId, clientSecret }); + return db; +} describe('ATXPPaymentServer', () => { - it('should call the charge endpoint', async () => { + it('should call the charge endpoint with client credentials', async () => { const mock = fetchMock.createInstance(); mock.post('https://auth.atxp.ai/charge', { status: 200, body: { success: true } }); - // Create server instance - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + // Create OAuthDb with credentials + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + + // Create server instance with credentials + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); const chargeParams = TH.charge({ sourceAccountId: 'solana:test-source', @@ -24,49 +35,47 @@ describe('ATXPPaymentServer', () => { // Verify the result expect(result).toBe(true); - // Verify fetch was called with correct parameters + // Verify fetch was called with correct parameters including auth header const call = mock.callHistory.lastCall('https://auth.atxp.ai/charge'); expect(call).toBeDefined(); expect(call?.options.method).toBe('post'); - expect(call?.options.headers).toEqual({ - 'content-type': 'application/json' - }); + + // Verify Authorization header is present with Basic auth + const expectedCredentials = Buffer.from('test-client-id:test-client-secret').toString('base64'); + expect((call?.options.headers as Record)?.['authorization']).toBe(`Basic ${expectedCredentials}`); + const parsedBody = JSON.parse(call?.options.body as string); expect(parsedBody.sourceAccountId).toEqual(chargeParams.sourceAccountId); expect(parsedBody.destinationAccountId).toEqual(chargeParams.destinationAccountId); expect(parsedBody.options).toBeDefined(); expect(parsedBody.options[0].amount).toEqual(chargeParams.options[0].amount.toString()); - - // Credentials were fetched from the real database }); - it('should make requests without authorization headers', async () => { + it('should throw error when credentials are not configured', async () => { const mock = fetchMock.createInstance(); mock.post('https://auth.atxp.ai/charge', { status: 200, body: { success: true } }); + // Create server instance WITHOUT credentials (empty MemoryOAuthDb) const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); - await server.charge(TH.charge({ + await expect(server.charge(TH.charge({ sourceAccountId: 'solana:test-source', destinationAccountId: 'solana:test-destination' - })); - - // Verify no authorization header is included - const call = mock.callHistory.lastCall('https://auth.atxp.ai/charge'); - expect((call?.options?.headers as any)?.['authorization']).toBeUndefined(); + }))).rejects.toThrow('Missing client credentials'); }); - it('should call the create payment request endpoint', async () => { + it('should call the create payment request endpoint with credentials', async () => { const mock = fetchMock.createInstance(); mock.post('https://auth.atxp.ai/payment-request', { status: 200, body: { id: 'test-payment-request-id' } }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); const paymentRequestParams = TH.charge({ sourceAccountId: 'solana:test-source', @@ -78,38 +87,21 @@ describe('ATXPPaymentServer', () => { // Verify the result expect(result).toBe('test-payment-request-id'); - // Verify fetch was called with correct parameters + // Verify fetch was called with correct parameters including auth header const call = mock.callHistory.lastCall('https://auth.atxp.ai/payment-request'); expect(call).toBeDefined(); expect(call?.options.method).toBe('post'); - expect(call?.options.headers).toEqual({ - 'content-type': 'application/json' - }); + + // Verify Authorization header is present + const expectedCredentials = Buffer.from('test-client-id:test-client-secret').toString('base64'); + expect((call?.options.headers as Record)?.['authorization']).toBe(`Basic ${expectedCredentials}`); + const parsedBody = JSON.parse(call?.options.body as string); expect(parsedBody.sourceAccountId).toEqual(paymentRequestParams.sourceAccountId); expect(parsedBody.destinationAccountId).toEqual(paymentRequestParams.destinationAccountId); expect(parsedBody.options).toBeDefined(); }); - it('should make payment request without authorization headers', async () => { - const mock = fetchMock.createInstance(); - mock.post('https://auth.atxp.ai/payment-request', { - status: 200, - body: { id: 'test-payment-request-id' } - }); - - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); - - await server.createPaymentRequest(TH.charge({ - sourceAccountId: 'solana:test-source', - destinationAccountId: 'solana:test-destination' - })); - - // Verify no authorization header is included - const call = mock.callHistory.lastCall('https://auth.atxp.ai/payment-request'); - expect((call?.options?.headers as any)?.['authorization']).toBeUndefined(); - }); - it('should handle charge endpoint returning 402 status (payment required)', async () => { const mock = fetchMock.createInstance(); mock.post('https://auth.atxp.ai/charge', { @@ -121,7 +113,8 @@ describe('ATXPPaymentServer', () => { } }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); const result = await server.charge(TH.charge({ sourceAccountId: 'solana:test-source', @@ -139,7 +132,8 @@ describe('ATXPPaymentServer', () => { body: { error: 'server error' } }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); await expect(server.charge(TH.charge({ sourceAccountId: 'solana:test-source', @@ -154,7 +148,8 @@ describe('ATXPPaymentServer', () => { body: { error: 'bad request' } }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); await expect(server.createPaymentRequest(TH.charge({ sourceAccountId: 'solana:test-source', @@ -169,7 +164,8 @@ describe('ATXPPaymentServer', () => { body: { success: true } // Missing 'id' field }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); await expect(server.createPaymentRequest(TH.charge({ sourceAccountId: 'solana:test-source', diff --git a/packages/atxp-server/src/paymentServer.ts b/packages/atxp-server/src/paymentServer.ts index a1778af..7c41dd7 100644 --- a/packages/atxp-server/src/paymentServer.ts +++ b/packages/atxp-server/src/paymentServer.ts @@ -1,5 +1,5 @@ import { PaymentServer, Charge } from "./types.js"; -import { AuthorizationServerUrl, FetchLike, Logger, OAuthDb } from "@atxp/common"; +import { AuthorizationServerUrl, FetchLike, Logger, OAuthDb, MemoryOAuthDb } from "@atxp/common"; /** * Expected error response format from ATXP payment server @@ -29,11 +29,15 @@ interface PaymentServerErrorResponse { * ``` */ export class ATXPPaymentServer implements PaymentServer { + private readonly oAuthDb: OAuthDb; + constructor( private readonly server: AuthorizationServerUrl, private readonly logger: Logger, private readonly fetchFn: FetchLike = fetch.bind(globalThis), - private readonly oAuthDb?: OAuthDb) { + oAuthDb?: OAuthDb) { + // Default to MemoryOAuthDb if not provided + this.oAuthDb = oAuthDb ?? new MemoryOAuthDb(); } charge = async(chargeRequest: Charge): Promise => { @@ -131,20 +135,18 @@ export class ATXPPaymentServer implements PaymentServer { 'Content-Type': 'application/json' }; - // Add Basic auth header with client credentials if available + // Add Basic auth header with client credentials // This authenticates the MCP server to enable resource_url validation - if (this.oAuthDb) { - try { - const credentials = await this.oAuthDb.getClientCredentials(this.server); - if (credentials?.clientId && credentials?.clientSecret) { - const credentialString = `${credentials.clientId}:${credentials.clientSecret}`; - const base64Credentials = Buffer.from(credentialString).toString('base64'); - headers['Authorization'] = `Basic ${base64Credentials}`; - } - } catch (error) { - this.logger.warn('Failed to get client credentials for /charge authentication', error); - } + const credentials = await this.oAuthDb.getClientCredentials(this.server); + if (!credentials?.clientId || !credentials?.clientSecret) { + throw new Error( + `Missing client credentials for authorization server ${this.server}. ` + + `Ensure the MCP server has been registered and credentials are stored in the OAuthDb.` + ); } + const credentialString = `${credentials.clientId}:${credentials.clientSecret}`; + const base64Credentials = Buffer.from(credentialString).toString('base64'); + headers['Authorization'] = `Basic ${base64Credentials}`; const response = await this.fetchFn(url, { method, From fce85d01aed9e3ca29e043dc023196dc8710a38b Mon Sep 17 00:00:00 2001 From: bdj Date: Tue, 13 Jan 2026 16:07:04 -0800 Subject: [PATCH 08/10] Revert atxp-client changes - spend permissions are ATXPAccount implementation detail The security fix (Fix 1: validate resource_url on /charge) only requires changes to ATXPPaymentServer to send client credentials. The atxp-client (atxpFetcher) should not be creating spend permissions - that's an implementation detail of ATXPAccounts, not something generic clients should handle. This reverts all atxp-client changes, keeping only the atxp-server changes. Co-Authored-By: Claude Opus 4.5 --- packages/atxp-client/src/atxpClient.ts | 17 +- .../src/atxpFetcher.scopedSpend.test.ts | 260 ------------------ packages/atxp-client/src/atxpFetcher.ts | 107 +------ packages/atxp-client/src/types.ts | 8 +- 4 files changed, 18 insertions(+), 374 deletions(-) delete mode 100644 packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts diff --git a/packages/atxp-client/src/atxpClient.ts b/packages/atxp-client/src/atxpClient.ts index 82b1bc8..9e6554f 100644 --- a/packages/atxp-client/src/atxpClient.ts +++ b/packages/atxp-client/src/atxpClient.ts @@ -6,9 +6,8 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { createDestinationMakers } from './destinationMakers/index.js'; import { DEFAULT_ATXP_ACCOUNTS_SERVER, ATXPAccount } from "@atxp/common"; -type RequiredClientConfigFields = 'account'; +type RequiredClientConfigFields = 'mcpServer' | 'account'; type OptionalClientConfig = Omit; -// BuildableClientConfigFields are excluded from DEFAULT_CLIENT_CONFIG - they're either truly optional or built at runtime type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers'; // Detect if we're in a browser environment and bind fetch appropriately @@ -23,6 +22,7 @@ const getFetch = (): typeof fetch => { export const DEFAULT_CLIENT_CONFIG: Required> = { allowedAuthorizationServers: [DEFAULT_AUTHORIZATION_SERVER], + atxpAccountsServer: DEFAULT_ATXP_ACCOUNTS_SERVER, approvePayment: async (_p) => true, fetchFn: getFetch(), oAuthChannelFetch: getFetch(), @@ -57,16 +57,18 @@ export function buildClientConfig(args: ClientArgs): ClientConfig { const fetchFn = withDefaults.fetchFn; // Build destination makers if not provided - // Derive accounts server from account's origin if available - let accountsServer = DEFAULT_ATXP_ACCOUNTS_SERVER; - if (withDefaults.account && withDefaults.account instanceof ATXPAccount) { + let accountsServer = withDefaults.atxpAccountsServer; + // QoL hack for unspecified accounts server - if the caller is passing an atxpAccount, then assume the origin for that + // is what we should use for the accounts server. In practice, the only option is accounts.atxp.ai, + // but this supports staging environment + if (args.atxpAccountsServer === undefined && withDefaults.account && withDefaults.account instanceof ATXPAccount) { accountsServer = withDefaults.account.origin; } const destinationMakers = withDefaults.destinationMakers ?? createDestinationMakers({ atxpAccountsServer: accountsServer, fetchFn }); - + const built = { oAuthDb, logger, destinationMakers }; return Object.freeze({ ...withDefaults, ...built }); }; @@ -83,8 +85,7 @@ export function buildStreamableTransport(args: ClientArgs): StreamableHTTPClient export async function atxpClient(args: ClientArgs): Promise { const config = buildClientConfig(args); - // Pass args (not config) to buildStreamableTransport since it needs mcpServer - const transport = buildStreamableTransport(args); + const transport = buildStreamableTransport(config); const client = new Client(config.clientInfo, config.clientOptions); await client.connect(transport); diff --git a/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts deleted file mode 100644 index 1bba406..0000000 --- a/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { MemoryOAuthDb, Account, DEFAULT_AUTHORIZATION_SERVER } from '@atxp/common'; -import { describe, it, expect, vi } from 'vitest'; -import fetchMock from 'fetch-mock'; -import { mockResourceServer, mockAuthorizationServer } from './clientTestHelpers.js'; -import { ATXPFetcher } from './atxpFetcher.js'; -import { OAuthDb, FetchLike } from '@atxp/common'; -import { PaymentMaker } from './types.js'; - -function mockPaymentMakers(solanaPaymentMaker?: PaymentMaker) { - solanaPaymentMaker = solanaPaymentMaker ?? { - makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), - generateJWT: vi.fn().mockResolvedValue('testJWT'), - getSourceAddress: vi.fn().mockReturnValue('SolAddress123') - }; - return [solanaPaymentMaker]; -} - -function atxpFetcher( - fetchFn: FetchLike, - paymentMakers?: PaymentMaker[], - db?: OAuthDb, - options?: { - accountsServer?: string; - } -) { - // Create account with optional token and origin for spend permission tests - // origin is derived from the connection string in ATXPAccount - const account: Account & { token?: string; origin?: string } = { - getAccountId: async () => "bdj" as any, - paymentMakers: paymentMakers ?? mockPaymentMakers(), - getSources: async () => [{ - address: 'SolAddress123', - chain: 'solana' as any, - walletType: 'eoa' as any - }], - token: options?.accountsServer ? 'test_connection_token' : undefined, - origin: options?.accountsServer - }; - - return new ATXPFetcher({ - account, - db: db ?? new MemoryOAuthDb(), - destinationMakers: new Map(), - fetchFn - }); -} - -describe('atxpFetcher scoped spend token', () => { - it('should use standard auth flow without spend permission when account has no origin/token', async () => { - const f = fetchMock.createInstance(); - mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) - .postOnce('https://example.com/mcp', 401) - .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); - mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) - .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { - const state = new URL(req.args[0] as any).searchParams.get('state'); - return { - status: 301, - headers: {location: `https://atxp.ai?state=${state}&code=testCode`} - }; - }); - - const paymentMaker = { - makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), - generateJWT: vi.fn().mockResolvedValue('standardJWT'), - getSourceAddress: vi.fn().mockReturnValue('SolAddress123') - }; - - // No accountsServer configured - account has no origin/token - const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker]); - await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - - // Should use local generateJWT - expect(paymentMaker.generateJWT).toHaveBeenCalled(); - - // Should have resource parameter (from OAuth error's resourceServerUrl) - const authCalls = f.callHistory.callLogs.filter(call => - call.url.includes('/authorize') - ); - expect(authCalls.length).toBeGreaterThan(0); - // Resource URL should be present (derived from the request) - expect(authCalls[0].url).toContain('resource='); - // But no spend_permission_token since account has no origin/token - expect(authCalls[0].url).not.toContain('spend_permission_token='); - }); - - it('should call spend-permission endpoint when account has origin and token', async () => { - const f = fetchMock.createInstance(); - - // Mock the resource server - mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) - .postOnce('https://example.com/mcp', 401) - .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); - - // Mock auth server with all required endpoints - mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) - .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { - const state = new URL(req.args[0] as any).searchParams.get('state'); - return { - status: 301, - headers: {location: `https://atxp.ai?state=${state}&code=testCode`} - }; - }); - - // Mock accounts /spend-permission endpoint - f.post('https://accounts.atxp.ai/spend-permission', { - spendPermissionToken: 'spendPermissionTokenXYZ' - }); - - const paymentMaker = { - makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), - generateJWT: vi.fn().mockResolvedValue('localJWT'), - getSourceAddress: vi.fn().mockReturnValue('SolAddress123') - }; - - // Account has origin and token configured - const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { - accountsServer: 'https://accounts.atxp.ai' - }); - - await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - - // Should still use local generateJWT for the authorize request - expect(paymentMaker.generateJWT).toHaveBeenCalled(); - - // Should have called spend-permission endpoint - const spendPermissionCalls = f.callHistory.callLogs.filter(call => - call.url === 'https://accounts.atxp.ai/spend-permission' - ); - expect(spendPermissionCalls.length).toBe(1); - - // Verify the spend-permission request body contains resource URL from OAuth error - const spendPermissionBody = JSON.parse(spendPermissionCalls[0].options?.body as string); - expect(spendPermissionBody.resourceUrl).toBeDefined(); - - // Should have passed spend_permission_token to authorize - const authCalls = f.callHistory.callLogs.filter(call => - call.url.includes('/authorize') - ); - expect(authCalls.length).toBeGreaterThan(0); - const authUrl = authCalls[0].url; - expect(authUrl).toContain('spend_permission_token=spendPermissionTokenXYZ'); - expect(authUrl).toContain('resource='); - }); - - it('should gracefully continue when spend-permission endpoint fails', async () => { - const f = fetchMock.createInstance(); - - mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) - .postOnce('https://example.com/mcp', 401) - .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); - - // Mock auth server with all required endpoints - mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) - .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { - const state = new URL(req.args[0] as any).searchParams.get('state'); - return { - status: 301, - headers: {location: `https://atxp.ai?state=${state}&code=testCode`} - }; - }); - - // Mock accounts /spend-permission endpoint - return error - f.post('https://accounts.atxp.ai/spend-permission', { - status: 500, - body: JSON.stringify({ error: 'Internal server error' }) - }); - - const paymentMaker = { - makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), - generateJWT: vi.fn().mockResolvedValue('localJWT'), - getSourceAddress: vi.fn().mockReturnValue('SolAddress123') - }; - - const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { - accountsServer: 'https://accounts.atxp.ai' - }); - - // Should not throw - should gracefully continue without spend permission token - const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - expect(response).toBeDefined(); - - // Should have attempted spend-permission endpoint - const spendPermissionCalls = f.callHistory.callLogs.filter(call => - call.url === 'https://accounts.atxp.ai/spend-permission' - ); - expect(spendPermissionCalls.length).toBe(1); - - // Should still have made authorize call (without spend_permission_token) - const authCalls = f.callHistory.callLogs.filter(call => - call.url.includes('/authorize') - ); - expect(authCalls.length).toBeGreaterThan(0); - // Should NOT have spend_permission_token since it failed - expect(authCalls[0].url).not.toContain('spend_permission_token='); - }); - - it('should skip spend-permission when no connection token available', async () => { - const f = fetchMock.createInstance(); - - mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) - .postOnce('https://example.com/mcp', 401) - .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); - - // Mock auth server with all required endpoints - mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) - .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { - const state = new URL(req.args[0] as any).searchParams.get('state'); - return { - status: 301, - headers: {location: `https://atxp.ai?state=${state}&code=testCode`} - }; - }); - - const paymentMaker = { - makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), - generateJWT: vi.fn().mockResolvedValue('localJWT'), - getSourceAddress: vi.fn().mockReturnValue('SolAddress123') - }; - - // Create account with origin but WITHOUT token property - tests that - // spend-permission is skipped when there's no connection token even if origin is set - const account: Account & { origin?: string } = { - getAccountId: async () => "bdj" as any, - paymentMakers: [paymentMaker], - getSources: async () => [{ - address: 'SolAddress123', - chain: 'solana' as any, - walletType: 'eoa' as any - }], - origin: 'https://accounts.atxp.ai' - }; - - const fetcher = new ATXPFetcher({ - account, - db: new MemoryOAuthDb(), - destinationMakers: new Map(), - fetchFn: f.fetchHandler - }); - - const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); - expect(response).toBeDefined(); - - // Should NOT have called spend-permission endpoint (no token) - const spendPermissionCalls = f.callHistory.callLogs.filter(call => - call.url === 'https://accounts.atxp.ai/spend-permission' - ); - expect(spendPermissionCalls.length).toBe(0); - - // Should still have made authorize call with resource parameter - const authCalls = f.callHistory.callLogs.filter(call => - call.url.includes('/authorize') - ); - expect(authCalls.length).toBeGreaterThan(0); - // Should NOT have spend_permission_token since we didn't call the endpoint - expect(authCalls[0].url).not.toContain('spend_permission_token='); - // Should still have resource parameter from OAuth error - expect(authCalls[0].url).toContain('resource='); - }); -}); diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index 2bd148e..ec6320d 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -400,85 +400,7 @@ export class ATXPFetcher { return this.allowedAuthorizationServers.includes(baseUrl); } - /** - * Gets the connection token from the account if available. - * Uses duck typing to check if the account has a token property (like ATXPAccount). - */ - protected getAccountConnectionToken(): string | null { - // Check if account has a token property (duck typing for ATXPAccount) - const accountWithToken = this.account as { token?: string }; - if (typeof accountWithToken.token === 'string' && accountWithToken.token.length > 0) { - return accountWithToken.token; - } - return null; - } - - /** - * Gets the origin URL from the account if available. - * Uses duck typing to check if the account has an origin property (like ATXPAccount). - * This is the accounts service URL derived from the connection string. - */ - protected getAccountOrigin(): string | null { - // Check if account has an origin property (duck typing for ATXPAccount) - const accountWithOrigin = this.account as { origin?: string }; - if (typeof accountWithOrigin.origin === 'string' && accountWithOrigin.origin.length > 0) { - return accountWithOrigin.origin; - } - return null; - } - - /** - * Creates a spend permission with the accounts service for the given resource URL. - * Returns the spend_permission_token to pass to auth. - */ - protected createSpendPermission = async (resourceUrl: string): Promise => { - // Get accounts server URL from account's origin (derived from connection string) - const accountsServer = this.getAccountOrigin(); - if (!accountsServer) { - this.logger.debug(`ATXP: No accounts server available from account, skipping spend permission creation`); - return null; - } - - // Get connection token from account for authenticating to accounts service - const connectionToken = this.getAccountConnectionToken(); - if (!connectionToken) { - this.logger.debug(`ATXP: No connection token available, skipping spend permission creation`); - return null; - } - - try { - const spendPermissionUrl = `${accountsServer}/spend-permission`; - this.logger.debug(`ATXP: Creating spend permission at ${spendPermissionUrl} for resource ${resourceUrl}`); - - const response = await this.sideChannelFetch(spendPermissionUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${connectionToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ resourceUrl }) - }); - - if (!response.ok) { - this.logger.warn(`ATXP: Failed to create spend permission: ${response.status} ${response.statusText}`); - return null; - } - - const data = await response.json() as { spendPermissionToken?: string }; - if (data.spendPermissionToken) { - this.logger.info(`ATXP: Created spend permission for resource ${resourceUrl}`); - return data.spendPermissionToken; - } - - this.logger.warn(`ATXP: Spend permission response missing token`); - return null; - } catch (error) { - this.logger.warn(`ATXP: Error creating spend permission: ${error instanceof Error ? error.message : 'Unknown error'}`); - return null; - } - } - - protected makeAuthRequestWithPaymentMaker = async (authorizationUrl: URL, paymentMaker: PaymentMaker, resourceUrl?: string): Promise => { + protected makeAuthRequestWithPaymentMaker = async (authorizationUrl: URL, paymentMaker: PaymentMaker): Promise => { const codeChallenge = authorizationUrl.searchParams.get('code_challenge'); if (!codeChallenge) { throw new Error(`Code challenge not provided`); @@ -496,43 +418,25 @@ export class ATXPFetcher { } const accountId = await this.account.getAccountId(); - - // Generate JWT locally via paymentMaker const authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId}); - // Build the authorization URL with resource parameter - // The resource parameter identifies the resource server URL for spend permission scoping - let finalAuthUrl = authorizationUrl.toString() + '&redirect=false'; - - // If we have a resource URL, create a spend permission first - let spendPermissionToken: string | null = null; - if (resourceUrl) { - finalAuthUrl += '&resource=' + encodeURIComponent(resourceUrl); - - // Create spend permission with accounts service using connection token - spendPermissionToken = await this.createSpendPermission(resourceUrl); - if (spendPermissionToken) { - finalAuthUrl += '&spend_permission_token=' + encodeURIComponent(spendPermissionToken); - } - } - // Make a fetch call to the authorization URL with the payment ID // redirect=false is a hack // The OAuth spec calls for the authorization url to return with a redirect, but fetch // on mobile will automatically follow the redirect (it doesn't support the redirect=manual option) - // We want the redirect URL so we can extract the code from it, not the contents of the + // We want the redirect URL so we can extract the code from it, not the contents of the // redirect URL (which might not even exist for agentic ATXP clients) // So ATXP servers are set up to instead return a 200 with the redirect URL in the body // if we pass redirect=false. // TODO: Remove the redirect=false hack once we have a way to handle the redirect on mobile - const response = await this.sideChannelFetch(finalAuthUrl, { + const response = await this.sideChannelFetch(authorizationUrl.toString()+'&redirect=false', { method: 'GET', redirect: 'manual', headers: { 'Authorization': `Bearer ${authToken}` } }); - // Check if we got a redirect response (301, 302, etc.) in case the server follows + // Check if we got a redirect response (301, 302, etc.) in case the server follows // the OAuth spec if (response.status >= 300 && response.status < 400) { const location = response.headers.get('Location'); @@ -581,8 +485,7 @@ export class ATXPFetcher { } try { - // Pass the resource server URL for spend permission scoping - const redirectUrl = await this.makeAuthRequestWithPaymentMaker(authorizationUrl, paymentMaker, error.resourceServerUrl); + const redirectUrl = await this.makeAuthRequestWithPaymentMaker(authorizationUrl, paymentMaker); // Handle the OAuth callback const oauthClient = await this.getOAuthClient(); await oauthClient.handleCallback(redirectUrl); diff --git a/packages/atxp-client/src/types.ts b/packages/atxp-client/src/types.ts index e916569..c5040c2 100644 --- a/packages/atxp-client/src/types.ts +++ b/packages/atxp-client/src/types.ts @@ -37,7 +37,9 @@ export interface PaymentFailureContext { } export type ClientConfig = { + mcpServer: string; account: Account; + atxpAccountsServer: string; destinationMakers: Map; allowedAuthorizationServers: AuthorizationServerUrl[]; approvePayment: (payment: ProspectivePayment) => Promise; @@ -57,12 +59,10 @@ export type ClientConfig = { } // ClientArgs for creating clients - required fields plus optional overrides -// mcpServer is required for atxpClient to know which MCP server to connect to -// but it's not part of ClientConfig since the fetcher derives resource URLs from OAuth errors -type RequiredClientConfigFields = 'account'; +type RequiredClientConfigFields = 'mcpServer' | 'account'; type RequiredClientConfig = Pick; type OptionalClientConfig = Omit; -export type ClientArgs = RequiredClientConfig & Partial & { mcpServer: string }; +export type ClientArgs = RequiredClientConfig & Partial; // Type for a fetch wrapper function that takes ClientArgs and returns wrapped fetch export type FetchWrapper = (config: ClientArgs) => FetchLike; From 874abc082e4cb0dd41890e3a484ae477614ba116 Mon Sep 17 00:00:00 2001 From: bdj Date: Tue, 13 Jan 2026 16:21:16 -0800 Subject: [PATCH 09/10] feat: Add spend permission creation for ATXPAccounts during OAuth ATXPAccount now creates spend permissions during the OAuth flow: 1. Added `createSpendPermission(resourceUrl)` method to ATXPAccount - Calls POST /spend-permission on accounts server - Returns spend permission token for authorization 2. Updated OAuthClient.makeAuthorizationUrl to accept optional spendPermissionToken - Adds spend_permission_token param to authorization URL - Also adds resource param for the MCP server URL 3. Updated ATXPFetcher.authToService to detect ATXPAccounts - Uses duck typing to check for createSpendPermission method - Creates spend permission before authorization if available - Continues auth flow even if spend permission creation fails This is an ATXPAccount-specific feature - other account types (SolanaAccount, BaseAccount, etc.) are unaffected as they don't have createSpendPermission. Co-Authored-By: Claude Opus 4.5 --- .../atxp-client/src/atxpFetcher.oauth.test.ts | 154 ++++++++++++++++++ packages/atxp-client/src/atxpFetcher.ts | 25 ++- packages/atxp-client/src/oAuth.ts | 8 +- packages/atxp-common/src/atxpAccount.test.ts | 125 ++++++++++++++ packages/atxp-common/src/atxpAccount.ts | 31 ++++ 5 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 packages/atxp-common/src/atxpAccount.test.ts diff --git a/packages/atxp-client/src/atxpFetcher.oauth.test.ts b/packages/atxp-client/src/atxpFetcher.oauth.test.ts index 4f942f2..4f5f459 100644 --- a/packages/atxp-client/src/atxpFetcher.oauth.test.ts +++ b/packages/atxp-client/src/atxpFetcher.oauth.test.ts @@ -208,4 +208,158 @@ describe('atxpFetcher.fetch oauth', () => { const fetcher = atxpFetcher(f.fetchHandler, [brokenPaymentMaker]); await expect(fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) })).rejects.toThrow('Payment maker is missing generateJWT method'); }); + + it('should call createSpendPermission and include token in auth URL for ATXP accounts', async () => { + const f = fetchMock.createInstance(); + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as string).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('testJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + // Mock an ATXP-style account with createSpendPermission method + const createSpendPermission = vi.fn().mockResolvedValue('spt_test123'); + const atxpAccount: Account & { createSpendPermission: typeof createSpendPermission } = { + getAccountId: async () => "bdj" as any, + paymentMakers: [paymentMaker], + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }], + createSpendPermission + }; + + const fetcher = new ATXPFetcher({ + account: atxpAccount, + db: new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn: f.fetchHandler + }); + + await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + + // Verify createSpendPermission was called with the resource URL + expect(createSpendPermission).toHaveBeenCalledWith('https://example.com/mcp'); + + // Verify the authorization URL includes the spend_permission_token + const authCall = f.callHistory.lastCall(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`); + expect(authCall).toBeDefined(); + const authUrl = new URL(authCall!.args[0] as string); + expect(authUrl.searchParams.get('spend_permission_token')).toBe('spt_test123'); + // Also verify resource is set + expect(authUrl.searchParams.get('resource')).toBe('https://example.com/mcp'); + }); + + it('should continue auth flow if createSpendPermission fails', async () => { + const f = fetchMock.createInstance(); + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as string).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('testJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + // Mock an account where createSpendPermission fails + const createSpendPermission = vi.fn().mockRejectedValue(new Error('Network error')); + const atxpAccount: Account & { createSpendPermission: typeof createSpendPermission } = { + getAccountId: async () => "bdj" as any, + paymentMakers: [paymentMaker], + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }], + createSpendPermission + }; + + const fetcher = new ATXPFetcher({ + account: atxpAccount, + db: new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn: f.fetchHandler + }); + + // Should not throw - auth flow should continue without spend permission + const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + expect(response.ok).toBe(true); + + // Verify createSpendPermission was called + expect(createSpendPermission).toHaveBeenCalled(); + + // Verify the authorization URL does NOT include spend_permission_token (since it failed) + const authCall = f.callHistory.lastCall(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`); + const authUrl = new URL(authCall!.args[0] as string); + expect(authUrl.searchParams.get('spend_permission_token')).toBeNull(); + }); + + it('should not call createSpendPermission for regular accounts without the method', async () => { + const f = fetchMock.createInstance(); + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as string).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('testJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + // Regular account without createSpendPermission + const regularAccount: Account = { + getAccountId: async () => "bdj" as any, + paymentMakers: [paymentMaker], + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }] + }; + + const fetcher = new ATXPFetcher({ + account: regularAccount, + db: new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn: f.fetchHandler + }); + + const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + expect(response.ok).toBe(true); + + // Verify the authorization URL does NOT include spend_permission_token + const authCall = f.callHistory.lastCall(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`); + const authUrl = new URL(authCall!.args[0] as string); + expect(authUrl.searchParams.get('spend_permission_token')).toBeNull(); + }); }); diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index ec6320d..cecf928 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -400,6 +400,13 @@ export class ATXPFetcher { return this.allowedAuthorizationServers.includes(baseUrl); } + /** + * Type guard to check if account has createSpendPermission method (ATXPAccount-specific) + */ + protected hasCreateSpendPermission = (account: Account): account is Account & { createSpendPermission: (resourceUrl: string) => Promise } => { + return typeof (account as { createSpendPermission?: unknown }).createSpendPermission === 'function'; + } + protected makeAuthRequestWithPaymentMaker = async (authorizationUrl: URL, paymentMaker: PaymentMaker): Promise => { const codeChallenge = authorizationUrl.searchParams.get('code_challenge'); if (!codeChallenge) { @@ -475,9 +482,25 @@ export class ATXPFetcher { // We can do the full OAuth flow - we'll generate a signed JWT and call /authorize on the // AS to get a code, then exchange the code for an access token const oauthClient = await this.getOAuthClient(); + + // For ATXP accounts, create a spend permission before authorization + // This is an ATXP-specific feature that allows pre-authorizing spending for MCP servers + let spendPermissionToken: string | undefined; + if (this.hasCreateSpendPermission(this.account)) { + try { + this.logger.info(`Creating spend permission for resource ${error.resourceServerUrl}`); + spendPermissionToken = await this.account.createSpendPermission(error.resourceServerUrl); + this.logger.debug(`Created spend permission token: ${spendPermissionToken.substring(0, 8)}...`); + } catch (spendPermissionError) { + // Log but don't fail - authorization can still proceed without spend permission + this.logger.warn(`Failed to create spend permission: ${(spendPermissionError as Error).message}`); + } + } + const authorizationUrl = await oauthClient.makeAuthorizationUrl( error.url, - error.resourceServerUrl + error.resourceServerUrl, + { spendPermissionToken } ); if (!this.isAllowedAuthServer(authorizationUrl)) { diff --git a/packages/atxp-client/src/oAuth.ts b/packages/atxp-client/src/oAuth.ts index 6d70f14..ca3026a 100644 --- a/packages/atxp-client/src/oAuth.ts +++ b/packages/atxp-client/src/oAuth.ts @@ -142,7 +142,7 @@ export class OAuthClient extends OAuthResourceClient { return response; } - makeAuthorizationUrl = async (url: string, resourceUrl: string): Promise => { + makeAuthorizationUrl = async (url: string, resourceUrl: string, options?: { spendPermissionToken?: string }): Promise => { resourceUrl = this.normalizeResourceServerUrl(resourceUrl); const authorizationServer = await this.getAuthorizationServer(resourceUrl); const credentials = await this.getClientCredentials(authorizationServer); @@ -154,6 +154,12 @@ export class OAuthClient extends OAuthResourceClient { authorizationUrl.searchParams.set('code_challenge', pkceValues.codeChallenge); authorizationUrl.searchParams.set('code_challenge_method', 'S256'); authorizationUrl.searchParams.set('state', pkceValues.state); + // Add resource URL so auth server knows which MCP server this is for + authorizationUrl.searchParams.set('resource', resourceUrl); + // Add spend permission token if provided (for ATXP accounts with scoped spend permissions) + if (options?.spendPermissionToken) { + authorizationUrl.searchParams.set('spend_permission_token', options.spendPermissionToken); + } return authorizationUrl; } diff --git a/packages/atxp-common/src/atxpAccount.test.ts b/packages/atxp-common/src/atxpAccount.test.ts new file mode 100644 index 0000000..dd860f6 --- /dev/null +++ b/packages/atxp-common/src/atxpAccount.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ATXPAccount } from './atxpAccount.js'; + +describe('ATXPAccount', () => { + describe('constructor', () => { + it('should parse connection string with token and account_id', () => { + const connectionString = 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz'; + const account = new ATXPAccount(connectionString); + expect(account.origin).toBe('https://accounts.example.com'); + expect(account.token).toBe('ct_abc123'); + }); + + it('should throw if connection string is empty', () => { + expect(() => new ATXPAccount('')).toThrow('connection string is empty'); + }); + + it('should throw if connection token is missing', () => { + expect(() => new ATXPAccount('https://accounts.example.com')).toThrow('missing connection token'); + }); + }); + + describe('createSpendPermission', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + }); + + it('should call /spend-permission with Bearer auth and return token', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ spendPermissionToken: 'spt_test123' }), + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz', + { fetchFn: mockFetch } + ); + + const token = await account.createSpendPermission('https://my-mcp-server.com'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://accounts.example.com/spend-permission', + { + method: 'POST', + headers: { + 'Authorization': 'Bearer ct_abc123', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ resourceUrl: 'https://my-mcp-server.com' }), + } + ); + expect(token).toBe('spt_test123'); + }); + + it('should throw if response is not ok', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'Invalid resourceUrl', + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz', + { fetchFn: mockFetch } + ); + + await expect(account.createSpendPermission('invalid-url')).rejects.toThrow( + '/spend-permission failed: 400 Bad Request' + ); + }); + + it('should throw if response does not contain spendPermissionToken', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ someOtherField: 'value' }), + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz', + { fetchFn: mockFetch } + ); + + await expect(account.createSpendPermission('https://mcp.example.com')).rejects.toThrow( + 'did not return spendPermissionToken' + ); + }); + }); + + describe('getAccountId', () => { + it('should return cached account ID from connection string', async () => { + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz' + ); + const accountId = await account.getAccountId(); + expect(accountId).toBe('atxp:atxp_acct_xyz'); + }); + + it('should fetch account ID from /me if not in connection string', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ accountId: 'atxp_acct_fetched' }), + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123', + { fetchFn: mockFetch } + ); + + const accountId = await account.getAccountId(); + expect(accountId).toBe('atxp:atxp_acct_fetched'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://accounts.example.com/me', + expect.objectContaining({ + method: 'GET', + headers: { + 'Authorization': 'Bearer ct_abc123', + 'Accept': 'application/json', + }, + }) + ); + }); + }); +}); diff --git a/packages/atxp-common/src/atxpAccount.ts b/packages/atxp-common/src/atxpAccount.ts index b31f4fd..e39b83a 100644 --- a/packages/atxp-common/src/atxpAccount.ts +++ b/packages/atxp-common/src/atxpAccount.ts @@ -255,4 +255,35 @@ export class ATXPAccount implements Account { return json; } + + /** + * Create a spend permission for the given resource URL. + * This is an ATXP-specific feature that allows pre-authorizing spending + * for a specific MCP server during OAuth authorization. + * + * @param resourceUrl - The MCP server URL to create a spend permission for + * @returns The spend permission token to pass to the authorization URL + */ + async createSpendPermission(resourceUrl: string): Promise { + const response = await this.fetchFn(`${this.origin}/spend-permission`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ resourceUrl }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`ATXPAccount: /spend-permission failed: ${response.status} ${response.statusText} ${text}`); + } + + const json = await response.json() as { spendPermissionToken?: string }; + if (!json?.spendPermissionToken) { + throw new Error('ATXPAccount: /spend-permission did not return spendPermissionToken'); + } + + return json.spendPermissionToken; + } } From c503456ba3208a2e0f3395c7f9d12f6a616f0298 Mon Sep 17 00:00:00 2001 From: bdj Date: Wed, 14 Jan 2026 08:49:37 -0800 Subject: [PATCH 10/10] Add createSpendPermission to Account interface - Added createSpendPermission method to Account type in common - Implemented in all account types (returns null for non-ATXP accounts) - Removed type guard hasCreateSpendPermission from atxpFetcher - Simplified OAuth flow to call createSpendPermission directly This provides a clean interface for future account types to support spend permissions without duck-typing. Co-Authored-By: Claude Opus 4.5 --- packages/atxp-base/src/baseAccount.ts | 8 +++++ packages/atxp-base/src/baseAppAccount.ts | 8 +++++ .../src/atxpClient.buildConfig.test.ts | 3 +- .../atxp-client/src/atxpClient.events.test.ts | 12 ++++--- packages/atxp-client/src/atxpClient.test.ts | 9 ++++-- .../atxp-client/src/atxpFetcher.oauth.test.ts | 16 +++++++--- .../src/atxpFetcher.payment.test.ts | 3 +- packages/atxp-client/src/atxpFetcher.ts | 32 ++++++++----------- .../src/defaultPaymentFailureHandler.test.ts | 3 +- .../src/__tests__/atxpCloudflare.test.ts | 3 +- .../src/__tests__/buildConfig.test.ts | 3 +- .../src/__tests__/requirePayment.test.ts | 3 +- packages/atxp-common/src/types.ts | 8 +++++ .../atxp-polygon/src/polygonBrowserAccount.ts | 8 +++++ .../atxp-polygon/src/polygonServerAccount.ts | 8 +++++ packages/atxp-server/src/serverConfig.test.ts | 1 + packages/atxp-server/src/serverTestHelpers.ts | 1 + packages/atxp-solana/src/solanaAccount.ts | 8 +++++ .../atxp-worldchain/src/worldchainAccount.ts | 8 +++++ 19 files changed, 108 insertions(+), 37 deletions(-) diff --git a/packages/atxp-base/src/baseAccount.ts b/packages/atxp-base/src/baseAccount.ts index b471112..05c9ac9 100644 --- a/packages/atxp-base/src/baseAccount.ts +++ b/packages/atxp-base/src/baseAccount.ts @@ -58,4 +58,12 @@ export class BaseAccount implements Account { walletType: 'eoa' }]; } + + /** + * Create a spend permission for the given resource URL. + * Base accounts don't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } \ No newline at end of file diff --git a/packages/atxp-base/src/baseAppAccount.ts b/packages/atxp-base/src/baseAppAccount.ts index 6de8096..89b7de7 100644 --- a/packages/atxp-base/src/baseAppAccount.ts +++ b/packages/atxp-base/src/baseAppAccount.ts @@ -215,4 +215,12 @@ export class BaseAppAccount implements Account { cache.delete(this.toCacheKey(userWalletAddress)); } + + /** + * Create a spend permission for the given resource URL. + * BaseAppAccount doesn't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } \ No newline at end of file diff --git a/packages/atxp-client/src/atxpClient.buildConfig.test.ts b/packages/atxp-client/src/atxpClient.buildConfig.test.ts index f63ef5e..bc3ebf9 100644 --- a/packages/atxp-client/src/atxpClient.buildConfig.test.ts +++ b/packages/atxp-client/src/atxpClient.buildConfig.test.ts @@ -10,7 +10,8 @@ describe('buildConfig', () => { account: { getAccountId: async () => 'bdj' as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null } }); expect(config.oAuthChannelFetch).toBe(fetchFn); diff --git a/packages/atxp-client/src/atxpClient.events.test.ts b/packages/atxp-client/src/atxpClient.events.test.ts index 5b94c68..4d0520c 100644 --- a/packages/atxp-client/src/atxpClient.events.test.ts +++ b/packages/atxp-client/src/atxpClient.events.test.ts @@ -30,7 +30,8 @@ describe('atxpClient events', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', @@ -70,7 +71,8 @@ describe('atxpClient events', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; // The client initialization or callTool will throw an error due to OAuth failure @@ -114,7 +116,8 @@ describe('atxpClient events', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', @@ -160,7 +163,8 @@ describe('atxpClient events', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', diff --git a/packages/atxp-client/src/atxpClient.test.ts b/packages/atxp-client/src/atxpClient.test.ts index 4fd5eef..cd7e824 100644 --- a/packages/atxp-client/src/atxpClient.test.ts +++ b/packages/atxp-client/src/atxpClient.test.ts @@ -29,7 +29,8 @@ describe('atxpClient', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', @@ -66,7 +67,8 @@ describe('atxpClient', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', @@ -116,7 +118,8 @@ describe('atxpClient', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', diff --git a/packages/atxp-client/src/atxpFetcher.oauth.test.ts b/packages/atxp-client/src/atxpFetcher.oauth.test.ts index 4f5f459..551b952 100644 --- a/packages/atxp-client/src/atxpFetcher.oauth.test.ts +++ b/packages/atxp-client/src/atxpFetcher.oauth.test.ts @@ -24,7 +24,8 @@ function atxpFetcher(fetchFn: FetchLike, paymentMakers?: PaymentMaker[], db?: OA address: 'SolAddress123', chain: 'solana' as any, walletType: 'eoa' as any - }] + }], + createSpendPermission: async () => null }; return new ATXPFetcher({ @@ -316,7 +317,7 @@ describe('atxpFetcher.fetch oauth', () => { expect(authUrl.searchParams.get('spend_permission_token')).toBeNull(); }); - it('should not call createSpendPermission for regular accounts without the method', async () => { + it('should not include spend_permission_token for accounts that return null', async () => { const f = fetchMock.createInstance(); mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) .postOnce('https://example.com/mcp', 401) @@ -336,7 +337,8 @@ describe('atxpFetcher.fetch oauth', () => { getSourceAddress: vi.fn().mockReturnValue('SolAddress123') }; - // Regular account without createSpendPermission + // Account that returns null for createSpendPermission (e.g., SolanaAccount, BaseAccount) + const createSpendPermission = vi.fn().mockResolvedValue(null); const regularAccount: Account = { getAccountId: async () => "bdj" as any, paymentMakers: [paymentMaker], @@ -344,7 +346,8 @@ describe('atxpFetcher.fetch oauth', () => { address: 'SolAddress123', chain: 'solana' as any, walletType: 'eoa' as any - }] + }], + createSpendPermission }; const fetcher = new ATXPFetcher({ @@ -357,7 +360,10 @@ describe('atxpFetcher.fetch oauth', () => { const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); expect(response.ok).toBe(true); - // Verify the authorization URL does NOT include spend_permission_token + // Verify createSpendPermission was called + expect(createSpendPermission).toHaveBeenCalledWith('https://example.com/mcp'); + + // Verify the authorization URL does NOT include spend_permission_token (since it returned null) const authCall = f.callHistory.lastCall(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`); const authUrl = new URL(authCall!.args[0] as string); expect(authUrl.searchParams.get('spend_permission_token')).toBeNull(); diff --git a/packages/atxp-client/src/atxpFetcher.payment.test.ts b/packages/atxp-client/src/atxpFetcher.payment.test.ts index 789b154..a582f78 100644 --- a/packages/atxp-client/src/atxpFetcher.payment.test.ts +++ b/packages/atxp-client/src/atxpFetcher.payment.test.ts @@ -36,7 +36,8 @@ function atxpFetcher( address: 'SolAddress123', chain: 'solana' as any, walletType: 'eoa' as any - }] + }], + createSpendPermission: async () => null }; return new ATXPFetcher({ diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index cecf928..a4c634a 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -400,13 +400,6 @@ export class ATXPFetcher { return this.allowedAuthorizationServers.includes(baseUrl); } - /** - * Type guard to check if account has createSpendPermission method (ATXPAccount-specific) - */ - protected hasCreateSpendPermission = (account: Account): account is Account & { createSpendPermission: (resourceUrl: string) => Promise } => { - return typeof (account as { createSpendPermission?: unknown }).createSpendPermission === 'function'; - } - protected makeAuthRequestWithPaymentMaker = async (authorizationUrl: URL, paymentMaker: PaymentMaker): Promise => { const codeChallenge = authorizationUrl.searchParams.get('code_challenge'); if (!codeChallenge) { @@ -483,24 +476,25 @@ export class ATXPFetcher { // AS to get a code, then exchange the code for an access token const oauthClient = await this.getOAuthClient(); - // For ATXP accounts, create a spend permission before authorization - // This is an ATXP-specific feature that allows pre-authorizing spending for MCP servers - let spendPermissionToken: string | undefined; - if (this.hasCreateSpendPermission(this.account)) { - try { - this.logger.info(`Creating spend permission for resource ${error.resourceServerUrl}`); - spendPermissionToken = await this.account.createSpendPermission(error.resourceServerUrl); - this.logger.debug(`Created spend permission token: ${spendPermissionToken.substring(0, 8)}...`); - } catch (spendPermissionError) { - // Log but don't fail - authorization can still proceed without spend permission - this.logger.warn(`Failed to create spend permission: ${(spendPermissionError as Error).message}`); + // Try to create a spend permission before authorization + // This allows pre-authorizing spending for MCP servers (returns null for account types that don't support it) + let spendPermissionToken: string | null = null; + try { + const result = await this.account.createSpendPermission(error.resourceServerUrl); + if (result) { + this.logger.info(`Created spend permission for resource ${error.resourceServerUrl}`); + this.logger.debug(`Created spend permission token: ${result.substring(0, 8)}...`); + spendPermissionToken = result; } + } catch (spendPermissionError) { + // Log but don't fail - authorization can still proceed without spend permission + this.logger.warn(`Failed to create spend permission: ${(spendPermissionError as Error).message}`); } const authorizationUrl = await oauthClient.makeAuthorizationUrl( error.url, error.resourceServerUrl, - { spendPermissionToken } + spendPermissionToken ? { spendPermissionToken } : undefined ); if (!this.isAllowedAuthServer(authorizationUrl)) { diff --git a/packages/atxp-client/src/defaultPaymentFailureHandler.test.ts b/packages/atxp-client/src/defaultPaymentFailureHandler.test.ts index be3ea65..f3cbebe 100644 --- a/packages/atxp-client/src/defaultPaymentFailureHandler.test.ts +++ b/packages/atxp-client/src/defaultPaymentFailureHandler.test.ts @@ -42,7 +42,8 @@ describe('Default Payment Failure Handler', () => { const account = { getAccountId: async () => 'test-account' as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null }; const fetcher = new ATXPFetcher({ diff --git a/packages/atxp-cloudflare/src/__tests__/atxpCloudflare.test.ts b/packages/atxp-cloudflare/src/__tests__/atxpCloudflare.test.ts index 755cd72..7059ee7 100644 --- a/packages/atxp-cloudflare/src/__tests__/atxpCloudflare.test.ts +++ b/packages/atxp-cloudflare/src/__tests__/atxpCloudflare.test.ts @@ -37,7 +37,8 @@ function mockAccount(accountId: string): Account { return { getAccountId: async () => accountId as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null }; } diff --git a/packages/atxp-cloudflare/src/__tests__/buildConfig.test.ts b/packages/atxp-cloudflare/src/__tests__/buildConfig.test.ts index 6b86eeb..6653097 100644 --- a/packages/atxp-cloudflare/src/__tests__/buildConfig.test.ts +++ b/packages/atxp-cloudflare/src/__tests__/buildConfig.test.ts @@ -17,7 +17,8 @@ function mockAccount(accountId: string): Account { return { getAccountId: async () => accountId as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null }; } diff --git a/packages/atxp-cloudflare/src/__tests__/requirePayment.test.ts b/packages/atxp-cloudflare/src/__tests__/requirePayment.test.ts index bd1368f..55e8521 100644 --- a/packages/atxp-cloudflare/src/__tests__/requirePayment.test.ts +++ b/packages/atxp-cloudflare/src/__tests__/requirePayment.test.ts @@ -27,7 +27,8 @@ function mockAccount(accountId: string): Account { return { getAccountId: async () => accountId as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null }; } diff --git a/packages/atxp-common/src/types.ts b/packages/atxp-common/src/types.ts index 8232576..f2df6f0 100644 --- a/packages/atxp-common/src/types.ts +++ b/packages/atxp-common/src/types.ts @@ -175,6 +175,14 @@ export interface PaymentDestination { */ export type Account = PaymentDestination & { paymentMakers: PaymentMaker[]; + /** + * Create a spend permission for the given resource URL. + * This allows pre-authorizing spending for a specific MCP server during OAuth authorization. + * + * @param resourceUrl - The MCP server URL to create a spend permission for + * @returns The spend permission token, or null if this account type doesn't support spend permissions + */ + createSpendPermission: (resourceUrl: string) => Promise; } /** diff --git a/packages/atxp-polygon/src/polygonBrowserAccount.ts b/packages/atxp-polygon/src/polygonBrowserAccount.ts index 02b32d2..4d0682e 100644 --- a/packages/atxp-polygon/src/polygonBrowserAccount.ts +++ b/packages/atxp-polygon/src/polygonBrowserAccount.ts @@ -110,4 +110,12 @@ export class PolygonBrowserAccount implements Account { static clearAllCachedData(_userWalletAddress: string, _cache?: unknown): void { // No-op: Direct Wallet mode doesn't cache any data } + + /** + * Create a spend permission for the given resource URL. + * PolygonBrowserAccount doesn't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } diff --git a/packages/atxp-polygon/src/polygonServerAccount.ts b/packages/atxp-polygon/src/polygonServerAccount.ts index aa2f874..25dfa73 100644 --- a/packages/atxp-polygon/src/polygonServerAccount.ts +++ b/packages/atxp-polygon/src/polygonServerAccount.ts @@ -96,4 +96,12 @@ export class PolygonServerAccount implements Account { walletType: WalletTypeEnum.EOA }]; } + + /** + * Create a spend permission for the given resource URL. + * PolygonServerAccount doesn't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } diff --git a/packages/atxp-server/src/serverConfig.test.ts b/packages/atxp-server/src/serverConfig.test.ts index 3a12d24..101fd45 100644 --- a/packages/atxp-server/src/serverConfig.test.ts +++ b/packages/atxp-server/src/serverConfig.test.ts @@ -9,6 +9,7 @@ function mockAccount(accountId: string): Account { getAccountId: async () => `base:${accountId}` as any, paymentMakers: [], getSources: async () => [], + createSpendPermission: async () => null, }; } diff --git a/packages/atxp-server/src/serverTestHelpers.ts b/packages/atxp-server/src/serverTestHelpers.ts index 6c408b4..d303c89 100644 --- a/packages/atxp-server/src/serverTestHelpers.ts +++ b/packages/atxp-server/src/serverTestHelpers.ts @@ -21,6 +21,7 @@ export function mockAccount(accountId: string): Account { getAccountId: async () => formattedAccountId as any, paymentMakers: [], getSources: async () => [], + createSpendPermission: async () => null, }; } diff --git a/packages/atxp-solana/src/solanaAccount.ts b/packages/atxp-solana/src/solanaAccount.ts index 0eb3e1a..4f68f24 100644 --- a/packages/atxp-solana/src/solanaAccount.ts +++ b/packages/atxp-solana/src/solanaAccount.ts @@ -43,4 +43,12 @@ export class SolanaAccount implements Account { walletType: 'eoa' }]; } + + /** + * Create a spend permission for the given resource URL. + * Solana accounts don't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } \ No newline at end of file diff --git a/packages/atxp-worldchain/src/worldchainAccount.ts b/packages/atxp-worldchain/src/worldchainAccount.ts index 7582a05..194ec38 100644 --- a/packages/atxp-worldchain/src/worldchainAccount.ts +++ b/packages/atxp-worldchain/src/worldchainAccount.ts @@ -218,4 +218,12 @@ export class WorldchainAccount implements Account { cache.delete(this.toCacheKey(userWalletAddress)); } + + /** + * Create a spend permission for the given resource URL. + * WorldchainAccount doesn't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } \ No newline at end of file