Flows

MCP Flow

The MCP authorization and tool-call flow.

Store MCP Server — Full Feature Flow

Feature area: MCP / AI Agent Integration
Branch: phase-2
Last updated: 2026-06-09


Overview

The Store MCP Server exposes Sendy store data and operations as Model Context Protocol (MCP) tools so that AI clients (Cursor, Claude Desktop, custom agents) can act on behalf of a store. Unlike the REST API-key integration, MCP uses OAuth 2.0 PKCE bearer tokens — the AI client authenticates as the store owner/staff and calls structured tools instead of raw HTTP endpoints.

Three MCP endpoints exist, one per tenant area:

URLTenant
POST /api/v1/store/mcpStore owner / store staff
POST /api/v1/admin/mcpPlatform admin
POST /api/v1/delivery-company/mcpDelivery company

Transport: Streamable HTTP (MapMcp). All three expose the same tool surface; context (which store, which DC) is resolved from the JWT claims of the bearer token.


How It Differs from API-Key Integration

API-Key IntegrationMCP Integration
AuthX-API-Key headerOAuth 2.0 Bearer (PKCE)
Client typeServer-to-serverAI agent / human-in-the-loop
InterfaceREST endpointsStructured tool calls
Scope enforcementPer-endpoint checks in controllerPer-tool HasScope() check inside the tool class
API keys work?YesNo — only OAuth bearer
Used forERP / POS automationAI assistants, Cursor, Claude Desktop

Flow 1 — Register a Redirect URI

Before the OAuth flow can complete, the redirect_uri must be registered with Sendy. This is a one-time setup step performed by the store owner or admin.

Store registers their own redirect URI:

POST /api/v1/store/oauth-mcp/redirect-uris
Authorization: Bearer <store JWT>
Permission: store.oauth_mcp.redirect_uris.manage

Body:
{
  "clientId": "<registered_client_id>",
  "redirectUri": "https://my-agent.example.com/callback"
}

Admin can manage all clients and URIs:

GET/POST/DELETE /api/v1/admin/oauth-mcp/clients
GET/POST/DELETE /api/v1/admin/oauth-mcp/redirect-uris

Store lists their registered URIs:

GET /api/v1/store/oauth-mcp/redirect-uris?clientId=<optional>
Permission: store.oauth_mcp.redirect_uris.view

Flow 2 — OAuth PKCE Authorization (Get a Bearer Token)

The MCP client must obtain an OAuth 2.0 access token before calling any tool. Sendy implements PKCE authorization code (code_challenge_method=S256).

Step 1 — Start authorization

GET /api/v1/oauth/authorize
  ?response_type=code
  &client_id=<registered_client_id>
  &redirect_uri=<registered_redirect_uri>
  &code_challenge=<base64url(SHA-256(code_verifier))>
  &code_challenge_method=S256
  &state=<random>
  &scope=catalog:read orders:read orders:write tickets:write messaging:read messaging:write

If the user is not already authenticated, the server redirects to the login UI with ?request_id=<id>.

Step 2 — User logs in (if not already authenticated)

POST /api/v1/oauth/login
Content-Type: application/json
Accept: application/json

{
  "requestId": "<id from step 1>",
  "phone": "...",
  "password": "..."
}

Response (JSON mode):

{ "data": { "redirectUrl": "https://my-agent.example.com/callback?code=...&state=..." } }

Without Accept: application/json, the server issues a 302 redirect directly.

Step 3 — Exchange code for token

POST /api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<authorization_code>
&code_verifier=<plain_verifier>
&client_id=<client_id>
&redirect_uri=<redirect_uri>

Response:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "catalog:read orders:read orders:write tickets:write messaging:read messaging:write"
}

Step 4 — Call the MCP endpoint

POST /api/v1/store/mcp
Authorization: Bearer <access_token>
Content-Type: application/json

{ "method": "tools/call", "params": { "name": "list_orders", "arguments": { "page": 1, "perPage": 10 } } }

Flow 3 — Using MCP Tools

Once authenticated, the AI client calls tools via the MCP protocol. Each tool enforces its own scope — the server returns 403 if the token lacks the required scope.

Catalog tools (catalog:read)

