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
3 changes: 3 additions & 0 deletions internal/handlers/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,9 @@ func (h *ImportHandler) PreviewFromURL(c *gin.Context) {
}

response := gin.H{"recipe": result.Recipe}
if result.ImageURL != "" {
response["image_url"] = result.ImageURL
}
if result.CanonicalID != nil {
response["canonical_id"] = *result.CanonicalID
}
Expand Down
67 changes: 46 additions & 21 deletions internal/mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,21 @@ import (
"github.com/windoze95/saltybytes-api/internal/config"
)

// mcpAppMIMEType is the MCP Apps standard mime type for UI resources.
const mcpAppMIMEType = "text/html;profile=mcp-app"
const (
// mcpAppMIMEType is the MCP Apps standard MIME type for UI resources.
mcpAppMIMEType = "text/html;profile=mcp-app"
// ChatGPT discovers widgets through an output template using the Apps SDK
// skybridge MIME type. The HTML is shared; the resource contracts are not.
chatGPTMIMEType = "text/html+skybridge"
chatGPTWidgetURI = "ui://saltybytes/chatgpt.html"
)

//go:embed widget/app.html
var widgetHTML string

// serverInstructions is surfaced to connected MCP hosts to guide tool use.
const serverInstructions = `SaltyBytes finds REAL recipes from around the web and manages the user's saved recipe collection.
Typical flow: search_recipes to find candidates -> preview_recipe on the chosen result -> save_recipe when the user wants to keep it.
Typical flow: search_recipes to find candidates -> preview_recipe on the chosen result -> save_recipe when the user wants to keep it -> start_cooking when they are ready.
Every tool renders an interactive widget in the conversation; prefer letting the widget present recipe details instead of restating them in text.`

// widgetResourceMeta declares the widget's MCP Apps metadata (CSP etc.).
Expand All @@ -45,27 +51,46 @@ func widgetResourceMeta(cfg *config.Config) mcp.Meta {
}}
}

func chatGPTResourceMeta(cfg *config.Config) mcp.Meta {
resourceDomains := []string{
fmt.Sprintf("https://%s.s3.amazonaws.com", cfg.EnvVars.S3Bucket),
fmt.Sprintf("https://%s.s3.%s.amazonaws.com", cfg.EnvVars.S3Bucket, cfg.EnvVars.AWSRegion),
}
return mcp.Meta{
"openai/widgetDescription": "Browse, save, and cook SaltyBytes recipes without leaving the conversation.",
"openai/widgetPrefersBorder": true,
"openai/widgetDomain": cfg.EnvVars.SiteBaseURL,
"openai/widgetCSP": map[string]any{
"connect_domains": []string{},
"resource_domains": resourceDomains,
},
}
}

// registerWidget registers the single MCP Apps UI resource that renders all
// tool results.
func registerWidget(server *mcp.Server, cfg *config.Config) {
meta := widgetResourceMeta(cfg)
server.AddResource(&mcp.Resource{
URI: widgetURI,
Name: "saltybytes-app",
Title: "SaltyBytes recipe browser",
Description: "Interactive recipe cards for search results, previews, and the user's saved collection.",
MIMEType: mcpAppMIMEType,
Meta: meta,
}, func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{{
URI: widgetURI,
MIMEType: mcpAppMIMEType,
Text: widgetHTML,
Meta: meta,
}},
}, nil
})
register := func(uri, name, mimeType string, meta mcp.Meta) {
server.AddResource(&mcp.Resource{
URI: uri,
Name: name,
Title: "SaltyBytes recipe browser and cook mode",
Description: "Interactive recipe cards for search, previews, saved recipes, and focused cooking.",
MIMEType: mimeType,
Meta: meta,
}, func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{{
URI: uri,
MIMEType: mimeType,
Text: widgetHTML,
Meta: meta,
}},
}, nil
})
}
register(widgetURI, "saltybytes-app", mcpAppMIMEType, widgetResourceMeta(cfg))
register(chatGPTWidgetURI, "saltybytes-chatgpt", chatGPTMIMEType, chatGPTResourceMeta(cfg))
}

// BuildServer constructs the MCP server with all tools and widgets registered.
Expand Down
59 changes: 57 additions & 2 deletions internal/mcpserver/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
viewRecipeCard = "recipe_card"
viewPreview = "preview"
viewRecipeList = "recipe_list"
viewCookMode = "cook_mode"
)

