Skip to content

Commit f39c3e4

Browse files
badjerclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 2174ea2 commit f39c3e4

5 files changed

Lines changed: 92 additions & 96 deletions

File tree

packages/atxp-client/src/atxpClient.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { DEFAULT_ATXP_ACCOUNTS_SERVER, ATXPAccount } from "@atxp/common";
99
type RequiredClientConfigFields = 'mcpServer' | 'account';
1010
type OptionalClientConfig = Omit<ClientConfig, RequiredClientConfigFields>;
1111
// BuildableClientConfigFields are excluded from DEFAULT_CLIENT_CONFIG - they're either truly optional or built at runtime
12-
type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers' | 'scopedSpendConfig';
12+
type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers';
1313

1414
// Detect if we're in a browser environment and bind fetch appropriately
1515
const getFetch = (): typeof fetch => {
@@ -69,8 +69,8 @@ export function buildClientConfig(args: ClientArgs): ClientConfig {
6969
atxpAccountsServer: accountsServer,
7070
fetchFn
7171
});
72-
73-
const built = { oAuthDb, logger, destinationMakers };
72+
73+
const built = { oAuthDb, logger, destinationMakers, atxpAccountsServer: accountsServer };
7474
return Object.freeze({ ...withDefaults, ...built });
7575
};
7676

packages/atxp-client/src/atxpFetcher.ts

