diff --git a/packages/sdk/package.json b/packages/sdk/package.json index baa14e7..9a09a92 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -21,6 +21,8 @@ "commerce", "checkout", "product-feed", + "mcp", + "model-context-protocol", "ai", "chatgpt", "openai", @@ -49,6 +51,10 @@ "types": "./dist/feed/next/index.d.ts", "import": "./dist/feed/next/index.js" }, + "./mcp": { + "types": "./dist/mcp/index.d.ts", + "import": "./dist/mcp/index.js" + }, "./test": { "types": "./dist/test/index.d.ts", "import": "./dist/test/index.js" diff --git a/packages/sdk/src/feed/types.ts b/packages/sdk/src/feed/types.ts index 7ab6612..e81aba6 100644 --- a/packages/sdk/src/feed/types.ts +++ b/packages/sdk/src/feed/types.ts @@ -179,7 +179,9 @@ export const ProductFeedItemSchema = z.object({ // Availability & Inventory availability_date: z.string().datetime().optional(), expiration_date: z.string().datetime().optional(), - pickup_method: z.enum(["buy_online_pickup_in_store", "curbside", "in_store"]).optional(), + pickup_method: z + .enum(["buy_online_pickup_in_store", "curbside", "in_store"]) + .optional(), pickup_sla: z.string().optional(), // e.g., "1 hour", "same day" // Variants & Item Groups @@ -232,7 +234,12 @@ export const ProductFeedItemSchema = z.object({ // Related Products related_product_ids: z.array(z.string()).optional(), relationship_type: z - .enum(["often_bought_with", "similar_to", "accessories_for", "alternative_to"]) + .enum([ + "often_bought_with", + "similar_to", + "accessories_for", + "alternative_to", + ]) .optional(), // Reviews & Q&A diff --git a/packages/sdk/src/mcp/handlers.ts b/packages/sdk/src/mcp/handlers.ts new file mode 100644 index 0000000..833371c --- /dev/null +++ b/packages/sdk/src/mcp/handlers.ts @@ -0,0 +1,184 @@ +/** + * Configuration for creating MCP handlers + */ +export interface HandlerConfig { + /** + * Base URL of your store's API (e.g., 'https://mystore.com') + */ + baseUrl: string; + + /** + * Optional headers to include in all requests (e.g., auth tokens) + */ + headers?: Record; + + /** + * Optional fetch implementation (useful for testing or custom logic) + */ + fetch?: typeof fetch; + + /** + * Function to generate checkout URL for a session. + * Used when completeCheckout is called without a payment token (MCP context). + * Defaults to `${baseUrl}/checkout/${sessionId}` if not provided. + * + * @example + * ```typescript + * getCheckoutUrl: (sessionId) => `https://mystore.com/checkout/${sessionId}` + * ``` + */ + getCheckoutUrl?: (sessionId: string) => string; +} + +/** + * Create handlers that call your acp-handler API endpoints + * + * @example + * ```typescript + * const handlers = createHandlers({ + * baseUrl: 'https://mystore.com', + * headers: { 'Authorization': 'Bearer token' } + * }); + * + * server.registerTool( + * 'search_products', + * tools.searchProducts, + * handlers.searchProducts + * ); + * ``` + */ +export function createHandlers(config: HandlerConfig) { + const { baseUrl, headers = {}, fetch: customFetch = fetch } = config; + + const request = async ( + path: string, + options: RequestInit = {}, + ): Promise => { + const url = `${baseUrl}${path}`; + const response = await customFetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + ...headers, + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ + message: response.statusText, + })); + throw new Error( + `API request failed: ${error.message || response.statusText}`, + ); + } + + return response.json(); + }; + + return { + /** + * Search for products in the catalog + */ + searchProducts: async (input: { + query: string; + category?: string; + limit?: number; + }): Promise => { + const params = new URLSearchParams({ + q: input.query, + }); + + if (input.category) { + params.set("category", input.category); + } + + if (input.limit) { + params.set("limit", String(input.limit)); + } + + return request(`/api/products/search?${params}`); + }, + + /** + * Get detailed information about a specific product + */ + getProduct: async (input: { product_id: string }): Promise => { + return request(`/api/products/${input.product_id}`); + }, + + /** + * Create a new checkout session + */ + createCheckout: async (input: any): Promise => { + return request("/api/checkout", { + method: "POST", + body: JSON.stringify(input), + }); + }, + + /** + * Update an existing checkout session + */ + updateCheckout: async (input: any): Promise => { + const { session_id, ...body } = input; + return request(`/api/checkout/${session_id}`, { + method: "POST", + body: JSON.stringify(body), + }); + }, + + /** + * Complete a checkout session and process payment. + * In MCP context (no payment token), returns checkout URL for user to complete payment. + * In ACP context (with payment token), processes payment directly. + */ + completeCheckout: async (input: any): Promise => { + const { session_id, customer, payment } = input; + + // MCP context: no payment token provided + // Return checkout URL for user to complete payment on merchant site + if (!payment?.token) { + const checkoutUrl = config.getCheckoutUrl + ? config.getCheckoutUrl(session_id) + : `${config.baseUrl}/checkout/${session_id}`; + + return { + checkout_url: checkoutUrl, + session_id, + status: "pending_payment", + message: "Complete your purchase at the checkout link", + }; + } + + // ACP context: payment token provided + // Process payment through ACP complete endpoint + return request(`/api/checkout/${session_id}/complete`, { + method: "POST", + body: JSON.stringify({ customer, payment }), + }); + }, + + /** + * Cancel a checkout session + */ + cancelCheckout: async (input: { session_id: string }): Promise => { + const { session_id } = input; + return request(`/api/checkout/${session_id}/cancel`, { + method: "POST", + }); + }, + + /** + * Get the current state of a checkout session + */ + getCheckout: async (input: { session_id: string }): Promise => { + return request(`/api/checkout/${input.session_id}`); + }, + }; +} + +/** + * Type for the handlers object returned by createHandlers + */ +export type Handlers = ReturnType; diff --git a/packages/sdk/src/mcp/index.ts b/packages/sdk/src/mcp/index.ts new file mode 100644 index 0000000..deb1705 --- /dev/null +++ b/packages/sdk/src/mcp/index.ts @@ -0,0 +1,91 @@ +/** + * MCP (Model Context Protocol) tools for acp-handler + * + * This module provides tool definitions and handlers to expose your acp-handler + * checkout API as MCP tools for ChatGPT Apps. This allows merchants to sell + * products through ChatGPT without waiting for ACP approval. + * + * ## Payment Handling + * + * MCP tools follow the same ACP protocol as the full ACP implementation, but with + * a key difference: ChatGPT Apps don't provide delegated payment tokens. When + * `completeCheckout` is called without a payment token, it returns a checkout URL + * for the user to complete payment on your site (which can be loaded in the ChatGPT + * iframe if desired). + * + * @example Basic usage (default checkout URL) + * ```typescript + * import { McpServer } from 'mcp-handler'; + * import { tools, createHandlers } from 'acp-handler/mcp'; + * + * const server = new McpServer({ name: 'my-store' }); + * const handlers = createHandlers({ + * baseUrl: 'https://mystore.com' + * // Defaults to: https://mystore.com/checkout/:sessionId + * }); + * + * // Register all tools + * server.registerTool('search_products', tools.searchProducts, handlers.searchProducts); + * server.registerTool('create_checkout', tools.createCheckout, handlers.createCheckout); + * server.registerTool('complete_checkout', tools.completeCheckout, handlers.completeCheckout); + * + * server.start(); + * ``` + * + * @example With custom checkout URL + * ```typescript + * import { McpServer } from 'mcp-handler'; + * import { tools, createHandlers } from 'acp-handler/mcp'; + * + * const server = new McpServer({ name: 'my-store' }); + * const handlers = createHandlers({ + * baseUrl: 'https://mystore.com', + * headers: { 'Authorization': 'Bearer secret' }, + * getCheckoutUrl: (sessionId) => `https://mystore.com/buy/${sessionId}?source=chatgpt` + * }); + * + * // Register tools + * server.registerTool('search_products', tools.searchProducts, handlers.searchProducts); + * server.registerTool('create_checkout', tools.createCheckout, handlers.createCheckout); + * server.registerTool('complete_checkout', tools.completeCheckout, handlers.completeCheckout); + * ``` + * + * @example With custom handler logic + * ```typescript + * import { McpServer } from 'mcp-handler'; + * import { tools, createHandlers } from 'acp-handler/mcp'; + * + * const server = new McpServer({ name: 'my-store' }); + * const handlers = createHandlers({ + * baseUrl: 'https://mystore.com', + * getCheckoutUrl: (sessionId) => `https://mystore.com/checkout/${sessionId}` + * }); + * + * // Customize tool definitions + * server.registerTool( + * 'search_products', + * { + * ...tools.searchProducts, + * description: 'Search our awesome product catalog!', + * }, + * handlers.searchProducts + * ); + * + * // Or wrap handlers with custom logic + * server.registerTool( + * 'create_checkout', + * tools.createCheckout, + * async (input) => { + * console.log('Creating checkout:', input); + * const result = await handlers.createCheckout(input); + * return result; + * } + * ); + * ``` + * + * @module mcp + */ + +export { createHandlers, type HandlerConfig, type Handlers } from "./handlers"; +export type { MCPToolDefinition } from "./tools"; +export { tools } from "./tools"; diff --git a/packages/sdk/src/mcp/tools.ts b/packages/sdk/src/mcp/tools.ts new file mode 100644 index 0000000..1a68eef --- /dev/null +++ b/packages/sdk/src/mcp/tools.ts @@ -0,0 +1,221 @@ +import { z } from "zod"; + +/** + * MCP tool definition structure + */ +export interface MCPToolDefinition { + description: string; + inputSchema: z.ZodSchema; +} + +/** + * Search products in the catalog + * + * Maps to: GET /api/products/search + */ +export const searchProducts: MCPToolDefinition = { + description: + "Search for products in the catalog. Returns a list of products matching the query with pricing and availability.", + inputSchema: z.object({ + query: z.string().describe("Search query to find products"), + category: z.string().optional().describe("Filter by product category"), + limit: z + .number() + .int() + .positive() + .default(10) + .describe("Maximum number of results to return"), + }), +}; + +/** + * Get detailed information about a specific product + * + * Maps to: GET /api/products/:id + */ +export const getProduct: MCPToolDefinition = { + description: + "Get detailed information about a specific product including variants, images, and full description.", + inputSchema: z.object({ + product_id: z.string().describe("Unique identifier for the product"), + }), +}; + +/** + * Create a new checkout session + * + * Maps to: POST /api/checkout + */ +export const createCheckout: MCPToolDefinition = { + description: + "Create a new checkout session with the specified items. Returns pricing, available fulfillment options, and session ID.", + inputSchema: z.object({ + items: z + .array( + z.object({ + id: z.string().describe("Product ID"), + quantity: z + .number() + .int() + .positive() + .describe("Quantity to purchase"), + }), + ) + .min(1) + .describe("List of items to add to checkout"), + customer: z + .object({ + email: z.string().email().describe("Customer email address"), + name: z.string().optional().describe("Customer full name"), + }) + .optional() + .describe("Customer information (optional at creation)"), + fulfillment: z + .object({ + selected_option_id: z + .string() + .optional() + .describe("ID of the selected fulfillment option"), + address: z + .object({ + line1: z.string(), + line2: z.string().optional(), + city: z.string(), + state: z.string().optional(), + postal_code: z.string(), + country: z.string(), + }) + .optional() + .describe("Shipping address"), + }) + .optional() + .describe("Fulfillment preferences"), + }), +}; + +/** + * Update an existing checkout session + * + * Maps to: PATCH /api/checkout/:id + */ +export const updateCheckout: MCPToolDefinition = { + description: + "Update an existing checkout session. Can modify items, customer information, or fulfillment details.", + inputSchema: z.object({ + session_id: z.string().describe("Checkout session ID to update"), + items: z + .array( + z.object({ + id: z.string().describe("Product ID"), + quantity: z + .number() + .int() + .positive() + .describe("Quantity to purchase"), + }), + ) + .optional() + .describe("Updated list of items"), + customer: z + .object({ + email: z.string().email().describe("Customer email address"), + name: z.string().optional().describe("Customer full name"), + }) + .optional() + .describe("Customer information"), + fulfillment: z + .object({ + selected_option_id: z + .string() + .optional() + .describe("ID of the selected fulfillment option"), + address: z + .object({ + line1: z.string(), + line2: z.string().optional(), + city: z.string(), + state: z.string().optional(), + postal_code: z.string(), + country: z.string(), + }) + .optional() + .describe("Shipping address"), + }) + .optional() + .describe("Fulfillment preferences"), + }), +}; + +/** + * Complete a checkout session and process payment + * + * Maps to: POST /api/checkout/:id/complete + * + * Behavior depends on payment token availability: + * - With payment token (ACP): Processes payment directly through the complete endpoint + * - Without payment token (MCP): Returns checkout_url for user to complete payment on merchant site + */ +export const completeCheckout: MCPToolDefinition = { + description: + "Complete the checkout process. In MCP context (without payment token), returns a checkout URL for the user to complete payment. In ACP context (with delegated payment token), processes payment directly.", + inputSchema: z.object({ + session_id: z.string().describe("Checkout session ID to complete"), + customer: z.object({ + email: z.string().email().describe("Customer email address"), + name: z.string().describe("Customer full name"), + }), + payment: z + .object({ + method: z.string().describe("Payment method (e.g., 'card', 'paypal')"), + token: z + .string() + .optional() + .describe( + "Payment token from payment processor (delegated token for ACP)", + ), + }) + .optional() + .describe( + "Payment information (optional for MCP, required for ACP payment processing)", + ), + }), +}; + +/** + * Cancel a checkout session + * + * Maps to: POST /api/checkout/:id/cancel + */ +export const cancelCheckout: MCPToolDefinition = { + description: + "Cancel an existing checkout session. The session will be marked as canceled and cannot be completed.", + inputSchema: z.object({ + session_id: z.string().describe("Checkout session ID to cancel"), + }), +}; + +/** + * Get the current state of a checkout session + * + * Maps to: GET /api/checkout/:id + */ +export const getCheckout: MCPToolDefinition = { + description: + "Retrieve the current state of a checkout session including items, pricing, and status.", + inputSchema: z.object({ + session_id: z.string().describe("Checkout session ID to retrieve"), + }), +}; + +/** + * All available MCP tools + */ +export const tools = { + searchProducts, + getProduct, + createCheckout, + updateCheckout, + completeCheckout, + cancelCheckout, + getCheckout, +} as const; diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts index 20a93de..a2a8455 100644 --- a/packages/sdk/tsdown.config.ts +++ b/packages/sdk/tsdown.config.ts @@ -6,6 +6,7 @@ const config: UserConfig = defineConfig({ "./src/next/index.ts", "./src/feed/index.ts", "./src/feed/next/index.ts", + "./src/mcp/index.ts", "./src/test/index.ts", ], outDir: "./dist",