Skip to content

Commit 821435d

Browse files
author
nmolchanov
committed
feat: ChatGPT Apps submission readiness — widgets, OpenAI notation, tool metadata
- add GREEN_API_WIDGET_DOMAIN env for ChatGPT Apps widget origin - enrich MCP tool metadata: human-readable titles, idempotentHint for read-only tools, OpenAI-style annotations, descriptions, docs hooks - expose docs and resources for tool discovery; add tools metadata tests - widget CSP connectDomains derived from GREEN_API_URL - update QR widget template for ChatGPT Apps compatibility - clamp count/minutes in chat-history and recent-messages tools
1 parent 14a4441 commit 821435d

6 files changed

Lines changed: 437 additions & 74 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ The variables below cover both deployment modes. Credentials variables (`GREEN_A
104104
- `GREEN_API_TRANSPORT` — transport: `stdio`, `sse`, `http`, or `hybrid`
105105
- `GREEN_API_PORT` — HTTP port (defaults to `8090`)
106106
- `GREEN_API_BASE_URL` — public base URL (used for OAuth issuer/redirects, e.g. `https://mcp.example.com`)
107+
- `GREEN_API_WIDGET_DOMAIN` — unique widget origin for ChatGPT Apps submission metadata (defaults to `GREEN_API_BASE_URL`, then `https://mcp.green-api.com`)
107108
- `GREEN_API_AUTH_MODE` — `config` or `proxy`
108109
- `GREEN_API_AUTH_CACHE_TTL` — proxy-auth credential cache TTL (seconds)
109110
- `GREEN_API_WEBHOOK_MODE` — `polling` or `receiver`

internal/infrastructure/mcp/resources.go

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package mcp
66
import (
77
"context"
88
"fmt"
9+
"os"
10+
"strings"
911

1012
"github.com/green-api/green-api-mcp-gateway/internal/domain"
1113
"github.com/green-api/green-api-mcp-gateway/internal/infrastructure"
@@ -24,14 +26,18 @@ import (
2426
// - whatsapp://instance/{id}/settings — settings of an instance
2527
func registerResources(s *Server) {
2628
// ui://qr — MCP App widget for instance authorization via QR code
29+
qrMeta := widgetResourceMeta("Interactive QR code widget for authorizing a GREEN-API instance", []string{})
30+
qrResource := mcp.NewResource("ui://qr", "QR Code Widget",
31+
mcp.WithMIMEType("text/html;profile=mcp-app"),
32+
mcp.WithResourceDescription("Interactive QR code widget for authorizing a GREEN-API instance"),
33+
)
34+
qrResource.Meta = mcp.NewMetaFromMap(qrMeta)
2735
s.mcp.AddResource(
28-
mcp.NewResource("ui://qr", "QR Code Widget",
29-
mcp.WithMIMEType("text/html;profile=mcp-app"),
30-
mcp.WithResourceDescription("Interactive QR code widget for authorizing a GREEN-API instance"),
31-
),
36+
qrResource,
3237
mcpgo.ResourceHandlerFunc(func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
3338
return []mcp.ResourceContents{
3439
mcp.TextResourceContents{
40+
Meta: qrMeta,
3541
URI: "ui://qr",
3642
MIMEType: "text/html;profile=mcp-app",
3743
Text: infrastructure.QRAppHTML,
@@ -41,14 +47,18 @@ func registerResources(s *Server) {
4147
)
4248

4349
// ui://contacts — MCP App widget for browsing the contacts list
50+
contactsMeta := widgetResourceMeta("Interactive contacts list with search and contact details", []string{"https://pps.whatsapp.net"})
51+
contactsResource := mcp.NewResource("ui://contacts", "Contacts List Widget",
52+
mcp.WithMIMEType("text/html;profile=mcp-app"),
53+
mcp.WithResourceDescription("Interactive contacts list with search and contact details"),
54+
)
55+
contactsResource.Meta = mcp.NewMetaFromMap(contactsMeta)
4456
s.mcp.AddResource(
45-
mcp.NewResource("ui://contacts", "Contacts List Widget",
46-
mcp.WithMIMEType("text/html;profile=mcp-app"),
47-
mcp.WithResourceDescription("Interactive contacts list with search and contact details"),
48-
),
57+
contactsResource,
4958
mcpgo.ResourceHandlerFunc(func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
5059
return []mcp.ResourceContents{
5160
mcp.TextResourceContents{
61+
Meta: contactsMeta,
5262
URI: "ui://contacts",
5363
MIMEType: "text/html;profile=mcp-app",
5464
Text: infrastructure.ContactsAppHTML,
@@ -115,6 +125,42 @@ func registerResources(s *Server) {
115125
)
116126
}
117127

128+
func widgetResourceMeta(description string, resourceDomains []string) map[string]any {
129+
domain := strings.TrimRight(os.Getenv("GREEN_API_WIDGET_DOMAIN"), "/")
130+
if domain == "" {
131+
domain = strings.TrimRight(os.Getenv("GREEN_API_BASE_URL"), "/")
132+
}
133+
if domain == "" {
134+
domain = "https://mcp.green-api.com"
135+
}
136+
137+
apiURL := strings.TrimRight(os.Getenv("GREEN_API_URL"), "/")
138+
if apiURL == "" {
139+
apiURL = "https://api.green-api.com"
140+
}
141+
connectDomains := []string{apiURL}
142+
standardCSP := map[string]any{
143+
"connectDomains": connectDomains,
144+
"resourceDomains": resourceDomains,
145+
}
146+
legacyCSP := map[string]any{
147+
"connect_domains": connectDomains,
148+
"resource_domains": resourceDomains,
149+
}
150+
151+
return map[string]any{
152+
"ui": map[string]any{
153+
"prefersBorder": true,
154+
"csp": standardCSP,
155+
"domain": domain,
156+
},
157+
"openai/widgetDescription": description,
158+
"openai/widgetPrefersBorder": true,
159+
"openai/widgetCSP": legacyCSP,
160+
"openai/widgetDomain": domain,
161+
}
162+
}
163+
118164
// extractInstanceID parses the {id} variable from a resource URI template match.
119165
// The mcp-go library populates req.Params.Arguments with matched template variables.
120166
func extractInstanceID(req mcp.ReadResourceRequest) (uint64, error) {

internal/infrastructure/mcp/server.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ func resolveToolIcon() *mcp.Icon {
8888
// addTool registers a tool and wraps its handler with rate limiting + metrics instrumentation.
8989
// It automatically injects the GREEN-API icon into the tool metadata when configured.
9090
func (s *Server) addTool(tool mcp.Tool, handler mcpgo.ToolHandlerFunc) {
91+
applySubmissionReviewHints(&tool)
92+
applyToolTitle(&tool)
93+
applyWidgetToolMeta(&tool)
9194
if s.toolIcon != nil {
9295
tool.Icons = append(tool.Icons, *s.toolIcon)
9396
}
@@ -126,6 +129,132 @@ func (s *Server) addTool(tool mcp.Tool, handler mcpgo.ToolHandlerFunc) {
126129
}))
127130
}
128131

132+
func applySubmissionReviewHints(tool *mcp.Tool) {
133+
readOnlyTools := map[string]bool{
134+
"whatsapp_check_whatsapp": true,
135+
"whatsapp_get_authorization_code": true,
136+
"whatsapp_get_chat_history": true,
137+
"whatsapp_get_contact_avatar": true,
138+
"whatsapp_get_contact_info": true,
139+
"whatsapp_get_contacts": true,
140+
"whatsapp_get_group_data": true,
141+
"whatsapp_get_instances": true,
142+
"whatsapp_get_message": true,
143+
"whatsapp_get_qr": true,
144+
"whatsapp_get_settings": true,
145+
"whatsapp_get_state": true,
146+
"whatsapp_get_wa_settings": true,
147+
"whatsapp_last_incoming_messages": true,
148+
"whatsapp_last_outgoing_messages": true,
149+
"whatsapp_receive_notification": true,
150+
}
151+
localOnlyTools := map[string]bool{
152+
"whatsapp_connect": true,
153+
"whatsapp_disconnect": true,
154+
}
155+
destructiveTools := map[string]bool{
156+
"whatsapp_delete_instance": true,
157+
"whatsapp_delete_message": true,
158+
"whatsapp_delete_notification": true,
159+
"whatsapp_edit_message": true,
160+
"whatsapp_forward_messages": true,
161+
"whatsapp_leave_group": true,
162+
"whatsapp_logout": true,
163+
"whatsapp_remove_group_admin": true,
164+
"whatsapp_remove_group_participant": true,
165+
"whatsapp_send_contact": true,
166+
"whatsapp_send_file": true,
167+
"whatsapp_send_file_by_upload": true,
168+
"whatsapp_send_location": true,
169+
"whatsapp_send_message": true,
170+
"whatsapp_send_poll": true,
171+
"whatsapp_set_settings": true,
172+
}
173+
174+
readOnly := readOnlyTools[tool.Name]
175+
openWorld := !readOnly && !localOnlyTools[tool.Name]
176+
destructive := destructiveTools[tool.Name]
177+
178+
tool.Annotations.ReadOnlyHint = &readOnly
179+
tool.Annotations.OpenWorldHint = &openWorld
180+
tool.Annotations.DestructiveHint = &destructive
181+
if readOnly {
182+
idempotent := true
183+
tool.Annotations.IdempotentHint = &idempotent
184+
}
185+
}
186+
187+
var toolTitles = map[string]string{
188+
"whatsapp_connect": "Connect Instance",
189+
"whatsapp_disconnect": "Disconnect Instance",
190+
"whatsapp_send_message": "Send Message",
191+
"whatsapp_send_file": "Send File by URL",
192+
"whatsapp_upload_file": "Upload File",
193+
"whatsapp_send_file_by_upload": "Send File",
194+
"whatsapp_send_location": "Send Location",
195+
"whatsapp_send_contact": "Send Contact",
196+
"whatsapp_send_poll": "Send Poll",
197+
"whatsapp_forward_messages": "Forward Messages",
198+
"whatsapp_edit_message": "Edit Message",
199+
"whatsapp_delete_message": "Delete Message",
200+
"whatsapp_get_state": "Get Instance State",
201+
"whatsapp_get_settings": "Get Instance Settings",
202+
"whatsapp_set_settings": "Update Instance Settings",
203+
"whatsapp_get_qr": "Get QR Code",
204+
"whatsapp_check_whatsapp": "Check WhatsApp Number",
205+
"whatsapp_get_contacts": "Get Contacts",
206+
"whatsapp_get_contact_info": "Get Contact Info",
207+
"whatsapp_receive_notification": "Receive Notification",
208+
"whatsapp_create_group": "Create Group",
209+
"whatsapp_get_group_data": "Get Group Data",
210+
"whatsapp_add_group_participant": "Add Group Participant",
211+
"whatsapp_remove_group_participant": "Remove Group Participant",
212+
"whatsapp_reboot": "Reboot Instance",
213+
"whatsapp_logout": "Logout Instance",
214+
"whatsapp_get_authorization_code": "Get Authorization Code",
215+
"whatsapp_get_wa_settings": "Get WhatsApp Account Settings",
216+
"whatsapp_get_chat_history": "Get Chat History",
217+
"whatsapp_get_message": "Get Message",
218+
"whatsapp_last_incoming_messages": "Get Recent Incoming Messages",
219+
"whatsapp_last_outgoing_messages": "Get Recent Outgoing Messages",
220+
"whatsapp_read_chat": "Mark Chat as Read",
221+
"whatsapp_delete_notification": "Delete Notification",
222+
"whatsapp_set_group_admin": "Promote Group Admin",
223+
"whatsapp_remove_group_admin": "Demote Group Admin",
224+
"whatsapp_leave_group": "Leave Group",
225+
"whatsapp_get_contact_avatar": "Get Contact Avatar",
226+
"whatsapp_create_instance": "Create Partner Instance",
227+
"whatsapp_delete_instance": "Delete Partner Instance",
228+
"whatsapp_get_instances": "List Partner Instances",
229+
}
230+
231+
func applyToolTitle(tool *mcp.Tool) {
232+
if title, ok := toolTitles[tool.Name]; ok && tool.Annotations.Title == "" {
233+
tool.Annotations.Title = title
234+
}
235+
}
236+
237+
func applyWidgetToolMeta(tool *mcp.Tool) {
238+
resourceURIByTool := map[string]string{
239+
"whatsapp_get_contacts": "ui://contacts",
240+
"whatsapp_get_qr": "ui://qr",
241+
}
242+
resourceURI, ok := resourceURIByTool[tool.Name]
243+
if !ok {
244+
return
245+
}
246+
247+
fields := map[string]any{}
248+
if tool.Meta != nil {
249+
for key, value := range tool.Meta.AdditionalFields {
250+
fields[key] = value
251+
}
252+
}
253+
fields["ui"] = map[string]any{"resourceUri": resourceURI}
254+
fields["openai/outputTemplate"] = resourceURI
255+
tool.Meta = mcp.NewMetaFromMap(fields)
256+
}
257+
129258
// ServeStdio runs the MCP server over stdio transport (blocking).
130259
func (s *Server) ServeStdio(ctx context.Context) error {
131260
stdioSrv := mcpgo.NewStdioServer(s.mcp)

internal/infrastructure/mcp/tools.go

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -624,15 +624,6 @@ func registerTools(s *Server) {
624624
)
625625

626626
// whatsapp_get_qr
627-
qrTool := mcp.NewTool("whatsapp_get_qr",
628-
mcp.WithDescription("Get QR code for instance authorization. Returns the QR image inline \u2014 scan it in your messenger app to link the instance."),
629-
mcp.WithNumber("instance_id", mcp.Description("Instance ID (not required when authenticated via OAuth)")),
630-
)
631-
qrTool.Meta = &mcp.Meta{
632-
AdditionalFields: map[string]any{
633-
"ui": map[string]any{"resourceUri": "ui://qr"},
634-
},
635-
}
636627
s.addTool(
637628
mcp.NewTool("whatsapp_get_qr",
638629
mcp.WithDescription("Get the QR code for instance authorization"),
@@ -657,11 +648,13 @@ func registerTools(s *Server) {
657648
small = qr.Message // fallback to original
658649
}
659650
res := mcp.NewToolResultImage("Scan this QR code to authorize the instance", small, "image/png")
660-
res.Meta = &mcp.Meta{AdditionalFields: map[string]any{"ui": map[string]any{"resourceUri": "ui://qr"}}}
651+
res.Meta = qrToolResultMeta(small, "image/png")
652+
res.StructuredContent = map[string]any{"type": "qrCode", "message": small, "mimeType": "image/png"}
661653
return res, nil
662654
}
663655
res := mcp.NewToolResultText(fmt.Sprintf(`{"type":%q,"message":%q}`, qr.Type, qr.Message))
664656
res.Meta = &mcp.Meta{AdditionalFields: map[string]any{"ui": map[string]any{"resourceUri": "ui://qr"}}}
657+
res.StructuredContent = map[string]any{"type": qr.Type, "message": qr.Message}
665658
return res, nil
666659
}),
667660
)
@@ -695,15 +688,6 @@ func registerTools(s *Server) {
695688
)
696689

697690
// whatsapp_get_contacts
698-
contactsTool := mcp.NewTool("whatsapp_get_contacts",
699-
mcp.WithDescription("Get the contact list"),
700-
mcp.WithNumber("instance_id", mcp.Description("Instance ID (not required when authenticated via OAuth)")),
701-
)
702-
contactsTool.Meta = &mcp.Meta{
703-
AdditionalFields: map[string]any{
704-
"ui": map[string]any{"resourceUri": "ui://contacts"},
705-
},
706-
}
707691
s.addTool(
708692
mcp.NewTool("whatsapp_get_contacts",
709693
mcp.WithDescription("Get the contact list"),
@@ -1012,10 +996,10 @@ func registerTools(s *Server) {
1012996
// whatsapp_get_chat_history
1013997
s.addTool(
1014998
mcp.NewTool("whatsapp_get_chat_history",
1015-
mcp.WithDescription("Get chat message history"),
999+
mcp.WithDescription("Get chat message history. Use a small 'count' (default 50, max 100) to keep responses focused."),
10161000
mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")),
10171001
mcp.WithString("chat_id", mcp.Required(), mcp.Description("Chat ID (79001234567@c.us or 120363XXX@g.us)")),
1018-
mcp.WithNumber("count", mcp.Description("Number of messages (default 100)")),
1002+
mcp.WithNumber("count", mcp.Description("Number of messages (default 50, max 100)")),
10191003
),
10201004
mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
10211005
instanceID, err := resolveInstanceID(ctx, req)
@@ -1026,7 +1010,13 @@ func registerTools(s *Server) {
10261010
if err != nil {
10271011
return mcp.NewToolResultError(err.Error()), nil
10281012
}
1029-
count := int(req.GetFloat("count", 100))
1013+
count := int(req.GetFloat("count", 50))
1014+
if count < 1 {
1015+
count = 50
1016+
}
1017+
if count > 100 {
1018+
count = 100
1019+
}
10301020
body := domain.GetChatHistoryRequest{ChatID: chatID, Count: count}
10311021
result, err := s.client.GetChatHistory(ctx, instanceID, body)
10321022
if err != nil {
@@ -1069,16 +1059,22 @@ func registerTools(s *Server) {
10691059
// whatsapp_last_incoming_messages
10701060
s.addTool(
10711061
mcp.NewTool("whatsapp_last_incoming_messages",
1072-
mcp.WithDescription("Get recent incoming messages"),
1062+
mcp.WithDescription("Get recent incoming messages from the last N minutes (default 60, max 1440 = 24 hours)."),
10731063
mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")),
1074-
mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 1440 = 24 hours)")),
1064+
mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 60, max 1440)")),
10751065
),
10761066
mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
10771067
instanceID, err := resolveInstanceID(ctx, req)
10781068
if err != nil {
10791069
return mcp.NewToolResultError(err.Error()), nil
10801070
}
1081-
minutes := int(req.GetFloat("minutes", 1440))
1071+
minutes := int(req.GetFloat("minutes", 60))
1072+
if minutes < 1 {
1073+
minutes = 60
1074+
}
1075+
if minutes > 1440 {
1076+
minutes = 1440
1077+
}
10821078
result, err := s.client.LastIncomingMessages(ctx, instanceID, minutes)
10831079
if err != nil {
10841080
return mcp.NewToolResultError(err.Error()), nil
@@ -1090,16 +1086,22 @@ func registerTools(s *Server) {
10901086
// whatsapp_last_outgoing_messages
10911087
s.addTool(
10921088
mcp.NewTool("whatsapp_last_outgoing_messages",
1093-
mcp.WithDescription("Get recent outgoing messages"),
1089+
mcp.WithDescription("Get recent outgoing messages from the last N minutes (default 60, max 1440 = 24 hours)."),
10941090
mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")),
1095-
mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 1440 = 24 hours)")),
1091+
mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 60, max 1440)")),
10961092
),
10971093
mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
10981094
instanceID, err := resolveInstanceID(ctx, req)
10991095
if err != nil {
11001096
return mcp.NewToolResultError(err.Error()), nil
11011097
}
1102-
minutes := int(req.GetFloat("minutes", 1440))
1098+
minutes := int(req.GetFloat("minutes", 60))
1099+
if minutes < 1 {
1100+
minutes = 60
1101+
}
1102+
if minutes > 1440 {
1103+
minutes = 1440
1104+
}
11031105
result, err := s.client.LastOutgoingMessages(ctx, instanceID, minutes)
11041106
if err != nil {
11051107
return mcp.NewToolResultError(err.Error()), nil
@@ -1353,6 +1355,17 @@ func marshalJSON(v any) ([]byte, error) {
13531355
return json.Marshal(v)
13541356
}
13551357

1358+
func qrToolResultMeta(base64, mimeType string) *mcp.Meta {
1359+
return &mcp.Meta{AdditionalFields: map[string]any{
1360+
"ui": map[string]any{"resourceUri": "ui://qr"},
1361+
"qr": map[string]any{
1362+
"type": "qrCode",
1363+
"message": base64,
1364+
"mimeType": mimeType,
1365+
},
1366+
}}
1367+
}
1368+
13561369
// resolveInstanceID returns the instance ID for a tool call.
13571370
// Priority: explicit argument > authenticated context (OAuth/proxy mode).
13581371
// This allows instance_id to be omitted when the client authenticated via OAuth.

0 commit comments

Comments
 (0)