Skip to main content
Glama
server.ts28.7 kB
/** * MCP Server implementation for SCP */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { discoverWithCapabilities } from './discovery/dns-lookup.js'; import { completeAuthorizationFlow, revokeToken } from './auth/oauth-client.js'; import { getAuthorizationInfo, getValidAccessToken } from './auth/token-manager.js'; import { getAuthorization, storeAuthorization, deleteAuthorization } from './storage/database-sqljs.js'; import { encryptToken } from './storage/encryption.js'; import * as scpClient from './http/client.js'; /** * Create and configure MCP server */ export function createServer(): Server { const server = new Server( { name: 'scp-mcp-server', version: '0.1.0', instructions: `Shopper Context Protocol (SCP) Server CRITICAL: ALWAYS GET REAL EMAIL FROM USER When authorizing, you MUST get the user's actual email address. ❌ NEVER use placeholder emails like user@example.com ✅ ALWAYS ask: "What email address do you use with [Merchant]?" IMPORTANT WORKFLOW: BEFORE YOU DO ANYTHING YOU MUST SEE IF THE DOMAIN SUPPORTS SCP Check if domain supports SCP: - scp_check_domain_support(domain="acmestore.com") If not supported: - Tell user: "This domain does not support Shopper Context Protocol, tell them to email scp@cordial.com to get it hooked up" 1. AUTHORIZATION REQUIRED FIRST Before accessing ANY customer data, you MUST authorize with the merchant domain. Check authorization: - scp_check_authorization(domain="acmestore.com") If not authorized: - ASK USER: "What email address do you use with [Merchant Name]?" - WAIT for user to provide their REAL email - Call: scp_authorize(domain="...", email="user_provided_email", scopes=["orders", "loyalty", "preferences", "intent:read", "intent:create"]) - Tell user: "Please check your email for a magic link to authorize access" 2. ACCESSING DATA Once authorized, use these tools: - scp_get_orders(domain="...") - Order history - scp_get_loyalty(domain="...") - Loyalty status - scp_get_preferences(domain="...") - Saved preferences - scp_get_intents(domain="...") - Shopping intents 3. CREATING INTENTS When user expresses a shopping goal, create an intent: - scp_create_intent(domain="...", base_intent="...", context={...}) 4. ERROR HANDLING If you get "Not authorized" error: - Follow authorization workflow above If you get "Invalid email" error: - Ask user for their REAL email address REMEMBER: ✅ ALWAYS ask user for their real email address before authorizing ✅ ALWAYS check authorization before accessing data ✅ Request comprehensive scopes upfront: ["orders", "loyalty", "preferences", "intent:read", "intent:create"] ✅ Create intents when users express shopping goals ❌ NEVER use example.com or placeholder email addresses ❌ NEVER access data without authorization first` }, { capabilities: { tools: {}, prompts: {} } } ); // Register prompts - provide instructions to LLM server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [ { name: 'scp-usage-guide', description: 'How to use Shopper Context Protocol (SCP) tools', arguments: [] } ] })); server.setRequestHandler(GetPromptRequestSchema, async (request) => { if (request.params.name === 'scp-usage-guide') { return { messages: [ { role: 'user', content: { type: 'text', text: `# Shopper Context Protocol (SCP) - Usage Guide You have access to Shopper Context Protocol tools that allow you to securely access shopper data from merchants. ## IMPORTANT: Authorization Required First Before accessing ANY shopper data, you MUST authorize with the merchant domain first. ## Step-by-Step Workflow BEFORE YOU DO ANYTHING YOU MUST SEE IF THE DOMAIN SUPPORTS SCP Check if domain supports SCP: - scp_check_domain_support(domain="acmestore.com") If not supported: - Tell user: "This domain does not support Shopper Context Protocol, tell them to email scp@cordial.com to get it hooked up" ### 1. When User Asks About Merchant Data Example: "Show me my Boot Barn orders" **Step 1:** Check if already authorized \`\`\` scp_check_authorization(domain="acmestore.com") \`\`\` **If NOT authorized:** **Step 2:** Ask the user for their email "What email address do you use with Boot Barn?" **Step 3:** Authorize with the merchant \`\`\` scp_authorize( domain="acmestore.com", email="user@example.com", scopes=["orders", "loyalty", "preferences", "intent:read", "intent:create"] ) \`\`\` **Step 4:** Inform user they'll receive a magic link "I've started the authorization process. Please check your email for a magic link from Boot Barn and click it to authorize access." **Step 5:** After user confirms, proceed to access data ### 2. Accessing Customer Data Once authorized, use these tools: **Get Orders:** \`\`\` scp_get_orders(domain="acmestore.com", limit=10) \`\`\` **Get Loyalty Status:** \`\`\` scp_get_loyalty(domain="acmestore.com") \`\`\` **Get Preferences:** \`\`\` scp_get_preferences(domain="acmestore.com") \`\`\` **Get Shopping Intents:** \`\`\` scp_get_intents(domain="acmestore.com", status=["active", "in_progress"]) \`\`\` ### 3. Creating Shopping Intents When a user expresses a shopping goal, create an intent to track it: Example: "I'm looking for hiking boots for a summer trip to Colorado" \`\`\` scp_create_intent( domain="acmestore.com", base_intent="Find hiking boots for summer Colorado trip", context={ "trip_date": "2025-07-15", "location": "Colorado mountains", "activities": ["hiking", "camping"], "budget": "under $250" }, ai_assistant="claude" ) \`\`\` This allows the merchant to help fulfill the intent across touchpoints (website, store, email, etc.) ### 4. Discovering New Merchants If you need to find a merchant's SCP endpoint: \`\`\` scp_discover(domain="acmestore.com") \`\`\` This returns the SCP endpoint URL and supported capabilities. ## Common Scopes Request these scopes based on what data you need: - \`orders\` - Order history - \`loyalty\` - Loyalty points and tier - \`offers\` - Personalized offers - \`preferences\` - Saved sizes, addresses, brands - \`intent:read\` - View shopping intents - \`intent:create\` - Create new intents - \`intent:write\` - Update existing intents **Best Practice:** Request all commonly-needed scopes upfront: \`["orders", "loyalty", "preferences", "intent:read", "intent:create"]\` ## Error Handling ### "Not Authorized" Error If you get this error: \`\`\` ❌ Not authorized with acmestore.com. Please authorize first by calling: scp_authorize... \`\`\` **Action:** Follow the authorization workflow (ask for email, call scp_authorize) ### "Customer Not Found" Error The email address doesn't have an account with this merchant. **Action:** Ask user to verify their email or check if they have an account. ### "Missing Scope" Error The authorization doesn't include the required scope. **Action:** Re-authorize with additional scopes: \`\`\` scp_revoke_authorization(domain="acmestore.com") scp_authorize(domain="acmestore.com", email="...", scopes=["orders", "loyalty", "new_scope"]) \`\`\` ## Example Conversation Flow **User:** "What are my recent Boot Barn orders?" **You:** 1. Call \`scp_check_authorization(domain="acmestore.com")\` 2. If not authorized: - Ask: "What email do you use with Acme Store?" - User provides: "john@example.com" - Call \`scp_authorize(domain="acmestore.com", email="john@example.com", scopes=[...])\` - Say: "Please check your email for a magic link to authorize access." - Wait for confirmation 3. Call \`scp_get_orders(domain="acmestore.com", limit=5)\` 4. Present orders in a friendly format ## Security & Privacy - All data is fetched in real-time from the merchant (nothing stored) - Tokens are encrypted locally - Each merchant has isolated authorization - User can revoke access anytime with \`scp_revoke_authorization\` ## Remember ✅ ALWAYS check authorization before accessing data ✅ ALWAYS ask for email if not authorized ✅ Create intents when users express shopping goals ✅ Use appropriate scopes for the data needed ✅ Present data in a user-friendly way ❌ NEVER try to access data without authorization ❌ NEVER store or cache customer data ❌ NEVER use @example.com email addresses, always use a real email address and ask the user for it if not provided ` } } ] }; } throw new Error('Unknown prompt'); }); // Register tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'scp_authorize', description: 'BEFORE USING THIS ENSURE THE DOMAIN SUPPORTS SCP BY CALLING scp_discover FIRST. Authorize access to a merchant\'s customer context via SCP. Must be called before accessing any customer data. IMPORTANT: You must ask the user for their REAL email address - never use placeholder emails like user@example.com. Ask: "What email address do you use with [Merchant]?" and wait for their response.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain (e.g., \'acmestore.com\')' }, email: { type: 'string', description: 'Customer\'s REAL email address (must ask user for this - never use example.com or placeholder emails)' }, scopes: { type: 'array', items: { type: 'string' }, description: 'Requested scopes (e.g., [\'orders\', \'loyalty\', \'intent:read\']). Best practice: request all needed scopes upfront.' } }, required: ['domain', 'email', 'scopes'] } }, { name: 'scp_check_authorization', description: 'Check if authorized with a merchant domain', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' } }, required: ['domain'] } }, { name: 'scp_revoke_authorization', description: 'Revoke authorization with a merchant domain', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' } }, required: ['domain'] } }, { name: 'scp_discover', description: 'Discover SCP endpoint for a merchant domain via DNS', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' } }, required: ['domain'] } }, { name: 'scp_get_orders', description: 'Get order history from a merchant. Domain must be authorized first.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' }, limit: { type: 'number', description: 'Maximum number of orders to return', default: 10 }, offset: { type: 'number', description: 'Number of orders to skip', default: 0 }, status: { type: 'array', items: { type: 'string' }, description: 'Filter by order status (e.g., [\'delivered\', \'shipped\'])' } }, required: ['domain'] } }, { name: 'scp_get_loyalty', description: 'Get loyalty status and points from a merchant. Domain must be authorized first.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' } }, required: ['domain'] } }, { name: 'scp_get_offers', description: 'Get active personalized offers from a merchant. Domain must be authorized first.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' }, active_only: { type: 'boolean', description: 'Only return active offers', default: true } }, required: ['domain'] } }, { name: 'scp_get_preferences', description: 'Get saved customer preferences (sizes, styles, addresses) from a merchant. Domain must be authorized first.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' } }, required: ['domain'] } }, { name: 'scp_get_intents', description: 'Get shopping intents from a merchant. Domain must be authorized first.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' }, status: { type: 'array', items: { type: 'string' }, description: 'Filter by status (e.g., [\'active\', \'in_progress\'])' }, limit: { type: 'number', description: 'Maximum number of intents to return', default: 10 } }, required: ['domain'] } }, { name: 'scp_create_intent', description: 'Create a new shopping intent with a merchant. Domain must be authorized with intent:create scope.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' }, base_intent: { type: 'string', description: 'Natural language description of the shopping goal' }, context: { type: 'object', description: 'Additional context about the intent' }, mechanism: { type: 'string', description: 'How the intent was created', default: 'conversational_ai' }, ai_assistant: { type: 'string', description: 'Name of the AI assistant' }, visibility: { type: 'string', description: 'Who can see this intent', enum: ['merchant_only', 'shared_with_customer'], default: 'merchant_only' } }, required: ['domain', 'base_intent'] } }, { name: 'scp_update_intent', description: 'Update an existing shopping intent. Domain must be authorized with intent:write scope.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Merchant domain' }, intent_id: { type: 'string', description: 'Intent ID to update' }, status: { type: 'string', description: 'New status' }, context: { type: 'object', description: 'Updated context' }, add_milestone: { type: 'string', description: 'Add a milestone note' } }, required: ['domain', 'intent_id'] } } ] })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (!args) { throw new Error('Missing tool arguments'); } try { switch (name) { case 'scp_authorize': return await handleAuthorize(args.domain as string, args.email as string, args.scopes as string[]); case 'scp_check_authorization': return await handleCheckAuthorization(args.domain as string); case 'scp_revoke_authorization': return await handleRevokeAuthorization(args.domain as string); case 'scp_discover': return await handleDiscover(args.domain as string); case 'scp_get_orders': return await handleGetOrders(args.domain as string, args); case 'scp_get_loyalty': return await handleGetLoyalty(args.domain as string); case 'scp_get_offers': return await handleGetOffers(args.domain as string, args); case 'scp_get_preferences': return await handleGetPreferences(args.domain as string); case 'scp_get_intents': return await handleGetIntents(args.domain as string, args); case 'scp_create_intent': return await handleCreateIntent(args.domain as string, args); case 'scp_update_intent': return await handleUpdateIntent(args.domain as string, args); default: throw new Error(`Unknown tool: ${name}`); } } catch (error: any) { return { content: [ { type: 'text', text: `Error: ${error.message}` } ], isError: true }; } }); return server; } /** * Tool handler: scp_authorize * Handles both initial authorization and re-authorization with scope changes */ async function handleAuthorize( domain: string, email: string, scopes: string[] ) { // Validate email - reject example/test emails if (email.endsWith('@example.com') || email.includes('example')) { return { content: [ { type: 'text', text: `❌ Invalid email address: ${email}\n\nPlease use a REAL email address, not an example one.\n\nAsk the user: "What email address do you use with ${domain}?"\n\nThen call scp_authorize again with their actual email address.` } ] }; } // Check if already authorized const existing = await getAuthorization(domain); if (existing) { // Compare requested scopes with existing scopes const existingScopes = new Set(existing.scopes); const requestedScopes = new Set(scopes); // Check if scopes are identical const scopesMatch = existingScopes.size === requestedScopes.size && [...requestedScopes].every(s => existingScopes.has(s)); if (scopesMatch && existing.customer_email === email) { // Same scopes and email - just return that they're already authorized return { content: [ { type: 'text', text: `✓ Already authorized with ${domain}\nEmail: ${existing.customer_email}\nScopes: ${existing.scopes.join(', ')}` } ] }; } // Scopes are different or email is different - need to re-authorize if (existing.customer_email !== email) { // Different email - must revoke first return { content: [ { type: 'text', text: `⚠️ Already authorized with ${domain} using ${existing.customer_email}.\n\nTo authorize with a different email (${email}), please revoke the existing authorization first:\nscp_revoke_authorization(domain="${domain}")\n\nThen try authorizing again.` } ] }; } // Same email, different scopes - re-authorize const addedScopes = [...requestedScopes].filter(s => !existingScopes.has(s)); const removedScopes = [...existingScopes].filter(s => !requestedScopes.has(s)); console.error(`[SCP] Re-authorizing ${domain} with scope changes:`, { added: addedScopes, removed: removedScopes }); } // Discover endpoint const discovery = await discoverWithCapabilities(domain); if (!discovery) { throw new Error(`Could not discover Shopper Context Protocol endpoint for ${domain}`); } const { endpoint, capabilities } = discovery; // Verify scopes are supported if (capabilities && capabilities.scopes_supported) { const unsupported = scopes.filter(s => !capabilities.scopes_supported.includes(s)); if (unsupported.length > 0) { throw new Error(`Unsupported scopes: ${unsupported.join(', ')}`); } } // Complete OAuth flow (will trigger email verification) const { tokenResponse } = await completeAuthorizationFlow(endpoint, email, domain, scopes); // Store authorization (will update if existing) const now = Date.now(); await storeAuthorization({ merchant_domain: domain, scp_endpoint: endpoint, customer_id: tokenResponse.customer_id, customer_email: tokenResponse.email, access_token_encrypted: encryptToken(tokenResponse.access_token), refresh_token_encrypted: encryptToken(tokenResponse.refresh_token), expires_at: now + (tokenResponse.expires_in * 1000), scopes: tokenResponse.scope.split(' '), created_at: existing ? existing.created_at : now, // Preserve original creation time updated_at: now }); const action = existing ? 'Re-authorized' : 'Connected to'; return { content: [ { type: 'text', text: `✓ ${action} ${domain}!\nGranted scopes: ${tokenResponse.scope}\nCustomer: ${tokenResponse.email}` } ] }; } /** * Tool handler: scp_check_authorization */ async function handleCheckAuthorization(domain: string) { const info = await getAuthorizationInfo(domain); if (!info.authorized) { return { content: [ { type: 'text', text: `Not authorized with ${domain}` } ] }; } const expiresDate = new Date(info.expires_at!).toISOString(); return { content: [ { type: 'text', text: JSON.stringify({ authorized: true, domain, customer_email: info.customer_email, scopes: info.scopes, authorized_at: new Date(info.authorized_at!).toISOString(), expires_at: expiresDate }, null, 2) } ] }; } /** * Tool handler: scp_revoke_authorization */ async function handleRevokeAuthorization(domain: string) { const auth = await getAuthorization(domain); if (!auth) { throw new Error(`No authorization found for ${domain}`); } // Try to revoke token on server try { const accessToken = await getValidAccessToken(domain); await revokeToken(auth.scp_endpoint, accessToken); } catch (error) { // Revocation failed, but still delete locally } // Delete local authorization await deleteAuthorization(domain); return { content: [ { type: 'text', text: `✓ Revoked authorization for ${domain}` } ] }; } /** * Tool handler: scp_discover */ async function handleDiscover(domain: string) { const discovery = await discoverWithCapabilities(domain); if (!discovery) { throw new Error(`Could not discover Shopper Context Protocol endpoint for ${domain}`); } return { content: [ { type: 'text', text: JSON.stringify({ domain, scp_endpoint: discovery.endpoint, discovery_method: 'dns_txt', capabilities: discovery.capabilities }, null, 2) } ] }; } /** * Helper: Check authorization and return helpful message if not authorized */ async function checkAuthorizationOrThrow(domain: string): Promise<{ auth: any; accessToken: Promise<string> }> { const auth = await getAuthorization(domain); if (!auth) { const errorMessage = `❌ Not authorized with ${domain}.\n\n` + `Please authorize first by calling:\n` + `scp_authorize with domain="${domain}", email="your@email.com", and scopes=["orders", "loyalty", "preferences", "intent:read", "intent:create"]`; throw new Error(errorMessage); } return { auth, accessToken: getValidAccessToken(domain) }; } /** * Tool handler: scp_get_orders */ async function handleGetOrders(domain: string, params: any) { const { auth, accessToken } = await checkAuthorizationOrThrow(domain); const token = await accessToken; const data = await scpClient.getOrders(auth.scp_endpoint, token, { limit: params.limit || 10, offset: params.offset || 0, status: params.status }); return { content: [ { type: 'text', text: JSON.stringify(data, null, 2) } ] }; } /** * Tool handler: scp_get_loyalty */ async function handleGetLoyalty(domain: string) { const { auth, accessToken } = await checkAuthorizationOrThrow(domain); const token = await accessToken; const data = await scpClient.getLoyalty(auth.scp_endpoint, token); return { content: [ { type: 'text', text: JSON.stringify(data, null, 2) } ] }; } /** * Tool handler: scp_get_offers */ async function handleGetOffers(domain: string, params: any) { const { auth, accessToken } = await checkAuthorizationOrThrow(domain); const token = await accessToken; const data = await scpClient.getOffers(auth.scp_endpoint, token, { active_only: params.active_only !== false }); return { content: [ { type: 'text', text: JSON.stringify(data, null, 2) } ] }; } /** * Tool handler: scp_get_preferences */ async function handleGetPreferences(domain: string) { const { auth, accessToken } = await checkAuthorizationOrThrow(domain); const token = await accessToken; const data = await scpClient.getPreferences(auth.scp_endpoint, token); return { content: [ { type: 'text', text: JSON.stringify(data, null, 2) } ] }; } /** * Tool handler: scp_get_intents */ async function handleGetIntents(domain: string, params: any) { const { auth, accessToken } = await checkAuthorizationOrThrow(domain); const token = await accessToken; const data = await scpClient.getIntents(auth.scp_endpoint, token, { status: params.status || ['active', 'in_progress'], limit: params.limit || 10, offset: params.offset || 0 }); return { content: [ { type: 'text', text: JSON.stringify(data, null, 2) } ] }; } /** * Tool handler: scp_create_intent */ async function handleCreateIntent(domain: string, params: any) { const { auth, accessToken } = await checkAuthorizationOrThrow(domain); const token = await accessToken; const data = await scpClient.createIntent(auth.scp_endpoint, token, { base_intent: params.base_intent, mechanism: params.mechanism || 'conversational_ai', ai_assistant: params.ai_assistant, ai_session_id: params.ai_session_id, context: params.context, visibility: params.visibility || 'merchant_only', expires_at: params.expires_at }); return { content: [ { type: 'text', text: JSON.stringify(data, null, 2) } ] }; } /** * Tool handler: scp_update_intent */ async function handleUpdateIntent(domain: string, params: any) { const { auth, accessToken } = await checkAuthorizationOrThrow(domain); const token = await accessToken; // Need to implement updateIntent in http/client.ts const data = await scpClient.makeRPCRequest( auth.scp_endpoint, token, 'scp.update_intent', { intent_id: params.intent_id, status: params.status, context: params.context, add_milestone: params.add_milestone } ); return { content: [ { type: 'text', text: JSON.stringify(data, null, 2) } ] }; } /** * Start MCP server with stdio transport */ export async function startServer() { const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); // Handle graceful shutdown process.on('SIGINT', async () => { await server.close(); process.exit(0); }); process.on('SIGTERM', async () => { await server.close(); process.exit(0); }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/shopper-context-protocol/scp-mcp-wrapper'

If you have feedback or need assistance with the MCP directory API, please join our Discord server