Lines changed: 84 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
Account,
1919
getErrorRecoveryHint
2020
} from '@atxp/common';
21-
import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext, ScopedSpendConfig } from './types.js';
21+
import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext } from './types.js';
2222
import { InsufficientFundsError, ATXPPaymentError } from './errors.js';
2323
import { getIsReactNative, createReactNativeSafeFetch, Destination } from '@atxp/common';
2424
import { McpError } from '@modelcontextprotocol/sdk/types.js';
@@ -48,7 +48,7 @@ export function atxpFetch(config: ClientConfig): FetchLike {
4848
onPaymentFailure: config.onPaymentFailure,
4949
onPaymentAttemptFailed: config.onPaymentAttemptFailed,
5050
atxpAccountsServer: config.atxpAccountsServer,
51-
scopedSpendConfig: config.scopedSpendConfig
51+
mcpServer: config.mcpServer
5252
});
5353
return fetcher.fetch;
5454
}
@@ -71,7 +71,7 @@ export class ATXPFetcher {
7171
protected strict: boolean;
7272
protected allowInsecureRequests: boolean;
7373
protected atxpAccountsServer?: string;
74-
protected scopedSpendConfig?: ScopedSpendConfig;
74+
protected mcpServer?: string;
7575
constructor(config: {
7676
account: Account;
7777
db: OAuthDb;
@@ -89,7 +89,7 @@ export class ATXPFetcher {
8989
onPaymentFailure?: (context: PaymentFailureContext) => Promise<void>;
9090
onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise<void>;
9191
atxpAccountsServer?: string;
92-
scopedSpendConfig?: ScopedSpendConfig;
92+
mcpServer?: string;
9393
}) {
9494
const {
9595
account,
@@ -108,7 +108,7 @@ export class ATXPFetcher {
108108
onPaymentFailure,
109109
onPaymentAttemptFailed,
110110
atxpAccountsServer,
111-
scopedSpendConfig
111+
mcpServer
112112
} = config;
113113
// Use React Native safe fetch if in React Native environment
114114
this.safeFetchFn = getIsReactNative() ? createReactNativeSafeFetch(fetchFn) : fetchFn;
@@ -130,7 +130,7 @@ export class ATXPFetcher {
130130
this.onPaymentFailure = onPaymentFailure || this.defaultPaymentFailureHandler;
131131
this.onPaymentAttemptFailed = onPaymentAttemptFailed;
132132
this.atxpAccountsServer = atxpAccountsServer;
133-
this.scopedSpendConfig = scopedSpendConfig;
133+
this.mcpServer = mcpServer;
134134
}
135135

136136
/**
@@ -410,12 +410,77 @@ export class ATXPFetcher {
410410
return this.allowedAuthorizationServers.includes(baseUrl);
411411
}
412412

413+
/**
414+
* Gets the connection token from the account if available.
415+
* Uses duck typing to check if the account has a token property (like ATXPAccount).
416+
*/
417+
protected getAccountConnectionToken(): string | null {
418+
// Check if account has a token property (duck typing for ATXPAccount)
419+
const accountWithToken = this.account as { token?: string };
420+
if (typeof accountWithToken.token === 'string' && accountWithToken.token.length > 0) {
421+
return accountWithToken.token;
422+
}
423+
return null;
424+
}
425+
426+
/**
427+
* Creates a spend permission with the accounts service for the given resource URL.
428+
* Returns the spend_permission_token to pass to auth.
429+
*/
430+
protected createSpendPermission = async (resourceUrl: string): Promise<string | null> => {
431+
if (!this.atxpAccountsServer) {
432+
this.logger.debug(`ATXP: No accounts server configured, skipping spend permission creation`);
433+
return null;
434+
}
435+
436+
// Get connection token from account for authenticating to accounts service
437+
const connectionToken = this.getAccountConnectionToken();
438+
if (!connectionToken) {
439+
this.logger.debug(`ATXP: No connection token available, skipping spend permission creation`);
440+
return null;
441+
}
442+
443+
try {
444+
const spendPermissionUrl = `${this.atxpAccountsServer}/spend-permission`;
445+
this.logger.debug(`ATXP: Creating spend permission at ${spendPermissionUrl} for resource ${resourceUrl}`);
446+
447+
const response = await this.sideChannelFetch(spendPermissionUrl, {
448+
method: 'POST',
449+
headers: {
450+
'Authorization': `Bearer ${connectionToken}`,
451+
'Content-Type': 'application/json'
452+
},
453+
body: JSON.stringify({ resourceUrl })
454+
});
455+
456+
if (!response.ok) {
457+
this.logger.warn(`ATXP: Failed to create spend permission: ${response.status} ${response.statusText}`);
458+
return null;
459+
}
460+
461+
const data = await response.json() as { spendPermissionToken?: string };
462+
if (data.spendPermissionToken) {
463+
this.logger.info(`ATXP: Created spend permission for resource ${resourceUrl}`);
464+
return data.spendPermissionToken;
465+
}
466+
467+
this.logger.warn(`ATXP: Spend permission response missing token`);
468+
return null;
469+
} catch (error) {
470+
this.logger.warn(`ATXP: Error creating spend permission: ${error instanceof Error ? error.message : 'Unknown error'}`);
471+
return null;
472+
}
473+
}
474+
413475
protected makeAuthRequestWithPaymentMaker = async (authorizationUrl: URL, paymentMaker: PaymentMaker): Promise<string> => {
414476
const codeChallenge = authorizationUrl.searchParams.get('code_challenge');
415477
if (!codeChallenge) {
416478
throw new Error(`Code challenge not provided`);
417479
}
418480

481+
// Debug logging for spend permission configuration
482+
this.logger.debug(`ATXP: makeAuthRequestWithPaymentMaker - mcpServer: ${this.mcpServer}, atxpAccountsServer: ${this.atxpAccountsServer}`);
483+
419484
if (!paymentMaker) {
420485
const paymentMakerCount = this.account.paymentMakers.length;
421486
throw new Error(`Payment maker is null/undefined. Available payment maker count: ${paymentMakerCount}. This usually indicates a payment maker object was not properly instantiated.`);
@@ -429,76 +494,23 @@ export class ATXPFetcher {
429494

430495
const accountId = await this.account.getAccountId();
431496

432-
// If scoped spend config is set and we have accounts server, use accounts /sign endpoint
433-
// to get both JWT and scoped spend token
434-
let authToken: string;
435-
let scopedSpendToken: string | undefined;
436-
437-
if (this.scopedSpendConfig && this.atxpAccountsServer) {
438-
this.logger.debug(`ATXP: using scoped spend token flow with limit ${this.scopedSpendConfig.spendLimit}`);
439-
440-
// First, resolve the destination account ID from auth server
441-
const clientId = authorizationUrl.searchParams.get('client_id');
442-
if (!clientId) {
443-
throw new Error(`ATXP: client_id not found in authorization URL - cannot resolve destination for scoped spend token`);
444-
}
445-
446-
// Call auth's resolve endpoint to get destination account ID
447-
const resolveUrl = new URL(authorizationUrl.origin + '/authorize');
448-
resolveUrl.searchParams.set('client_id', clientId);
449-
resolveUrl.searchParams.set('resolve_only', 'true');
450-
451-
this.logger.debug(`ATXP: resolving destination account for client_id ${clientId}`);
452-
const resolveResponse = await this.sideChannelFetch(resolveUrl.toString(), {
453-
method: 'GET'
454-
});
455-
456-
if (!resolveResponse.ok) {
457-
const errorBody = await resolveResponse.text();
458-
throw new Error(`ATXP: failed to resolve destination account for client_id ${clientId}: ${resolveResponse.status} ${errorBody}`);
459-
}
497+
// Generate JWT locally via paymentMaker
498+
const authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId});
460499

461-
const resolveData = await resolveResponse.json() as { destinationAccountId: string };
462-
const destinationAccountId = resolveData.destinationAccountId;
463-
this.logger.debug(`ATXP: resolved destination account ${destinationAccountId} for client_id ${clientId}`);
464-
465-
// Call accounts /sign endpoint to get JWT + scoped spend token
466-
const signUrl = new URL('/sign', this.atxpAccountsServer);
467-
const signBody = {
468-
paymentRequestId: '',
469-
codeChallenge: codeChallenge,
470-
accountId: accountId,
471-
destinationAccountId: destinationAccountId,
472-
spendLimit: this.scopedSpendConfig.spendLimit
473-
};
500+
// Build the authorization URL with resource parameter
501+
// The resource parameter identifies the MCP server resource URL for spend permission scoping
502+
let finalAuthUrl = authorizationUrl.toString() + '&redirect=false';
474503

475-
this.logger.debug(`ATXP: calling accounts /sign for JWT + scoped spend token`);
476-
const signResponse = await this.sideChannelFetch(signUrl.toString(), {
477-
method: 'POST',
478-
headers: {
479-
'Content-Type': 'application/json'
480-
},
481-
body: JSON.stringify(signBody)
482-
});
504+
// If we have a resource URL (MCP server), create a spend permission first
505+
let spendPermissionToken: string | null = null;
506+
if (this.mcpServer) {
507+
finalAuthUrl += '&resource=' + encodeURIComponent(this.mcpServer);
483508

484-
if (!signResponse.ok) {
485-
const errorBody = await signResponse.text();
486-
throw new Error(`ATXP: accounts /sign failed: ${signResponse.status} ${errorBody}`);
509+
// Create spend permission with accounts service using connection token
510+
spendPermissionToken = await this.createSpendPermission(this.mcpServer);
511+
if (spendPermissionToken) {
512+
finalAuthUrl += '&spend_permission_token=' + encodeURIComponent(spendPermissionToken);
487513
}
488-
489-
const signData = await signResponse.json() as { jwt: string; scopedSpendToken?: string };
490-
authToken = signData.jwt;
491-
scopedSpendToken = signData.scopedSpendToken;
492-
this.logger.debug(`ATXP: got JWT${scopedSpendToken ? ' and scoped spend token' : ''} from accounts /sign`);
493-
} else {
494-
// Use original flow - generate JWT locally via paymentMaker
495-
authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId});
496-
}
497-
498-
// Build the authorization URL with optional scoped spend token
499-
let finalAuthUrl = authorizationUrl.toString() + '&redirect=false';
500-
if (scopedSpendToken) {
501-
finalAuthUrl += '&scoped_spend_token=' + encodeURIComponent(scopedSpendToken);
502514
}
503515

504516
// Make a fetch call to the authorization URL with the payment ID

packages/atxp-client/src/types.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,6 @@ export interface PaymentFailureContext {
3636
timestamp: Date;
3737
}
3838

39-
/**
40-
* Configuration for scoped spend tokens.
41-
* When configured, the SDK will request scoped spend tokens during authorization,
42-
* enabling async charging instead of blocking on payment before each operation.
43-
*/
44-
export interface ScopedSpendConfig {
45-
/**
46-
* Maximum amount the SDK is authorized to spend on behalf of the user.
47-
* This is the spend limit for the scoped spend token, in USD (e.g., "50.00").
48-
*/
49-
spendLimit: string;
50-
}
51-
5239
export type ClientConfig = {
5340
mcpServer: string;
5441
account: Account;
@@ -69,12 +56,6 @@ export type ClientConfig = {
6956
onPaymentFailure: (context: PaymentFailureContext) => Promise<void>;
7057
/** Optional callback when a single payment attempt fails (before trying other networks) */
7158
onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise<void>;
72-
/**
73-
* Optional scoped spend configuration.
74-
* When provided, the SDK will request scoped spend tokens during authorization,
75-
* enabling async charging instead of blocking on payment before each operation.
76-
*/
77-
scopedSpendConfig?: ScopedSpendConfig;
7859
}
7960

8061
// ClientArgs for creating clients - required fields plus optional overrides

src/dev/cli.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,15 @@ async function main() {
3939

4040
try {
4141
const account = new ATXPAccount(process.env.ATXP_CONNECTION_STRING!);
42+
// Use local accounts server for development if MCP server is localhost
43+
const isLocalDev = url.includes('localhost');
4244
const mcpClient = await atxpClient({
4345
mcpServer: url,
4446
account,
4547
allowedAuthorizationServers: ['http://localhost:3010', 'https://auth.atxp.ai', 'https://atpx-auth-staging.onrender.com'],
4648
allowHttp: true,
47-
logger: new ConsoleLogger({level: LogLevel.DEBUG})
49+
logger: new ConsoleLogger({level: LogLevel.DEBUG}),
50+
...(isLocalDev && { atxpAccountsServer: 'http://localhost:8016' })
4851
});
4952
const res = await mcpClient.callTool({
5053
name: toolName,

src/dev/resource.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@ console.log('Starting MCP server with destination', destinationAccountId);
554554

555555
app.use(atxpExpress({
556556
destination: destination,
557-
//server: 'http://localhost:3010',
557+
server: 'http://localhost:3010',
558558
payeeName: 'ATXP Client Example Resource Server',
559559
minimumPayment: BigNumber(0.01),
560560
allowHttp: true,

0 commit comments

Comments
 (0)