// Deps carries the service-layer dependencies for the MCP tools. The tools
Expand Down Expand Up @@ -85,6 +86,8 @@ func textResult(summary string) *mcp.CallToolResult {
func toolMeta(description, invoking, invoked string) mcp.Meta {
return mcp.Meta{
"ui": map[string]any{"resourceUri": widgetURI},
"openai/outputTemplate": chatGPTWidgetURI,
"openai/widgetAccessible": true,
"openai/widgetDescription": description,
"openai/toolInvocation/invoking": invoking,
"openai/toolInvocation/invoked": invoked,
Expand Down Expand Up @@ -170,6 +173,7 @@ type previewRecipeIn struct {
type previewRecipeOut struct {
View string `json:"view"`
SourceURL string `json:"source_url"`
ImageURL string `json:"image_url,omitempty"`
Recipe *models.RecipeDef `json:"recipe,omitempty"`
CanonicalID *uint `json:"canonical_id,omitempty"`
IsMulti bool `json:"is_multi"`
Expand All @@ -191,6 +195,7 @@ func (d *Deps) previewRecipe(ctx context.Context, req *mcp.CallToolRequest, in p
return nil, out, fmt.Errorf("could not extract a recipe from that page — the site may be blocking access; try another result")
}
out.Recipe = preview.Recipe
out.ImageURL = preview.ImageURL
out.CanonicalID = preview.CanonicalID
out.IsMulti = preview.IsMulti
out.Recipes = preview.MultiCards
Expand Down Expand Up @@ -311,22 +316,56 @@ type getRecipeOut struct {

func (d *Deps) getRecipe(ctx context.Context, req *mcp.CallToolRequest, in getRecipeIn) (*mcp.CallToolResult, getRecipeOut, error) {
out := getRecipeOut{View: viewRecipeCard, Saved: true}
if _, err := d.userForRequest(req, "recipes:read"); err != nil {
user, err := d.userForRequest(req, "recipes:read")
if err != nil {
return nil, out, err
}
id, err := strconv.ParseUint(strings.TrimSpace(in.RecipeID), 10, 64)
if err != nil {
return nil, out, fmt.Errorf("recipe_id must be a numeric id")
}
recipe, err := d.Recipes.GetRecipeByID(uint(id))
if err != nil {
if err != nil || recipe.OwnerID != strconv.FormatUint(uint64(user.ID), 10) {
return nil, out, fmt.Errorf("recipe %s not found", in.RecipeID)
}
out.Recipe = recipe
return textResult(fmt.Sprintf("Showing %q (%d ingredients, %d steps, ~%d min) as an interactive recipe card.",
recipe.Title, len(recipe.Ingredients), len(recipe.Instructions), recipe.CookTimeMinutes)), out, nil
}

// --- start_cooking ---

type startCookingIn struct {
RecipeID string `json:"recipe_id" jsonschema:"the saved SaltyBytes recipe id to cook"`
}

type startCookingOut struct {
View string `json:"view"`
Recipe *service.RecipeResponse `json:"recipe,omitempty"`
CurrentStep int `json:"current_step"`
}

func (d *Deps) startCooking(ctx context.Context, req *mcp.CallToolRequest, in startCookingIn) (*mcp.CallToolResult, startCookingOut, error) {
out := startCookingOut{View: viewCookMode}
user, err := d.userForRequest(req, "recipes:read")
if err != nil {
return nil, out, err
}
id, err := strconv.ParseUint(strings.TrimSpace(in.RecipeID), 10, 64)
if err != nil {
return nil, out, fmt.Errorf("recipe_id must be a numeric id")
}
recipe, err := d.Recipes.GetRecipeByID(uint(id))
if err != nil || recipe.OwnerID != strconv.FormatUint(uint64(user.ID), 10) {
return nil, out, fmt.Errorf("recipe %s not found", in.RecipeID)
}
if len(recipe.Instructions) == 0 {
return nil, out, fmt.Errorf("%q has no cooking instructions yet", recipe.Title)
}
out.Recipe = recipe
return textResult(fmt.Sprintf("Starting cook mode for %q at step 1 of %d. The interactive card keeps the user focused on one instruction at a time.", recipe.Title, len(recipe.Instructions))), out, nil
}

// registerTools adds every SaltyBytes tool (with its MCP Apps widget
// declaration) to the server.
func registerTools(server *mcp.Server, deps *Deps) {
Expand Down Expand Up @@ -415,4 +454,20 @@ func registerTools(server *mcp.Server, deps *Deps) {
OpenWorldHint: boolPtr(false),
},
}, deps.getRecipe)

mcp.AddTool(server, &mcp.Tool{
Name: "start_cooking",
Title: "Start cooking a saved recipe",
Description: "Open a saved SaltyBytes recipe in focused cook mode with one instruction at a time. Call this when the user says they are ready to cook, asks to start cooking, or wants step-by-step guidance for a saved recipe.",
Meta: toolMeta(
"A focused one-step-at-a-time cooking guide with ingredients and progress.",
"Setting up cook mode...",
"Ready to cook",
),
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: true,
DestructiveHint: boolPtr(false),
OpenWorldHint: boolPtr(false),
},
}, deps.startCooking)
}
Loading
Loading