Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ The variables below cover both deployment modes. Credentials variables (`GREEN_A
- `GREEN_API_TRANSPORT` — transport: `stdio`, `sse`, `http`, or `hybrid`
- `GREEN_API_PORT` — HTTP port (defaults to `8090`)
- `GREEN_API_BASE_URL` — public base URL (used for OAuth issuer/redirects, e.g. `https://mcp.example.com`)
- `GREEN_API_WIDGET_DOMAIN` — unique widget origin for ChatGPT Apps submission metadata (defaults to `GREEN_API_BASE_URL`, then `https://mcp.green-api.com`)
- `GREEN_API_AUTH_MODE` — `config` or `proxy`
- `GREEN_API_AUTH_CACHE_TTL` — proxy-auth credential cache TTL (seconds)
- `GREEN_API_WEBHOOK_MODE` — `polling` or `receiver`
Expand Down
62 changes: 54 additions & 8 deletions internal/infrastructure/mcp/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package mcp
import (
"context"
"fmt"
"os"
"strings"

"github.com/green-api/green-api-mcp-gateway/internal/domain"
"github.com/green-api/green-api-mcp-gateway/internal/infrastructure"
Expand All @@ -24,14 +26,18 @@ import (
// - whatsapp://instance/{id}/settings — settings of an instance
func registerResources(s *Server) {
// ui://qr — MCP App widget for instance authorization via QR code
qrMeta := widgetResourceMeta("Interactive QR code widget for authorizing a GREEN-API instance", []string{})
qrResource := mcp.NewResource("ui://qr", "QR Code Widget",
mcp.WithMIMEType("text/html;profile=mcp-app"),
mcp.WithResourceDescription("Interactive QR code widget for authorizing a GREEN-API instance"),
)
qrResource.Meta = mcp.NewMetaFromMap(qrMeta)
s.mcp.AddResource(
mcp.NewResource("ui://qr", "QR Code Widget",
mcp.WithMIMEType("text/html;profile=mcp-app"),
mcp.WithResourceDescription("Interactive QR code widget for authorizing a GREEN-API instance"),
),
qrResource,
mcpgo.ResourceHandlerFunc(func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
Meta: qrMeta,
URI: "ui://qr",
MIMEType: "text/html;profile=mcp-app",
Text: infrastructure.QRAppHTML,
Expand All @@ -41,14 +47,18 @@ func registerResources(s *Server) {
)

// ui://contacts — MCP App widget for browsing the contacts list
contactsMeta := widgetResourceMeta("Interactive contacts list with search and contact details", []string{"https://pps.whatsapp.net"})
contactsResource := mcp.NewResource("ui://contacts", "Contacts List Widget",
mcp.WithMIMEType("text/html;profile=mcp-app"),
mcp.WithResourceDescription("Interactive contacts list with search and contact details"),
)
contactsResource.Meta = mcp.NewMetaFromMap(contactsMeta)
s.mcp.AddResource(
mcp.NewResource("ui://contacts", "Contacts List Widget",
mcp.WithMIMEType("text/html;profile=mcp-app"),
mcp.WithResourceDescription("Interactive contacts list with search and contact details"),
),
contactsResource,
mcpgo.ResourceHandlerFunc(func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
Meta: contactsMeta,
URI: "ui://contacts",
MIMEType: "text/html;profile=mcp-app",
Text: infrastructure.ContactsAppHTML,
Expand Down Expand Up @@ -115,6 +125,42 @@ func registerResources(s *Server) {
)
}

func widgetResourceMeta(description string, resourceDomains []string) map[string]any {
domain := strings.TrimRight(os.Getenv("GREEN_API_WIDGET_DOMAIN"), "/")
if domain == "" {
domain = strings.TrimRight(os.Getenv("GREEN_API_BASE_URL"), "/")
}
if domain == "" {
domain = "https://mcp.green-api.com"
}

apiURL := strings.TrimRight(os.Getenv("GREEN_API_URL"), "/")
if apiURL == "" {
apiURL = "https://api.green-api.com"
}
connectDomains := []string{apiURL}
standardCSP := map[string]any{
"connectDomains": connectDomains,
"resourceDomains": resourceDomains,
}
legacyCSP := map[string]any{
"connect_domains": connectDomains,
"resource_domains": resourceDomains,
}

return map[string]any{
"ui": map[string]any{
"prefersBorder": true,
"csp": standardCSP,
"domain": domain,
},
"openai/widgetDescription": description,
"openai/widgetPrefersBorder": true,
"openai/widgetCSP": legacyCSP,
"openai/widgetDomain": domain,
}
}

// extractInstanceID parses the {id} variable from a resource URI template match.
// The mcp-go library populates req.Params.Arguments with matched template variables.
func extractInstanceID(req mcp.ReadResourceRequest) (uint64, error) {
Expand Down
129 changes: 129 additions & 0 deletions internal/infrastructure/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ func resolveToolIcon() *mcp.Icon {
// addTool registers a tool and wraps its handler with rate limiting + metrics instrumentation.
// It automatically injects the GREEN-API icon into the tool metadata when configured.
func (s *Server) addTool(tool mcp.Tool, handler mcpgo.ToolHandlerFunc) {
applySubmissionReviewHints(&tool)
applyToolTitle(&tool)
applyWidgetToolMeta(&tool)
if s.toolIcon != nil {
tool.Icons = append(tool.Icons, *s.toolIcon)
}
Expand Down Expand Up @@ -126,6 +129,132 @@ func (s *Server) addTool(tool mcp.Tool, handler mcpgo.ToolHandlerFunc) {
}))
}

func applySubmissionReviewHints(tool *mcp.Tool) {
readOnlyTools := map[string]bool{
"whatsapp_check_whatsapp": true,
"whatsapp_get_authorization_code": true,
"whatsapp_get_chat_history": true,
"whatsapp_get_contact_avatar": true,
"whatsapp_get_contact_info": true,
"whatsapp_get_contacts": true,
"whatsapp_get_group_data": true,
"whatsapp_get_instances": true,
"whatsapp_get_message": true,
"whatsapp_get_qr": true,
"whatsapp_get_settings": true,
"whatsapp_get_state": true,
"whatsapp_get_wa_settings": true,
"whatsapp_last_incoming_messages": true,
"whatsapp_last_outgoing_messages": true,
"whatsapp_receive_notification": true,
}
localOnlyTools := map[string]bool{
"whatsapp_connect": true,
"whatsapp_disconnect": true,
}
destructiveTools := map[string]bool{
"whatsapp_delete_instance": true,
"whatsapp_delete_message": true,
"whatsapp_delete_notification": true,
"whatsapp_edit_message": true,
"whatsapp_forward_messages": true,
"whatsapp_leave_group": true,
"whatsapp_logout": true,
"whatsapp_remove_group_admin": true,
"whatsapp_remove_group_participant": true,
"whatsapp_send_contact": true,
"whatsapp_send_file": true,
"whatsapp_send_file_by_upload": true,
"whatsapp_send_location": true,
"whatsapp_send_message": true,
"whatsapp_send_poll": true,
"whatsapp_set_settings": true,
}

readOnly := readOnlyTools[tool.Name]
openWorld := !readOnly && !localOnlyTools[tool.Name]
destructive := destructiveTools[tool.Name]

tool.Annotations.ReadOnlyHint = &readOnly
tool.Annotations.OpenWorldHint = &openWorld
tool.Annotations.DestructiveHint = &destructive
if readOnly {
idempotent := true
tool.Annotations.IdempotentHint = &idempotent
}
}

var toolTitles = map[string]string{
"whatsapp_connect": "Connect Instance",
"whatsapp_disconnect": "Disconnect Instance",
"whatsapp_send_message": "Send Message",
"whatsapp_send_file": "Send File by URL",
"whatsapp_upload_file": "Upload File",
"whatsapp_send_file_by_upload": "Send File",
"whatsapp_send_location": "Send Location",
"whatsapp_send_contact": "Send Contact",
"whatsapp_send_poll": "Send Poll",
"whatsapp_forward_messages": "Forward Messages",
"whatsapp_edit_message": "Edit Message",
"whatsapp_delete_message": "Delete Message",
"whatsapp_get_state": "Get Instance State",
"whatsapp_get_settings": "Get Instance Settings",
"whatsapp_set_settings": "Update Instance Settings",
"whatsapp_get_qr": "Get QR Code",
"whatsapp_check_whatsapp": "Check WhatsApp Number",
"whatsapp_get_contacts": "Get Contacts",
"whatsapp_get_contact_info": "Get Contact Info",
"whatsapp_receive_notification": "Receive Notification",
"whatsapp_create_group": "Create Group",
"whatsapp_get_group_data": "Get Group Data",
"whatsapp_add_group_participant": "Add Group Participant",
"whatsapp_remove_group_participant": "Remove Group Participant",
"whatsapp_reboot": "Reboot Instance",
"whatsapp_logout": "Logout Instance",
"whatsapp_get_authorization_code": "Get Authorization Code",
"whatsapp_get_wa_settings": "Get WhatsApp Account Settings",
"whatsapp_get_chat_history": "Get Chat History",
"whatsapp_get_message": "Get Message",
"whatsapp_last_incoming_messages": "Get Recent Incoming Messages",
"whatsapp_last_outgoing_messages": "Get Recent Outgoing Messages",
"whatsapp_read_chat": "Mark Chat as Read",
"whatsapp_delete_notification": "Delete Notification",
"whatsapp_set_group_admin": "Promote Group Admin",
"whatsapp_remove_group_admin": "Demote Group Admin",
"whatsapp_leave_group": "Leave Group",
"whatsapp_get_contact_avatar": "Get Contact Avatar",
"whatsapp_create_instance": "Create Partner Instance",
"whatsapp_delete_instance": "Delete Partner Instance",
"whatsapp_get_instances": "List Partner Instances",
}

func applyToolTitle(tool *mcp.Tool) {
if title, ok := toolTitles[tool.Name]; ok && tool.Annotations.Title == "" {
tool.Annotations.Title = title
}
}

func applyWidgetToolMeta(tool *mcp.Tool) {
resourceURIByTool := map[string]string{
"whatsapp_get_contacts": "ui://contacts",
"whatsapp_get_qr": "ui://qr",
}
resourceURI, ok := resourceURIByTool[tool.Name]
if !ok {
return
}

fields := map[string]any{}
if tool.Meta != nil {
for key, value := range tool.Meta.AdditionalFields {
fields[key] = value
}
}
fields["ui"] = map[string]any{"resourceUri": resourceURI}
fields["openai/outputTemplate"] = resourceURI
tool.Meta = mcp.NewMetaFromMap(fields)
}

// ServeStdio runs the MCP server over stdio transport (blocking).
func (s *Server) ServeStdio(ctx context.Context) error {
stdioSrv := mcpgo.NewStdioServer(s.mcp)
Expand Down
69 changes: 41 additions & 28 deletions internal/infrastructure/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,15 +624,6 @@ func registerTools(s *Server) {
)

// whatsapp_get_qr
qrTool := mcp.NewTool("whatsapp_get_qr",
mcp.WithDescription("Get QR code for instance authorization. Returns the QR image inline \u2014 scan it in your messenger app to link the instance."),
mcp.WithNumber("instance_id", mcp.Description("Instance ID (not required when authenticated via OAuth)")),
)
qrTool.Meta = &mcp.Meta{
AdditionalFields: map[string]any{
"ui": map[string]any{"resourceUri": "ui://qr"},
},
}
s.addTool(
mcp.NewTool("whatsapp_get_qr",
mcp.WithDescription("Get the QR code for instance authorization"),
Expand All @@ -657,11 +648,13 @@ func registerTools(s *Server) {
small = qr.Message // fallback to original
}
res := mcp.NewToolResultImage("Scan this QR code to authorize the instance", small, "image/png")
res.Meta = &mcp.Meta{AdditionalFields: map[string]any{"ui": map[string]any{"resourceUri": "ui://qr"}}}
res.Meta = qrToolResultMeta(small, "image/png")
res.StructuredContent = map[string]any{"type": "qrCode", "message": small, "mimeType": "image/png"}
return res, nil
}
res := mcp.NewToolResultText(fmt.Sprintf(`{"type":%q,"message":%q}`, qr.Type, qr.Message))
res.Meta = &mcp.Meta{AdditionalFields: map[string]any{"ui": map[string]any{"resourceUri": "ui://qr"}}}
res.StructuredContent = map[string]any{"type": qr.Type, "message": qr.Message}
return res, nil
}),
)
Expand Down Expand Up @@ -695,15 +688,6 @@ func registerTools(s *Server) {
)

// whatsapp_get_contacts
contactsTool := mcp.NewTool("whatsapp_get_contacts",
mcp.WithDescription("Get the contact list"),
mcp.WithNumber("instance_id", mcp.Description("Instance ID (not required when authenticated via OAuth)")),
)
contactsTool.Meta = &mcp.Meta{
AdditionalFields: map[string]any{
"ui": map[string]any{"resourceUri": "ui://contacts"},
},
}
s.addTool(
mcp.NewTool("whatsapp_get_contacts",
mcp.WithDescription("Get the contact list"),
Expand Down Expand Up @@ -1012,10 +996,10 @@ func registerTools(s *Server) {
// whatsapp_get_chat_history
s.addTool(
mcp.NewTool("whatsapp_get_chat_history",
mcp.WithDescription("Get chat message history"),
mcp.WithDescription("Get chat message history. Use a small 'count' (default 50, max 100) to keep responses focused."),
mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")),
mcp.WithString("chat_id", mcp.Required(), mcp.Description("Chat ID (79001234567@c.us or 120363XXX@g.us)")),
mcp.WithNumber("count", mcp.Description("Number of messages (default 100)")),
mcp.WithNumber("count", mcp.Description("Number of messages (default 50, max 100)")),
),
mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
instanceID, err := resolveInstanceID(ctx, req)
Expand All @@ -1026,7 +1010,13 @@ func registerTools(s *Server) {
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
count := int(req.GetFloat("count", 100))
count := int(req.GetFloat("count", 50))
if count < 1 {
count = 50
}
if count > 100 {
count = 100
}
body := domain.GetChatHistoryRequest{ChatID: chatID, Count: count}
result, err := s.client.GetChatHistory(ctx, instanceID, body)
if err != nil {
Expand Down Expand Up @@ -1069,16 +1059,22 @@ func registerTools(s *Server) {
// whatsapp_last_incoming_messages
s.addTool(
mcp.NewTool("whatsapp_last_incoming_messages",
mcp.WithDescription("Get recent incoming messages"),
mcp.WithDescription("Get recent incoming messages from the last N minutes (default 60, max 1440 = 24 hours)."),
mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")),
mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 1440 = 24 hours)")),
mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 60, max 1440)")),
),
mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
instanceID, err := resolveInstanceID(ctx, req)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
minutes := int(req.GetFloat("minutes", 1440))
minutes := int(req.GetFloat("minutes", 60))
if minutes < 1 {
minutes = 60
}
if minutes > 1440 {
minutes = 1440
}
result, err := s.client.LastIncomingMessages(ctx, instanceID, minutes)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
Expand All @@ -1090,16 +1086,22 @@ func registerTools(s *Server) {
// whatsapp_last_outgoing_messages
s.addTool(
mcp.NewTool("whatsapp_last_outgoing_messages",
mcp.WithDescription("Get recent outgoing messages"),
mcp.WithDescription("Get recent outgoing messages from the last N minutes (default 60, max 1440 = 24 hours)."),
mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")),
mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 1440 = 24 hours)")),
mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 60, max 1440)")),
),
mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
instanceID, err := resolveInstanceID(ctx, req)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
minutes := int(req.GetFloat("minutes", 1440))
minutes := int(req.GetFloat("minutes", 60))
if minutes < 1 {
minutes = 60
}
if minutes > 1440 {
minutes = 1440
}
result, err := s.client.LastOutgoingMessages(ctx, instanceID, minutes)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
Expand Down Expand Up @@ -1353,6 +1355,17 @@ func marshalJSON(v any) ([]byte, error) {
return json.Marshal(v)
}

func qrToolResultMeta(base64, mimeType string) *mcp.Meta {
return &mcp.Meta{AdditionalFields: map[string]any{
"ui": map[string]any{"resourceUri": "ui://qr"},
"qr": map[string]any{
"type": "qrCode",
"message": base64,
"mimeType": mimeType,
},
}}
}

// resolveInstanceID returns the instance ID for a tool call.
// Priority: explicit argument > authenticated context (OAuth/proxy mode).
// This allows instance_id to be omitted when the client authenticated via OAuth.
Expand Down
Loading
Loading