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:
| URL | Tenant |
|---|---|
POST /api/v1/store/mcp | Store owner / store staff |
POST /api/v1/admin/mcp | Platform admin |
POST /api/v1/delivery-company/mcp | Delivery 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 Integration | MCP Integration | |
|---|---|---|
| Auth | X-API-Key header | OAuth 2.0 Bearer (PKCE) |
| Client type | Server-to-server | AI agent / human-in-the-loop |
| Interface | REST endpoints | Structured tool calls |
| Scope enforcement | Per-endpoint checks in controller | Per-tool HasScope() check inside the tool class |
| API keys work? | Yes | No — only OAuth bearer |
| Used for | ERP / POS automation | AI 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-..." }
Flow 4 - Store Links an AI Agent to MCP + Messaging Channels
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:
| Link | Purpose | Auth used |
|---|---|---|
| Store -> MCP agent | Allows Cursor, Claude Desktop, or a custom agent service to call Sendy MCP tools for this store | OAuth MCP access token |
| Store -> social channel | Allows Sendy to receive and send WhatsApp / Instagram messages | Meta 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
- Store owner opens the MCP integration page.
- Frontend calls:
GET /api/v1/store/mcp/integration
Authorization: Bearer <store JWT>
- Sendy returns:
- MCP URL:
/api/v1/store/mcp - available tool names
- supported OAuth scopes
- MCP URL:
- 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.
- 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?..." }
- Store owner completes Meta's permission dialog.
- Meta redirects back to Sendy's callback. Sendy stores a
SocialChannel:ChannelType = whatsappExternalAccountId(WhatsApp Business Account ID)- Encrypted Meta access token
- Webhook verify token
IsEnabled = true
- 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.
- 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?..." }
- Store owner grants Instagram messaging permissions in Meta's dialog.
- Sendy stores a
SocialChannel:ChannelType = instagramExternalAccountId(Instagram Business Account ID)- Encrypted Meta access token
IsEnabled = true
- 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.
- 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.
- 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.
- 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
AiMessageJobis 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
| Website chat | |||
|---|---|---|---|
| Connect via | Facebook OAuth redirect | Facebook OAuth redirect | Direct API call |
| Initiation response | JSON { url } | JSON { url } | Channel record + embed snippet |
| Inbound delivery | Meta webhook | Meta webhook | Widget poll endpoint |
| Outbound send | WhatsApp Cloud API | Instagram Graph API | Stored in DB, polled by widget |
| Toggle agent | Same endpoint | Same endpoint | Same endpoint |
| MCP tools work? | Yes | Yes | Yes |
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:
| Channel | Endpoint |
|---|---|
POST /api/v1/webhooks/social/whatsapp | |
POST /api/v1/webhooks/social/instagram |
Server-side flow:
- Verify the Meta webhook signature / verify token.
- Find the connected
SocialChannel. - Upsert
SocialConversationby(SocialChannelId, ExternalContactId). - Deduplicate by
ExternalMessageId. - Persist inbound
SocialMessage. - If
SocialChannel.IsEnabled && SocialChannel.IsAgentEnabled, create anAiMessageJob.
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:
MapMcp("/api/v1/store/mcp")receives the JSON-RPC request.- The MCP bearer scheme validates the OAuth token.
StoreMcpTools.ListConversationschecksHasScope("messaging:read").- The tool resolves
ISocialConversationService. - The service reads tenant-scoped conversations for the store resolved from token claims.
- 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:
- Checks
messaging:read. - Loads conversation metadata and messages.
- 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:
- Checks
messaging:write. - Resolves
ISocialConversationService. - Loads the conversation and connected channel.
- Decrypts the channel access token.
- Sends the text via the correct provider client:
- WhatsApp Cloud API for WhatsApp conversations
- Instagram Graph API for Instagram conversations
- Persists outbound
SocialMessage. - Returns
SocialMessageResponseto 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:
- Checks
messaging:write. - Marks the conversation as resolved.
- 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
| Component | Responsibility |
|---|---|
| AI agent | Decides what to say and which MCP tools to call |
| MCP endpoint | Authenticates token, validates scopes, routes tool calls |
| MCP tools | Thin tool facade over store, order, catalog, ticket, and messaging services |
| Social messaging services | Own conversation persistence, channel token use, and outbound provider send |
| Meta channel APIs | Deliver inbound and outbound WhatsApp / Instagram messages |
Failure points to handle
| Failure | Expected behavior |
|---|---|
| Access token expired | Agent refreshes token or restarts OAuth |
| Missing MCP scope | Tool returns 403 with missing scope message |
| Conversation not found | Messaging service returns not-found response |
| Channel disabled | Message send should fail or be blocked by social service rules |
| Meta send fails | Outbound message is not confirmed; service returns error/failure status |
| Agent provider fails | AiMessageJob retries with backoff when using built-in dispatcher |
| Duplicate webhook delivery | Inbound message is deduplicated by external message ID |
Scope Reference
| Tool | Required scope |
|---|---|
list_products | catalog:read |
search_products | catalog:read |
get_product | catalog:read |
list_orders | orders:read |
track_order | orders:read |
create_order | orders:write |
create_ticket | tickets:write |
list_conversations | messaging:read |
get_conversation | messaging:read |
send_conversation_message | messaging:write |
resolve_conversation | messaging: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)
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /api/v1/store/mcp/integration | store.oauth_mcp.redirect_uris.view | Returns MCP URL, tool names, and required scopes |
GET | /api/v1/store/oauth-mcp/redirect-uris | store.oauth_mcp.redirect_uris.view | List registered redirect URIs |
POST | /api/v1/store/oauth-mcp/redirect-uris | store.oauth_mcp.redirect_uris.manage | Register a new redirect URI |
DELETE | /api/v1/store/oauth-mcp/redirect-uris/{id} | store.oauth_mcp.redirect_uris.manage | Remove a redirect URI |
OAuth endpoints (public)
| Method | Path | Description |
|---|---|---|
GET | /api/v1/oauth/authorize | Start PKCE authorization; redirects to login if unauthenticated |
POST | /api/v1/oauth/login | Submit credentials during authorization; returns redirect URL |
POST | /api/v1/oauth/token | Exchange authorization code for access + refresh token |
MCP endpoints (OAuth bearer)
| Method | Path | Tenant |
|---|---|---|
POST | /api/v1/store/mcp | Store owner / store staff |
POST | /api/v1/admin/mcp | Platform admin |
POST | /api/v1/delivery-company/mcp | Delivery company |
Source References
| Layer | File |
|---|---|
| MCP tools (all tools) | Sendy.Api/Mcp/StoreMcpTools.cs |
| MCP info controller (store) | Sendy.Api/Controllers/V1/Store/StoreMcpIntegrationController.cs |
| Redirect URI controller | Sendy.Api/Controllers/V1/Store/StoreOAuthMcpRedirectUrisController.cs |
| MCP info service | Sendy.Application/Services/Mcp/McpIntegrationInfoService.cs |
| Ticket creation service (MCP) | Sendy.Application/Services/Mcp/McpTicketCreationService.cs |
| OAuth authorization service | Sendy.Infrastructure/Services/Auth/OAuthAuthorizationCodeService.cs |
| OAuth client registry | Sendy.Infrastructure/Services/Auth/OAuthMcpClientRegistry.cs |
| Redirect URI service | Sendy.Application/Services/Store/StoreOAuthMcpRedirectUriService.cs |
| OAuth client entity | Sendy.Domain/Entities/Integration/OAuthMcpClient.cs |
| OAuth options | Sendy.Application/Common/OAuthMcpOptions.cs |
| Social channel controller | Sendy.Api/Controllers/V1/Store/StoreSocialChannelsController.cs |
| Social inbound service | Sendy.Application/Services/Messaging/SocialInboundService.cs |
| Social conversation service | Sendy.Application/Services/Messaging/SocialConversationService.cs |
| Social channel service | Sendy.Application/Services/Messaging/SocialChannelService.cs |
| AI message dispatcher | Sendy.Infrastructure/Jobs/AiMessageDispatchJob.cs |
| WhatsApp provider client | Sendy.Infrastructure/Services/Messaging/WhatsAppCloudApiClient.cs |
| Instagram provider client | Sendy.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