|
|
@@ -0,0 +1,423 @@
|
|
|
+/**
|
|
|
+ * MCP HTTP Server for ShopRenter
|
|
|
+ *
|
|
|
+ * Provides MCP tools for accessing ShopRenter orders and customers data
|
|
|
+ * without storing personal information locally (GDPR compliance).
|
|
|
+ *
|
|
|
+ * Authentication: Internal API keys from internal_api_keys table
|
|
|
+ * Required parameter: shop_id (store ID from stores table)
|
|
|
+ */
|
|
|
+
|
|
|
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|
|
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
|
|
+import {
|
|
|
+ requireInternalApiKey,
|
|
|
+ createInternalApiKeyErrorResponse
|
|
|
+} from '../_shared/internal-api-key-auth.ts';
|
|
|
+import {
|
|
|
+ fetchOrders,
|
|
|
+ fetchCustomers,
|
|
|
+ ShopRenterOrder,
|
|
|
+ ShopRenterCustomer
|
|
|
+} from '../_shared/shoprenter-client.ts';
|
|
|
+import {
|
|
|
+ McpToolsListResponse,
|
|
|
+ McpToolCallRequest,
|
|
|
+ LlmCustomer,
|
|
|
+ LlmOrder
|
|
|
+} from '../_shared/mcp-types.ts';
|
|
|
+import {
|
|
|
+ createMcpErrorResponse,
|
|
|
+ createMcpSuccessResponse,
|
|
|
+ validateParams
|
|
|
+} from '../_shared/mcp-helpers.ts';
|
|
|
+
|
|
|
+// Initialize Supabase client
|
|
|
+const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
|
|
+const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
|
|
+const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
|
|
+
|
|
|
+// MCP Tool Definitions
|
|
|
+const TOOLS: McpToolsListResponse = {
|
|
|
+ tools: [
|
|
|
+ {
|
|
|
+ name: 'shoprenter_list_orders',
|
|
|
+ description: 'List orders from a ShopRenter store. Returns order details including customer info, items, status, and totals.',
|
|
|
+ inputSchema: {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ shop_id: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'The UUID of the ShopRenter store from the stores table'
|
|
|
+ },
|
|
|
+ page: {
|
|
|
+ type: 'number',
|
|
|
+ description: 'Page number for pagination (default: 1)'
|
|
|
+ },
|
|
|
+ limit: {
|
|
|
+ type: 'number',
|
|
|
+ description: 'Number of orders per page (default: 25, max: 100)'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ required: ['shop_id']
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'shoprenter_list_customers',
|
|
|
+ description: 'List customers from a ShopRenter store. Returns customer details including contact info and order history.',
|
|
|
+ inputSchema: {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ shop_id: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'The UUID of the ShopRenter store from the stores table'
|
|
|
+ },
|
|
|
+ page: {
|
|
|
+ type: 'number',
|
|
|
+ description: 'Page number for pagination (default: 1)'
|
|
|
+ },
|
|
|
+ limit: {
|
|
|
+ type: 'number',
|
|
|
+ description: 'Number of customers per page (default: 25, max: 100)'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ required: ['shop_id']
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'shoprenter_get_customer_orders',
|
|
|
+ description: 'Get all orders for a specific customer by customer email. Returns detailed order history.',
|
|
|
+ inputSchema: {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ shop_id: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'The UUID of the ShopRenter store from the stores table'
|
|
|
+ },
|
|
|
+ customer_email: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'The customer email address'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ required: ['shop_id', 'customer_email']
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * Extract phone number from various ShopRenter structures
|
|
|
+ */
|
|
|
+function extractPhone(data: any): string | undefined {
|
|
|
+ if (data?.phone) return data.phone;
|
|
|
+ if (data?.billing_address?.phone) return data.billing_address.phone;
|
|
|
+ if (data?.shipping_address?.phone) return data.shipping_address.phone;
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Convert ShopRenter customer to LLM-friendly format
|
|
|
+ */
|
|
|
+function formatCustomerForLlm(customer: ShopRenterCustomer): LlmCustomer {
|
|
|
+ return {
|
|
|
+ id: customer.id,
|
|
|
+ name: `${customer.firstname} ${customer.lastname}`.trim(),
|
|
|
+ email: customer.email,
|
|
|
+ phone: customer.phone || undefined
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Convert ShopRenter order to LLM-friendly format
|
|
|
+ */
|
|
|
+function formatOrderForLlm(order: ShopRenterOrder): LlmOrder {
|
|
|
+ // Extract customer name and email
|
|
|
+ const customerName = order.customer_name ||
|
|
|
+ (order.customer ? `${order.customer.firstname || ''} ${order.customer.lastname || ''}`.trim() : 'Unknown');
|
|
|
+ const customerEmail = order.customer_email || order.customer?.email || '';
|
|
|
+ const customerPhone = order.customer_phone ||
|
|
|
+ order.customer?.phone ||
|
|
|
+ extractPhone(order.billing_address) ||
|
|
|
+ extractPhone(order.shipping_address);
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: order.id,
|
|
|
+ orderNumber: order.order_number || order.number || order.id,
|
|
|
+ customer: {
|
|
|
+ name: customerName,
|
|
|
+ email: customerEmail,
|
|
|
+ phone: customerPhone
|
|
|
+ },
|
|
|
+ status: order.status,
|
|
|
+ total: order.total,
|
|
|
+ currency: order.currency || 'HUF',
|
|
|
+ items: (order.items || order.line_items || []).map((item: any) => ({
|
|
|
+ name: item.name || item.product_name || 'Unknown',
|
|
|
+ quantity: item.quantity || 1,
|
|
|
+ price: item.price || item.total || '0'
|
|
|
+ })),
|
|
|
+ createdAt: order.created_at || order.date_created || new Date().toISOString()
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Fetch all orders with pagination
|
|
|
+ */
|
|
|
+async function fetchAllOrdersPages(
|
|
|
+ shop_id: string,
|
|
|
+ maxPages: number = 10
|
|
|
+): Promise<any[]> {
|
|
|
+ const allOrders: any[] = [];
|
|
|
+ let page = 1;
|
|
|
+ let hasMore = true;
|
|
|
+
|
|
|
+ while (hasMore && page <= maxPages) {
|
|
|
+ try {
|
|
|
+ const response = await fetchOrders(shop_id, page, 100);
|
|
|
+ const orders = Array.isArray(response) ? response : (response.data || response.orders || []);
|
|
|
+
|
|
|
+ if (orders.length === 0) {
|
|
|
+ hasMore = false;
|
|
|
+ } else {
|
|
|
+ allOrders.push(...orders);
|
|
|
+ hasMore = orders.length === 100; // If we got max results, there might be more
|
|
|
+ page++;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`[MCP ShopRenter] Error fetching page ${page}:`, error);
|
|
|
+ hasMore = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return allOrders;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Handle shoprenter_list_orders tool
|
|
|
+ */
|
|
|
+async function handleListOrders(args: Record<string, any>): Promise<Response> {
|
|
|
+ const { shop_id, page = 1, limit = 25 } = args;
|
|
|
+
|
|
|
+ // Validate shop exists and is ShopRenter
|
|
|
+ const { data: store, error: storeError } = await supabase
|
|
|
+ .from('stores')
|
|
|
+ .select('id, platform_name, data_access_permissions')
|
|
|
+ .eq('id', shop_id)
|
|
|
+ .eq('platform_name', 'shoprenter')
|
|
|
+ .single();
|
|
|
+
|
|
|
+ if (storeError || !store) {
|
|
|
+ return createMcpErrorResponse('STORE_NOT_FOUND', 'ShopRenter store not found', 404);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check permissions
|
|
|
+ const permissions = store.data_access_permissions as any;
|
|
|
+ if (permissions && !permissions.allow_order_access) {
|
|
|
+ return createMcpErrorResponse('PERMISSION_DENIED', 'Order access not allowed for this store', 403);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Fetch orders from ShopRenter API
|
|
|
+ const response = await fetchOrders(shop_id, page, Math.min(limit, 100));
|
|
|
+ const orders = Array.isArray(response) ? response : (response.data || response.orders || []);
|
|
|
+
|
|
|
+ // Format for LLM
|
|
|
+ const formattedOrders = orders.map(formatOrderForLlm);
|
|
|
+
|
|
|
+ return createMcpSuccessResponse({
|
|
|
+ count: formattedOrders.length,
|
|
|
+ page,
|
|
|
+ limit,
|
|
|
+ orders: formattedOrders
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[MCP ShopRenter] Error fetching orders:', error);
|
|
|
+ return createMcpErrorResponse(
|
|
|
+ 'FETCH_ERROR',
|
|
|
+ `Failed to fetch orders: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
|
+ 500
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Handle shoprenter_list_customers tool
|
|
|
+ */
|
|
|
+async function handleListCustomers(args: Record<string, any>): Promise<Response> {
|
|
|
+ const { shop_id, page = 1, limit = 25 } = args;
|
|
|
+
|
|
|
+ // Validate shop exists and is ShopRenter
|
|
|
+ const { data: store, error: storeError } = await supabase
|
|
|
+ .from('stores')
|
|
|
+ .select('id, platform_name, data_access_permissions')
|
|
|
+ .eq('id', shop_id)
|
|
|
+ .eq('platform_name', 'shoprenter')
|
|
|
+ .single();
|
|
|
+
|
|
|
+ if (storeError || !store) {
|
|
|
+ return createMcpErrorResponse('STORE_NOT_FOUND', 'ShopRenter store not found', 404);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check permissions
|
|
|
+ const permissions = store.data_access_permissions as any;
|
|
|
+ if (permissions && !permissions.allow_customer_access) {
|
|
|
+ return createMcpErrorResponse('PERMISSION_DENIED', 'Customer access not allowed for this store', 403);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Fetch customers from ShopRenter API
|
|
|
+ const response = await fetchCustomers(shop_id, page, Math.min(limit, 100));
|
|
|
+ const customers = Array.isArray(response) ? response : (response.data || response.customers || []);
|
|
|
+
|
|
|
+ // Format for LLM
|
|
|
+ const formattedCustomers = customers.map(formatCustomerForLlm);
|
|
|
+
|
|
|
+ return createMcpSuccessResponse({
|
|
|
+ count: formattedCustomers.length,
|
|
|
+ page,
|
|
|
+ limit,
|
|
|
+ customers: formattedCustomers
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[MCP ShopRenter] Error fetching customers:', error);
|
|
|
+ return createMcpErrorResponse(
|
|
|
+ 'FETCH_ERROR',
|
|
|
+ `Failed to fetch customers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
|
+ 500
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Handle shoprenter_get_customer_orders tool
|
|
|
+ */
|
|
|
+async function handleGetCustomerOrders(args: Record<string, any>): Promise<Response> {
|
|
|
+ const { shop_id, customer_email } = args;
|
|
|
+
|
|
|
+ // Validate shop exists and is ShopRenter
|
|
|
+ const { data: store, error: storeError } = await supabase
|
|
|
+ .from('stores')
|
|
|
+ .select('id, platform_name, data_access_permissions')
|
|
|
+ .eq('id', shop_id)
|
|
|
+ .eq('platform_name', 'shoprenter')
|
|
|
+ .single();
|
|
|
+
|
|
|
+ if (storeError || !store) {
|
|
|
+ return createMcpErrorResponse('STORE_NOT_FOUND', 'ShopRenter store not found', 404);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check permissions
|
|
|
+ const permissions = store.data_access_permissions as any;
|
|
|
+ if (permissions && (!permissions.allow_order_access || !permissions.allow_customer_access)) {
|
|
|
+ return createMcpErrorResponse('PERMISSION_DENIED', 'Order and customer access required', 403);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Fetch orders and filter by customer email
|
|
|
+ const allOrders = await fetchAllOrdersPages(shop_id, 10);
|
|
|
+ const customerOrders = allOrders.filter(order => {
|
|
|
+ const orderEmail = order.customer_email || order.customer?.email || '';
|
|
|
+ return orderEmail.toLowerCase() === customer_email.toLowerCase();
|
|
|
+ });
|
|
|
+
|
|
|
+ // Format for LLM
|
|
|
+ const formattedOrders = customerOrders.map(formatOrderForLlm);
|
|
|
+
|
|
|
+ return createMcpSuccessResponse({
|
|
|
+ customerEmail: customer_email,
|
|
|
+ count: formattedOrders.length,
|
|
|
+ orders: formattedOrders
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[MCP ShopRenter] Error fetching customer orders:', error);
|
|
|
+ return createMcpErrorResponse(
|
|
|
+ 'FETCH_ERROR',
|
|
|
+ `Failed to fetch customer orders: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
|
+ 500
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Main request handler
|
|
|
+ */
|
|
|
+serve(async (req: Request) => {
|
|
|
+ // CORS headers
|
|
|
+ if (req.method === 'OPTIONS') {
|
|
|
+ return new Response(null, {
|
|
|
+ headers: {
|
|
|
+ 'Access-Control-Allow-Origin': '*',
|
|
|
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
|
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type'
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const url = new URL(req.url);
|
|
|
+
|
|
|
+ // Handle /tools endpoint - list available tools
|
|
|
+ if (url.pathname.endsWith('/tools') && req.method === 'GET') {
|
|
|
+ return new Response(JSON.stringify(TOOLS), {
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'Access-Control-Allow-Origin': '*'
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Handle /call endpoint - execute tool
|
|
|
+ if (url.pathname.endsWith('/call') && req.method === 'POST') {
|
|
|
+ // Authenticate with internal API key
|
|
|
+ const authResult = await requireInternalApiKey(req, supabase, 'read_orders');
|
|
|
+
|
|
|
+ if (!authResult.valid) {
|
|
|
+ return createInternalApiKeyErrorResponse(authResult);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const body: McpToolCallRequest = await req.json();
|
|
|
+ const { name, arguments: args } = body;
|
|
|
+
|
|
|
+ // Validate shop_id is provided
|
|
|
+ const validation = validateParams(args, ['shop_id']);
|
|
|
+ if (!validation.valid) {
|
|
|
+ return createMcpErrorResponse(
|
|
|
+ 'MISSING_PARAMS',
|
|
|
+ `Missing required parameters: ${validation.missing?.join(', ')}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Route to appropriate handler
|
|
|
+ switch (name) {
|
|
|
+ case 'shoprenter_list_orders':
|
|
|
+ return await handleListOrders(args);
|
|
|
+
|
|
|
+ case 'shoprenter_list_customers':
|
|
|
+ return await handleListCustomers(args);
|
|
|
+
|
|
|
+ case 'shoprenter_get_customer_orders':
|
|
|
+ const customerValidation = validateParams(args, ['shop_id', 'customer_email']);
|
|
|
+ if (!customerValidation.valid) {
|
|
|
+ return createMcpErrorResponse(
|
|
|
+ 'MISSING_PARAMS',
|
|
|
+ `Missing required parameters: ${customerValidation.missing?.join(', ')}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return await handleGetCustomerOrders(args);
|
|
|
+
|
|
|
+ default:
|
|
|
+ return createMcpErrorResponse('UNKNOWN_TOOL', `Unknown tool: ${name}`, 404);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[MCP ShopRenter] Error handling request:', error);
|
|
|
+ return createMcpErrorResponse(
|
|
|
+ 'INTERNAL_ERROR',
|
|
|
+ error instanceof Error ? error.message : 'Internal server error',
|
|
|
+ 500
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 404 for unknown endpoints
|
|
|
+ return createMcpErrorResponse('NOT_FOUND', 'Endpoint not found. Use /tools or /call', 404);
|
|
|
+});
|