// =============================================================================
// AP2 TOOLS - PAYMENTS WITH MANDATES
// =============================================================================
// Payment tools that use A2A to call Stripe agent.
//
// KEY DIFFERENCE FROM A2A:
// - A2A (food tools): Agent freely calls other agents
// - AP2 (payment tools): Agent MUST have user-signed mandate first
//
// FLOW:
// 1. AI asks user: "Should I place this order for $15.37?"
// 2. User says: "Yes"
// 3. Agent calls requestUserAuthorization() → user-authorization.ts creates mandate
// 4. Agent verifies mandate is valid (signature, not expired, amount ok)
// 5. Agent sends payment request WITH mandate to Stripe agent
// 6. Stripe agent ALSO verifies mandate before processing
//
// WHY MANDATES?
// - Authorization: Proves user approved this specific payment
// - Authenticity: Signature proves it wasn't tampered with
// - Accountability: Clear audit trail of who approved what
// =============================================================================
import { createHmac } from 'crypto';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import type {
Mandate,
MandateRequest,
MandatePayload,
MandateVerification,
PaymentResponse,
PaymentStatusResponse,
PaymentStatusParams,
RefundResponse,
PaymentRequestWithMandate,
RefundRequestWithMandate,
PaymentResponseWithMandate,
RefundResponseWithMandate,
ProcessPaymentArgs,
ProcessRefundArgs,
} from './interfaces.js';
import type { PaymentMethod } from './types.js';
import { discoverAgents, callAgent } from './a2a-client.js';
import { authorizePayment } from './user-authorization.js';
const USER_SECRET = process.env.USER_SECRET || 'user-secret-key'; // Must match user-authorization.ts
// =============================================================================
// MANDATE FUNCTIONS
// =============================================================================
function createMandatePayload(m: MandatePayload): string {
return JSON.stringify({
userId: m.userId,
agentId: m.agentId,
action: m.action,
maxAmount: m.maxAmount,
currency: m.currency,
orderId: m.orderId,
issuedAt: m.issuedAt,
expiresAt: m.expiresAt,
});
}
// Calls the User Wallet Service to get authorization
// In real world: This triggers Face ID / PIN on user's phone
async function requestUserAuthorization(req: MandateRequest): Promise<Mandate> {
return authorizePayment(req);
}
// Verify mandate before sending to Stripe (defense in depth)
// Stripe agent ALSO verifies - double check is intentional
function verifyMandate(m: Mandate, amount?: number): MandateVerification {
const payload = createMandatePayload(m);
const expected = createHmac('sha256', USER_SECRET).update(payload).digest('hex');
// Check 1: Signature valid? (proves user signed it, not tampered)
if (m.signature !== expected) {
return { valid: false, reason: 'Bad signature' };
}
// Check 2: Not expired? (mandates only valid for 5 min)
if (new Date() > new Date(m.expiresAt)) {
return { valid: false, reason: 'Expired' };
}
// Check 3: Amount within limit? (can't charge more than user approved)
if (amount !== undefined && amount > m.maxAmount) {
return { valid: false, reason: 'Over limit' };
}
return { valid: true };
}
// =============================================================================
// TOOL DEFINITIONS
// =============================================================================
export const processPaymentTool: Tool = {
name: 'process_payment',
description: 'Process payment via Stripe. Requests user authorization, then sends mandate to Stripe.',
inputSchema: {
type: 'object',
properties: {
amount: { type: 'number', description: 'Payment amount' },
currency: { type: 'string', description: 'Currency code (e.g., USD)' },
paymentMethod: {
type: 'string',
description: 'Payment method',
enum: ['credit_card', 'debit_card', 'paypal', 'apple_pay', 'google_pay', 'digital_wallet'],
},
orderId: { type: 'string', description: 'Order ID' },
userId: { type: 'string', description: 'User ID for mandate' },
},
required: ['amount', 'currency', 'paymentMethod', 'orderId', 'userId'],
},
};
export const checkPaymentStatusTool: Tool = {
name: 'check_payment_status',
description: 'Check payment status. No mandate needed.',
inputSchema: {
type: 'object',
properties: {
transactionId: { type: 'string', description: 'Transaction ID' },
},
required: ['transactionId'],
},
};
export const processRefundTool: Tool = {
name: 'process_refund',
description: 'Process refund via Stripe. Requests user authorization, then sends mandate to Stripe.',
inputSchema: {
type: 'object',
properties: {
transactionId: { type: 'string', description: 'Transaction ID to refund' },
orderId: { type: 'string', description: 'Order ID' },
userId: { type: 'string', description: 'User ID for mandate' },
amount: { type: 'number', description: 'Refund amount (optional, defaults to full)' },
reason: { type: 'string', description: 'Refund reason (optional)' },
},
required: ['transactionId', 'orderId', 'userId'],
},
};
// =============================================================================
// HANDLERS
// =============================================================================
// Called by place_order AFTER user said "yes" in chat
export async function handleProcessPayment(args: ProcessPaymentArgs): Promise<PaymentResponseWithMandate> {
// Step 1: Request authorization from User Wallet Service
// This calls the separate wallet container, which simulates Face ID / PIN
const mandate = await requestUserAuthorization({
userId: args.userId,
agentId: 'mcp-server',
action: 'payment',
maxAmount: args.amount,
currency: args.currency,
orderId: args.orderId,
});
// Step 2: Verify mandate before sending (our check)
const verification = verifyMandate(mandate, args.amount);
if (!verification.valid) {
throw new Error(`Mandate failed: ${verification.reason}`);
}
// Step 3: Find Stripe agent via A2A
const agents = await discoverAgents('process_payment');
if (!agents.length) {
throw new Error('No payment agent available');
}
// Step 4: Send payment WITH mandate to Stripe agent
// Stripe will ALSO verify the mandate before processing
const request: PaymentRequestWithMandate = {
amount: args.amount,
currency: args.currency,
paymentMethod: args.paymentMethod as PaymentMethod,
orderId: args.orderId,
mandate, // <-- THIS is the proof of user approval
};
const response = await callAgent<PaymentRequestWithMandate, PaymentResponse>(
agents[0],
'payment/process',
request
);
return { ...response, mandate };
}
export async function handleCheckPaymentStatus(args: PaymentStatusParams): Promise<PaymentStatusResponse> {
const agents = await discoverAgents('process_payment');
if (!agents.length) {
throw new Error('No payment agent available');
}
return callAgent<PaymentStatusParams, PaymentStatusResponse>(
agents[0],
'payment/status',
{ transactionId: args.transactionId }
);
}
export async function handleProcessRefund(args: ProcessRefundArgs): Promise<RefundResponseWithMandate> {
// Request authorization from User Wallet Service
const mandate = await requestUserAuthorization({
userId: args.userId,
agentId: 'mcp-server',
action: 'refund',
maxAmount: args.amount || 999999,
currency: 'USD',
orderId: args.orderId,
});
const verification = verifyMandate(mandate);
if (!verification.valid) {
throw new Error(`Mandate failed: ${verification.reason}`);
}
const agents = await discoverAgents('process_payment');
if (!agents.length) {
throw new Error('No payment agent available');
}
const request: RefundRequestWithMandate = {
transactionId: args.transactionId,
amount: args.amount,
reason: args.reason,
mandate,
};
const response = await callAgent<RefundRequestWithMandate, RefundResponse>(
agents[0],
'payment/refund',
request
);
return { ...response, mandate };
}