/**
* negotiate_terms MCP Tool
* Handles UCP Capability Negotiation between an AI agent and the gateway.
*
* Flow:
* 1. Fetch the agent's UCP profile from agent_profile_url
* 2. Generate this server's capabilities via profile.ts
* 3. Run the intersection algorithm via negotiateCapabilities()
* 3b. If Shopify Admin API is configured, fetch real active discounts and merge them
* 4. Optionally validate a discount code against Shopify active discounts
* 5. Retrieve or create a checkout session via CheckoutSessionManager
* 6. Return the negotiation result
*/
import type {
AgentProfile,
UCPProfile,
UCPService,
CheckoutSession,
NegotiationResult,
DiscountOffer,
GatewayConfig,
} from '../types.js';
import { generateProfile } from '../ucp/profile.js';
import { negotiateCapabilities } from '../ucp/negotiate.js';
import { CheckoutSessionManager } from '../ucp/checkout-session.js';
import { ShopifyClient } from '../shopify/client.js';
import { AdminAPI } from '../shopify/admin.js';
import { logger } from '../utils/logger.js';
// ─── Parameter / Result Types ───
export interface NegotiateParams {
cart_id: string;
agent_profile_url: string;
discount_code?: string;
}
export interface NegotiateResult {
negotiation: NegotiationResult;
checkout_session?: CheckoutSession;
discount_applied?: boolean;
error?: string;
}
// ─── Singleton checkout session manager ───
let sessionManager: CheckoutSessionManager | undefined;
function getSessionManager(): CheckoutSessionManager {
if (!sessionManager) {
sessionManager = new CheckoutSessionManager();
}
return sessionManager;
}
/**
* Allow external callers (e.g. tests) to inject a session manager.
*/
export function setSessionManager(manager: CheckoutSessionManager): void {
sessionManager = manager;
}
// ─── Cached Admin API ───
let cachedAdminAPI: AdminAPI | null | undefined;
/**
* Return a cached AdminAPI instance if Shopify credentials are configured,
* or null if they are not. The result is computed once and cached at the
* module level so subsequent calls reuse the same instance.
*/
function getAdminAPI(config: GatewayConfig): AdminAPI | null {
if (cachedAdminAPI !== undefined) {
return cachedAdminAPI;
}
const { storeDomain, accessToken, storefrontToken } = config.shopify;
if (!storeDomain || !accessToken) {
cachedAdminAPI = null;
return null;
}
try {
const client = new ShopifyClient({
storeDomain,
accessToken,
storefrontToken: storefrontToken || '',
});
cachedAdminAPI = new AdminAPI(client);
return cachedAdminAPI;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
logger.warn('Failed to initialize Shopify AdminAPI', { error: message });
cachedAdminAPI = null;
return null;
}
}
/**
* Allow external callers (e.g. tests) to reset the cached AdminAPI instance.
*/
export function resetAdminAPICache(): void {
cachedAdminAPI = undefined;
}
// ─── Agent Profile Fetcher ───
/**
* Fetch the agent's UCP profile from the given URL and extract an AgentProfile.
* Returns null with a logged warning on any failure.
*/
async function fetchAgentProfile(profileUrl: string): Promise<AgentProfile | null> {
try {
const url = new URL(profileUrl);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
logger.warn('Agent profile URL uses unsupported protocol', { profileUrl });
return null;
}
const response = await fetch(profileUrl, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
logger.warn('Agent profile fetch failed', {
profileUrl,
status: response.status,
});
return null;
}
const profile = (await response.json()) as UCPProfile;
// Validate minimal structure
if (!profile.version || !Array.isArray(profile.services) || profile.services.length === 0) {
logger.warn('Agent profile has invalid structure', { profileUrl });
return null;
}
// Aggregate capabilities from all services
const capabilities: string[] = [];
for (const service of profile.services) {
if (Array.isArray(service.capabilities)) {
for (const cap of service.capabilities) {
capabilities.push(cap.name);
}
}
}
return {
profile_url: profileUrl,
capabilities,
credentials: [], // credentials are not exposed in the profile endpoint
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
logger.warn('Failed to fetch agent profile', { profileUrl, error: message });
return null;
}
}
// ─── Discount Validation ───
/**
* Check whether the given discount code matches any of the available discounts
* from the negotiation result. Returns the matched offer, or undefined.
*/
function findMatchingDiscount(
code: string,
availableDiscounts: DiscountOffer[],
): DiscountOffer | undefined {
const normalizedCode = code.trim().toUpperCase();
return availableDiscounts.find(
(d) => d.code.toUpperCase() === normalizedCode,
);
}
// ─── Main Tool Function ───
/**
* Negotiate capabilities, discounts, and shipping options between an agent
* and this merchant gateway.
*/
export async function negotiateTerms(
params: NegotiateParams,
config: GatewayConfig,
): Promise<NegotiateResult> {
const { cart_id, agent_profile_url, discount_code } = params;
logger.info('negotiate_terms: starting negotiation', {
cart_id,
agent_profile_url,
has_discount: !!discount_code,
});
// Step 1: Fetch agent profile
const agentProfile = await fetchAgentProfile(agent_profile_url);
if (!agentProfile) {
logger.warn('negotiate_terms: could not fetch agent profile, using empty capabilities');
}
const effectiveAgentProfile: AgentProfile = agentProfile ?? {
profile_url: agent_profile_url,
capabilities: [],
credentials: [],
};
// Step 2: Generate server capabilities
const serverProfile = generateProfile(config);
const shoppingService: UCPService | undefined = serverProfile.services[0];
if (!shoppingService) {
return {
negotiation: {
active_capabilities: [],
available_discounts: [],
shipping_options: [],
payment_handlers: [],
},
error: 'Server has no configured services',
};
}
// Step 3: Run capability intersection
const negotiation = negotiateCapabilities(
effectiveAgentProfile,
shoppingService.capabilities,
shoppingService.payment_handlers,
);
// Step 3b: Fetch real discounts from Shopify Admin API (if configured)
const adminAPI = getAdminAPI(config);
if (adminAPI) {
try {
const realDiscounts = await adminAPI.getDiscountCodes();
if (realDiscounts.length > 0) {
// Merge real discounts into the negotiation result, avoiding duplicates
const existingCodes = new Set(
negotiation.available_discounts.map((d) => d.code.toUpperCase()),
);
let added = 0;
for (const discount of realDiscounts) {
if (!existingCodes.has(discount.code.toUpperCase())) {
negotiation.available_discounts.push(discount);
existingCodes.add(discount.code.toUpperCase());
added++;
}
}
logger.info('negotiate_terms: fetched real discounts from Shopify Admin API', {
fetched: realDiscounts.length,
new_discounts_added: added,
total_available: negotiation.available_discounts.length,
});
} else {
logger.info('negotiate_terms: Shopify Admin API returned no active discounts');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
logger.warn('negotiate_terms: failed to fetch discounts from Shopify Admin API, continuing with existing discounts', {
error: message,
});
}
}
// Step 4: Validate discount code if provided
let discountApplied = false;
if (discount_code) {
const matched = findMatchingDiscount(discount_code, negotiation.available_discounts);
if (matched) {
discountApplied = true;
logger.info('negotiate_terms: discount code validated', {
code: discount_code,
type: matched.type,
value: matched.value,
});
} else {
logger.info('negotiate_terms: discount code not found in available discounts', {
code: discount_code,
});
}
}
// Step 5: Retrieve or create checkout session
const mgr = getSessionManager();
let checkoutSession: CheckoutSession | undefined = mgr.get(cart_id);
if (!checkoutSession) {
// cart_id doesn't map to an existing session — create a new one
checkoutSession = mgr.create();
logger.info('negotiate_terms: created new checkout session', {
cart_id,
session_id: checkoutSession.id,
});
}
// Step 6: Assemble result
const result: NegotiateResult = {
negotiation,
checkout_session: checkoutSession,
discount_applied: discountApplied,
};
logger.info('negotiate_terms: negotiation complete', {
active_capabilities_count: negotiation.active_capabilities.length,
discount_applied: discountApplied,
session_id: checkoutSession.id,
session_status: checkoutSession.status,
});
return result;
}