server.go•9.28 kB
package mcpserver
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/soggycactus/paprika-3-mcp/internal/paprika"
)
type NewServerOptions struct {
Version string
Username string
Password string
Paprika *paprika.Client
Logger *slog.Logger
}
func NewServer(opts NewServerOptions) (*Server, error) {
paprika3, err := paprika.NewClient(opts.Username, opts.Password, opts.Version, opts.Logger)
if err != nil {
return nil, err
}
s := server.NewMCPServer("paprika-3-mcp", opts.Version, server.WithResourceCapabilities(false, false))
return &Server{
paprika3: paprika3,
server: s,
logger: opts.Logger,
}, nil
}
type Server struct {
paprika3 *paprika.Client
logger *slog.Logger
server *server.MCPServer
}
func (s *Server) Start() {
go s.updateResources()
createRecipeTool := mcp.NewTool("create_paprika_recipe",
mcp.WithDescription("Save new recipes generated by LLMs in the Paprika 3 app"),
mcp.WithString("name", mcp.Description("The name of the recipe"), mcp.Required()),
mcp.WithString("ingredients", mcp.Description("The ingredients of the recipe"), mcp.Required()),
mcp.WithString("directions", mcp.Description("The directions for the recipe"), mcp.Required()),
mcp.WithString("description", mcp.Description("The description of the recipe"), mcp.DefaultString("")),
mcp.WithString("notes", mcp.Description("The notes for the recipe"), mcp.DefaultString("")),
mcp.WithString("servings", mcp.Description("The number of servings for the recipe"), mcp.DefaultString("")),
mcp.WithString("prep_time", mcp.Description("The prep time for the recipe"), mcp.DefaultString("")),
mcp.WithString("cook_time", mcp.Description("The cook time for the recipe"), mcp.DefaultString("")),
mcp.WithString("difficulty", mcp.Description("The difficulty of the recipe"), mcp.DefaultString("")),
)
updateRecipeTool := mcp.NewTool("update_paprika_recipe",
mcp.WithDescription("Update existing recipes in the Paprika 3 app"),
mcp.WithString("uid", mcp.Description("The UID of the recipe"), mcp.Required()),
mcp.WithString("name", mcp.Description("The name of the recipe"), mcp.Required()),
mcp.WithString("ingredients", mcp.Description("The ingredients of the recipe"), mcp.Required()),
mcp.WithString("directions", mcp.Description("The directions for the recipe"), mcp.Required()),
mcp.WithString("description", mcp.Description("The description of the recipe"), mcp.Required()),
mcp.WithString("notes", mcp.Description("The notes for the recipe"), mcp.Required()),
mcp.WithString("servings", mcp.Description("The number of servings for the recipe"), mcp.Required()),
mcp.WithString("prep_time", mcp.Description("The prep time for the recipe"), mcp.Required()),
mcp.WithString("cook_time", mcp.Description("The cook time for the recipe"), mcp.Required()),
mcp.WithString("difficulty", mcp.Description("The difficulty of the recipe"), mcp.Required()),
)
s.server.AddTools(server.ServerTool{
Tool: createRecipeTool,
Handler: s.createRecipe,
}, server.ServerTool{
Tool: updateRecipeTool,
Handler: s.updateRecipe,
})
if err := server.ServeStdio(s.server); err != nil {
s.logger.Error("Server error", "err", err)
}
}
func (s *Server) updateResources() {
s.addResources()
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
s.addResources()
}
}
func (s *Server) addResources() {
s.logger.Info("Updating recipe resources")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// List all recipes to expose them as resources
recipes, err := s.paprika3.ListRecipes(ctx)
if err != nil {
s.logger.Error("failed to list paprika recipes", "err", err)
return
}
if len(recipes.Result) >= 10 {
s.logger.Info("adding recipes resources concurrently")
s.addResourcesConcurrently(recipes)
return
}
for _, r := range recipes.Result {
if err := s.addRecipeResource(r.UID); err != nil {
s.logger.Error("failed to add recipe as MCP resource", "err", err)
}
}
}
func (s *Server) addRecipeResource(uid string) error {
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipe, err := s.paprika3.GetRecipe(ctx, uid)
if err != nil {
return err
}
if recipe.InTrash {
return nil
}
resourceContents := mcp.TextResourceContents{
URI: fmt.Sprintf("paprika://recipes/%s", recipe.UID),
MIMEType: "text/markdown",
Text: recipe.ToMarkdown(),
}
s.server.AddResource(mcp.NewResource(fmt.Sprintf("paprika://recipes/%s", recipe.UID), recipe.Name, mcp.WithResourceDescription(recipe.ResourceDescription()), mcp.WithMIMEType("text/markdown")), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{resourceContents}, nil
})
duration := time.Since(start)
s.logger.Info("Added resource", "name", recipe.Name, "uid", recipe.UID, "duration", duration)
return nil
}
func (s *Server) addResourcesConcurrently(recipes *paprika.RecipeList) {
buffer := make(chan struct{}, 10)
for _, r := range recipes.Result {
go func() (err error) {
buffer <- struct{}{}
defer func() {
<-buffer
}()
defer func(err error) {
if err != nil {
s.logger.Error("failed to add recipe as MCP resource", "err", err)
}
}(err)
return s.addRecipeResource(r.UID)
}()
}
}
func (s *Server) createRecipe(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
start := time.Now()
name, ok := req.Params.Arguments["name"].(string)
if !ok || len(name) == 0 {
return nil, errors.New("name is required")
}
ingredients, ok := req.Params.Arguments["ingredients"].(string)
if !ok || len(ingredients) == 0 {
return nil, errors.New("ingredients are required")
}
directions, ok := req.Params.Arguments["directions"].(string)
if !ok || len(directions) == 0 {
return nil, errors.New("directions are required")
}
servings := req.Params.Arguments["servings"].(string)
prepTime := req.Params.Arguments["prep_time"].(string)
cookTime := req.Params.Arguments["cook_time"].(string)
description := req.Params.Arguments["description"].(string)
notes := req.Params.Arguments["notes"].(string)
difficulty := req.Params.Arguments["difficulty"].(string)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
recipe, err := s.paprika3.SaveRecipe(ctx, paprika.Recipe{
Name: name,
Ingredients: ingredients,
Directions: directions,
Description: description,
Servings: servings,
PrepTime: prepTime,
CookTime: cookTime,
Notes: notes,
Difficulty: difficulty,
})
if err != nil {
return nil, err
}
duration := time.Since(start)
s.logger.Info("Created recipe", "name", recipe.Name, "uid", recipe.UID, "duration", duration)
return mcp.NewToolResultResource(recipe.Name, mcp.TextResourceContents{
URI: fmt.Sprintf("paprika://recipes/%s", recipe.UID),
MIMEType: "text/markdown",
Text: recipe.ToMarkdown(),
}), nil
}
func (s *Server) updateRecipe(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
start := time.Now()
uid, ok := req.Params.Arguments["uid"].(string)
if !ok || len(uid) == 0 {
return nil, errors.New("uid is required")
}
name, ok := req.Params.Arguments["name"].(string)
if !ok || len(name) == 0 {
return nil, errors.New("name is required")
}
ingredients, ok := req.Params.Arguments["ingredients"].(string)
if !ok || len(ingredients) == 0 {
return nil, errors.New("ingredients are required")
}
directions, ok := req.Params.Arguments["directions"].(string)
if !ok || len(directions) == 0 {
return nil, errors.New("directions are required")
}
description, ok := req.Params.Arguments["description"].(string)
if !ok {
return nil, errors.New("description is required")
}
servings, ok := req.Params.Arguments["servings"].(string)
if !ok {
return nil, errors.New("servings is required")
}
prepTime, ok := req.Params.Arguments["prep_time"].(string)
if !ok {
return nil, errors.New("prepTime is required")
}
cookTime, ok := req.Params.Arguments["cook_time"].(string)
if !ok {
return nil, errors.New("cookTime is required")
}
notes, ok := req.Params.Arguments["notes"].(string)
if !ok {
return nil, errors.New("notes is required")
}
difficulty, ok := req.Params.Arguments["difficulty"].(string)
if !ok {
return nil, errors.New("difficulty is required")
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
recipe, err := s.paprika3.SaveRecipe(ctx, paprika.Recipe{
UID: uid,
Name: name,
Ingredients: ingredients,
Directions: directions,
Description: description,
Servings: servings,
PrepTime: prepTime,
CookTime: cookTime,
Notes: notes,
Difficulty: difficulty,
})
if err != nil {
return nil, err
}
duration := time.Since(start)
s.logger.Info("Updated recipe", "name", recipe.Name, "uid", recipe.UID, "duration", duration)
return mcp.NewToolResultResource(recipe.Name, mcp.TextResourceContents{
URI: fmt.Sprintf("paprika://recipes/%s", recipe.UID),
MIMEType: "text/markdown",
Text: recipe.ToMarkdown(),
}), nil
}