list_products — list all active products in the store catalog.

tools/call → list_products
{}

search_products — full-text search by SKU, name, or description (max 50 results).

tools/call → search_products
{ "query": "blue t-shirt" }

get_product — get one product by UUID.

tools/call → get_product
{ "productId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }

Order tools (orders:read / orders:write)

list_orders — paginated order list with optional filters.

tools/call → list_orders
{
  "page": 1,
  "perPage": 15,
  "status": "pending",          // optional
  "search": "ORD-20260506",     // optional — searches public id
  "fulfillmentType": "from_to"  // optional: from_to | warehouse
}

track_order — fetch order detail by public ID.

tools/call → track_order
{ "publicId": "ORD-20260506-ABC123" }

create_order — create a store order (same shape as the REST integration endpoint).

tools/call → create_order
{
  "customerName": "Ali Hassan",
  "customerPhone": "9647701234567",
  "addressProvinceCode": "BGH",
  "addressAreaId": 5,
  "items": [{ "productId": "...", "quantity": 2 }]
}

Ticket tool (tickets:write)

create_ticket — creates a support ticket. Routes to the correct tenant area based on which MCP URL was called (/store/mcp, /admin/mcp, or /delivery-company/mcp).

tools/call → create_ticket
{
  "subject": "Missing item in order ORD-20260506-ABC123",
  "body": "Customer reports item #3 was not delivered."
}

Messaging tools (messaging:read / messaging:write)

list_conversations — lists open/pending/resolved conversations from connected WhatsApp or Instagram channels.

tools/call → list_conversations
{
  "channelType": "WhatsApp",   // optional: WhatsApp | Instagram
  "status": "Open",            // optional: Open | Resolved | Pending
  "page": 1
}

get_conversation — get the full message history for a specific conversation.

tools/call → get_conversation
{ "conversationId": "3fa85f64-..." }

send_conversation_message — send a text message to a customer in an existing conversation.

tools/call → send_conversation_message
{
  "conversationId": "3fa85f64-...",
  "text": "Your order ships tomorrow!"
}

resolve_conversation — mark a conversation as resolved/closed.

tools/call → resolve_conversation
{ "conversationId": "3fa85f64-..." }

This flow describes how a store connects an external AI agent to Sendy's MCP server, then lets that agent handle messages from WhatsApp or Instagram by reading context and replying through MCP tools.

There are two separate links:

LinkPurposeAuth used
Store -> MCP agentAllows Cursor, Claude Desktop, or a custom agent service to call Sendy MCP tools for this storeOAuth MCP access token
Store -> social channelAllows Sendy to receive and send WhatsApp / Instagram messagesMeta channel access token, encrypted by Sendy

The MCP token does not replace the Meta channel token. The MCP token lets the AI agent act inside Sendy; the channel token lets Sendy deliver messages to the customer.

Step 1 - Store prepares the MCP client

  1. Store owner opens the MCP integration page.
  2. Frontend calls:
GET /api/v1/store/mcp/integration
Authorization: Bearer <store JWT>
  1. Sendy returns:
    • MCP URL: /api/v1/store/mcp
    • available tool names
    • supported OAuth scopes
  2. Store registers the agent callback URL:
POST /api/v1/store/oauth-mcp/redirect-uris
Authorization: Bearer <store JWT>

{
  "clientId": "<agent_client_id>",
  "redirectUri": "https://agent.example.com/oauth/callback"
}

This creates the trust boundary between the store and the external agent client. Only registered redirect URIs can complete the PKCE flow.

Step 2 - Store authorizes the AI agent

The AI agent starts OAuth PKCE and requests only the scopes it needs. For a messaging automation agent, the minimum useful set is usually:

catalog:read orders:read tickets:write messaging:read messaging:write

If the agent will create orders from chat, add:

orders:write

After login and consent, the agent exchanges the authorization code for:

{
  "access_token": "eyJ...",
  "refresh_token": "...",
  "scope": "catalog:read orders:read tickets:write messaging:read messaging:write"
}

The agent stores the OAuth token on its side and sends it on every MCP request:

Authorization: Bearer <oauth_access_token>

Step 3 - Store connects a channel

Sendy supports three channel types. Each connects differently. All three result in a SocialChannel record with IsAgentEnabled=true before MCP messaging tools can act on their conversations.


Channel A — WhatsApp

WhatsApp uses a Facebook OAuth redirect flow.

  1. Call the initiation endpoint — the response is JSON { url }, not a redirect. The frontend must navigate to the returned URL:
GET /api/v1/store/social/channels/auth/whatsapp
Authorization: Bearer <store JWT>

Response: { "url": "https://www.facebook.com/dialog/oauth?..." }
  1. Store owner completes Meta's permission dialog.
  2. Meta redirects back to Sendy's callback. Sendy stores a SocialChannel:
    • ChannelType = whatsapp
    • ExternalAccountId (WhatsApp Business Account ID)
    • Encrypted Meta access token
    • Webhook verify token
    • IsEnabled = true
  3. Enable the AI agent for this channel:
POST /api/v1/store/social/channels/{channelId}/toggle-agent
Authorization: Bearer <store JWT>
{ "enabled": true }

From this point, inbound messages from POST /api/v1/webhooks/social/whatsapp create AiMessageJob entries that the agent can handle via MCP.


Channel B — Instagram

Instagram follows the exact same Facebook OAuth redirect flow as WhatsApp.

  1. Call the initiation endpoint — again JSON { url }, not a redirect:
GET /api/v1/store/social/channels/auth/instagram
Authorization: Bearer <store JWT>

Response: { "url": "https://www.facebook.com/dialog/oauth?..." }
  1. Store owner grants Instagram messaging permissions in Meta's dialog.
  2. Sendy stores a SocialChannel:
    • ChannelType = instagram
    • ExternalAccountId (Instagram Business Account ID)
    • Encrypted Meta access token
    • IsEnabled = true
  3. Enable the agent:
POST /api/v1/store/social/channels/{channelId}/toggle-agent
Authorization: Bearer <store JWT>
{ "enabled": true }

Inbound messages arrive at POST /api/v1/webhooks/social/instagram and follow the same job enqueue path.


Channel C — Website chat

Website chat does not use Facebook OAuth. The store creates the channel directly and embeds a widget snippet on their site.

  1. Create the website channel:
POST /api/v1/store/social/channels
Authorization: Bearer <store JWT>
{ "channelType": "website", "name": "Website Chat" }

Sendy generates a unique channelToken and returns an embed snippet.

  1. The store pastes the embed snippet into their website <head>. The widget sends and polls messages at:
POST /api/v1/widget/chat/{channelToken}/messages   ← visitor sends
GET  /api/v1/widget/chat/{channelToken}/messages   ← visitor polls

These endpoints are [AllowAnonymous] and do not require OAuth.

  1. Enable the agent for the website channel:
POST /api/v1/store/social/channels/{channelId}/toggle-agent
Authorization: Bearer <store JWT>
{ "enabled": true }

Important difference: Website chat uses poll-based delivery — there is no Meta webhook. Inbound messages are persisted directly when the widget POSTs. The AiMessageJob is enqueued the same way as for WhatsApp/Instagram, so MCP messaging tools (list_conversations, get_conversation, send_conversation_message) work identically once the job runs.


Channel comparison

WhatsAppInstagramWebsite chat
Connect viaFacebook OAuth redirectFacebook OAuth redirectDirect API call
Initiation responseJSON { url }JSON { url }Channel record + embed snippet
Inbound deliveryMeta webhookMeta webhookWidget poll endpoint
Outbound sendWhatsApp Cloud APIInstagram Graph APIStored in DB, polled by widget
Toggle agentSame endpointSame endpointSame endpoint
MCP tools work?YesYesYes

From this point, incoming channel messages can be handled by an agent workflow.

Step 4 - Customer message enters Sendy

When a customer sends a WhatsApp or Instagram message:

Customer -> WhatsApp / Instagram -> Sendy webhook

Webhook endpoints:

ChannelEndpoint
WhatsAppPOST /api/v1/webhooks/social/whatsapp
InstagramPOST /api/v1/webhooks/social/instagram

Server-side flow:

  1. Verify the Meta webhook signature / verify token.
  2. Find the connected SocialChannel.
  3. Upsert SocialConversation by (SocialChannelId, ExternalContactId).
  4. Deduplicate by ExternalMessageId.
  5. Persist inbound SocialMessage.
  6. If SocialChannel.IsEnabled && SocialChannel.IsAgentEnabled, create an AiMessageJob.

At this point the message is inside Sendy and available to MCP tools through list_conversations and get_conversation.


Flow 5 - Message -> Agent -> MCP Tool Calls -> Channel Response

This is the deeper MCP-side flow after the channel message exists in Sendy.

1. Agent discovers work

The agent can poll or be triggered by Sendy's internal job system. From MCP's perspective, the agent reads the queue of active customer conversations:

{
  "method": "tools/call",
  "params": {
    "name": "list_conversations",
    "arguments": {
      "status": "Open",
      "page": 1
    }
  }
}

MCP server behavior:

  1. MapMcp("/api/v1/store/mcp") receives the JSON-RPC request.
  2. The MCP bearer scheme validates the OAuth token.
  3. StoreMcpTools.ListConversations checks HasScope("messaging:read").
  4. The tool resolves ISocialConversationService.
  5. The service reads tenant-scoped conversations for the store resolved from token claims.
  6. Result is returned as ApiResponse<PaginatedListResponse<ConversationListItemResponse>>.

2. Agent loads the exact conversation

The agent fetches the message history before answering:

{
  "method": "tools/call",
  "params": {
    "name": "get_conversation",
    "arguments": {
      "conversationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
    }
  }
}

MCP server behavior:

  1. Checks messaging:read.
  2. Loads conversation metadata and messages.
  3. Returns inbound/outbound message history, channel context, and status.

The agent should use this result as the source of truth for:

  • customer question
  • previous replies
  • whether a human already answered
  • whether the thread is already resolved

3. Agent gathers business context through MCP

The agent chooses tools based on the customer message.

For product questions:

{
  "method": "tools/call",
  "params": {
    "name": "search_products",
    "arguments": { "query": "blue t-shirt" }
  }
}

For order status questions:

{
  "method": "tools/call",
  "params": {
    "name": "track_order",
    "arguments": { "publicId": "ORD-20260506-ABC123" }
  }
}

For complaints or escalation:

{
  "method": "tools/call",
  "params": {
    "name": "create_ticket",
    "arguments": {
      "subject": "Customer complaint from WhatsApp",
      "body": "Customer says item was missing from order ORD-20260506-ABC123."
    }
  }
}

For order creation from chat, if the token has orders:write:

{
  "method": "tools/call",
  "params": {
    "name": "create_order",
    "arguments": {
      "customerName": "Ali Hassan",
      "customerPhone": "9647701234567",
      "addressProvinceCode": "BGH",
      "addressAreaId": 5,
      "items": [
        { "productId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "quantity": 2 }
      ]
    }
  }
}

Each tool is independently authorized. A token with messaging:write cannot read orders unless it also has orders:read.

4. Agent sends the customer response through MCP

When the agent has enough context, it replies through send_conversation_message:

{
  "method": "tools/call",
  "params": {
    "name": "send_conversation_message",
    "arguments": {
      "conversationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "text": "Your order ORD-20260506-ABC123 is currently out for delivery. I will keep this chat open until it is delivered."
    }
  }
}

MCP server behavior:

  1. Checks messaging:write.
  2. Resolves ISocialConversationService.
  3. Loads the conversation and connected channel.
  4. Decrypts the channel access token.
  5. Sends the text via the correct provider client:
    • WhatsApp Cloud API for WhatsApp conversations
    • Instagram Graph API for Instagram conversations
  6. Persists outbound SocialMessage.
  7. Returns SocialMessageResponse to the agent.

The MCP response is not the customer-facing message by itself. It is the server acknowledgment that Sendy accepted/sent the outbound message through the linked channel.

5. Agent closes the loop

If the customer issue is complete, the agent can close the conversation:

{
  "method": "tools/call",
  "params": {
    "name": "resolve_conversation",
    "arguments": {
      "conversationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
    }
  }
}

MCP server behavior:

  1. Checks messaging:write.
  2. Marks the conversation as resolved.
  3. Returns an ApiResponse<object>.

If a later inbound message arrives from the same customer, the webhook flow can reopen or continue the same conversation depending on the social messaging service behavior.

End-to-end sequence

Store Owner
  |
  | 1. Register agent redirect URI
  v
Sendy Store OAuth MCP
  |
  | 2. OAuth PKCE consent
  v
AI Agent gets MCP bearer token
  |
  | 3. Store connects WhatsApp / Instagram channel
  v
Sendy SocialChannel { IsEnabled=true, IsAgentEnabled=true }
  |
  | 4. Customer sends message
  v
Meta webhook -> Sendy SocialInboundService
  |
  | 5. Persist SocialConversation + inbound SocialMessage
  v
AI Agent / Dispatcher
  |
  | 6. tools/call: get_conversation
  | 7. tools/call: search_products / track_order / create_ticket / create_order
  | 8. tools/call: send_conversation_message
  v
Sendy MCP StoreMcpTools
  |
  | 9. ISocialConversationService sends via WhatsApp/Instagram client
  v
Customer receives response in the original channel

Responsibility split

ComponentResponsibility
AI agentDecides what to say and which MCP tools to call
MCP endpointAuthenticates token, validates scopes, routes tool calls
MCP toolsThin tool facade over store, order, catalog, ticket, and messaging services
Social messaging servicesOwn conversation persistence, channel token use, and outbound provider send
Meta channel APIsDeliver inbound and outbound WhatsApp / Instagram messages

Failure points to handle

FailureExpected behavior
Access token expiredAgent refreshes token or restarts OAuth
Missing MCP scopeTool returns 403 with missing scope message
Conversation not foundMessaging service returns not-found response
Channel disabledMessage send should fail or be blocked by social service rules
Meta send failsOutbound message is not confirmed; service returns error/failure status
Agent provider failsAiMessageJob retries with backoff when using built-in dispatcher
Duplicate webhook deliveryInbound message is deduplicated by external message ID

Scope Reference

ToolRequired scope
list_productscatalog:read
search_productscatalog:read
get_productcatalog:read
list_ordersorders:read
track_orderorders:read
create_orderorders:write
create_tickettickets:write
list_conversationsmessaging:read
get_conversationmessaging:read
send_conversation_messagemessaging:write
resolve_conversationmessaging:write

Flow 6 — Discover the MCP Endpoint (Info)

Each tenant area exposes an info endpoint that returns the MCP server URL, available tool names, and required scopes. AI clients can call this on first connection to self-configure.

GET /api/v1/store/mcp/integration
Authorization: Bearer <store JWT>
Permission: store.oauth_mcp.redirect_uris.view  (or store_owner)

Response:

{
  "mcpUrl": "https://<host>/api/v1/store/mcp",
  "tools": ["list_products", "search_products", "get_product", "list_orders", "track_order", "create_order", "create_ticket", "list_conversations", "get_conversation", "send_conversation_message", "resolve_conversation"],
  "scopes": ["catalog:read", "orders:read", "orders:write", "tickets:write", "messaging:read", "messaging:write"]
}

Complete Flow Diagram

AI Client (Cursor / Claude Desktop / Agent)      Sendy API                  Store DB
                                                                              
           1. GET /oauth/authorize?scope=...                                  
         ├──────────────────────────────────────────►│                          
         │◄─ 302  login UI with ?request_id= ───────┤                          
                                                                              
           2. POST /oauth/login { requestId, ... }                            
         ├──────────────────────────────────────────►│                          
         │◄─ { redirectUrl: "...?code=..." } ────────┤                          
                                                                              
           3. POST /oauth/token { code, verifier }                            
         ├──────────────────────────────────────────►│                          
         │◄─ { access_token, refresh_token } ────────┤                          
                                                                              
           4. POST /api/v1/store/mcp                                          
              Authorization: Bearer <token>                                   
              tools/call  list_orders                                        
         ├──────────────────────────────────────────►│                          
                                                    ├── HasScope("orders:read")
                                                    ├── IStoreOrderService ───►│
                                                    │◄── PaginatedList ─────────┤
         │◄─ ApiResponse<PaginatedList<...>> ────────┤                          
                                                                              
           5. tools/call  create_order                                       
         ├──────────────────────────────────────────►│                          
                                                    ├── HasScope("orders:write")
                                                    ├── IStoreOrderService ───►│
                                                    │◄── OrderDetail ───────────┤
         │◄─ ApiResponse<object> ────────────────────┤                          

Cursor / Claude Desktop Config Example

{
  "mcpServers": {
    "sendy-store": {
      "url": "https://<host>/api/v1/store/mcp",
      "headers": {
        "Authorization": "Bearer <oauth_access_token>"
      }
    }
  }
}

Server Configuration Keys

OAuthMcp:SigningKey                          # 32+ byte base-64 — required
OAuthMcp:Issuer
OAuthMcp:Audience
OAuthMcp:AuthorizationCodeLifetimeSeconds   # default 60
OAuthMcp:AccessTokenLifetimeSeconds         # default 3600
OAuthMcp:RefreshTokenLifetimeSeconds        # default 86400

Endpoints Reference

Store-side management (requires JWT + permission)

MethodPathPermissionDescription
GET/api/v1/store/mcp/integrationstore.oauth_mcp.redirect_uris.viewReturns MCP URL, tool names, and required scopes
GET/api/v1/store/oauth-mcp/redirect-urisstore.oauth_mcp.redirect_uris.viewList registered redirect URIs
POST/api/v1/store/oauth-mcp/redirect-urisstore.oauth_mcp.redirect_uris.manageRegister a new redirect URI
DELETE/api/v1/store/oauth-mcp/redirect-uris/{id}store.oauth_mcp.redirect_uris.manageRemove a redirect URI

OAuth endpoints (public)

MethodPathDescription
GET/api/v1/oauth/authorizeStart PKCE authorization; redirects to login if unauthenticated
POST/api/v1/oauth/loginSubmit credentials during authorization; returns redirect URL
POST/api/v1/oauth/tokenExchange authorization code for access + refresh token

MCP endpoints (OAuth bearer)

MethodPathTenant
POST/api/v1/store/mcpStore owner / store staff
POST/api/v1/admin/mcpPlatform admin
POST/api/v1/delivery-company/mcpDelivery company

Source References

LayerFile
MCP tools (all tools)Sendy.Api/Mcp/StoreMcpTools.cs
MCP info controller (store)Sendy.Api/Controllers/V1/Store/StoreMcpIntegrationController.cs
Redirect URI controllerSendy.Api/Controllers/V1/Store/StoreOAuthMcpRedirectUrisController.cs
MCP info serviceSendy.Application/Services/Mcp/McpIntegrationInfoService.cs
Ticket creation service (MCP)Sendy.Application/Services/Mcp/McpTicketCreationService.cs
OAuth authorization serviceSendy.Infrastructure/Services/Auth/OAuthAuthorizationCodeService.cs
OAuth client registrySendy.Infrastructure/Services/Auth/OAuthMcpClientRegistry.cs
Redirect URI serviceSendy.Application/Services/Store/StoreOAuthMcpRedirectUriService.cs
OAuth client entitySendy.Domain/Entities/Integration/OAuthMcpClient.cs
OAuth optionsSendy.Application/Common/OAuthMcpOptions.cs
Social channel controllerSendy.Api/Controllers/V1/Store/StoreSocialChannelsController.cs
Social inbound serviceSendy.Application/Services/Messaging/SocialInboundService.cs
Social conversation serviceSendy.Application/Services/Messaging/SocialConversationService.cs
Social channel serviceSendy.Application/Services/Messaging/SocialChannelService.cs
AI message dispatcherSendy.Infrastructure/Jobs/AiMessageDispatchJob.cs
WhatsApp provider clientSendy.Infrastructure/Services/Messaging/WhatsAppCloudApiClient.cs
Instagram provider clientSendy.Infrastructure/Services/Messaging/InstagramGraphApiClient.cs

For messaging tool internals and AI agent behavior, see social-messaging-flow.md.
For the reference guide (config, Cursor setup, scope table), see DOCS/03-integrations/store-mcp-server.md.

Source: DOCS/flows/store-mcp-flow.md