server.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
  4
  5// Package mcp implements the MCP surface for cooked-mcp.
  6package mcp
  7
  8import (
  9	"context"
 10	"fmt"
 11	"strings"
 12
 13	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
 14
 15	"git.secluded.site/cooked-mcp/internal/cooked"
 16)
 17
 18const (
 19	serverName   = "Cooked"
 20	readToolName = "read"
 21)
 22
 23// Backend provides the Cooked operations exposed as MCP tools.
 24type Backend interface {
 25	ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
 26}
 27
 28// Server exposes Cooked as an MCP server.
 29type Server struct {
 30	backend Backend
 31	sdk     *sdk.Server
 32}
 33
 34// NewServer returns an MCP server for a Cooked backend.
 35func NewServer(backend Backend, version string) *Server {
 36	server := &Server{backend: backend}
 37	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
 38	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
 39
 40	return server
 41}
 42
 43// RunStdio serves MCP over the official SDK stdio transport.
 44func (s *Server) RunStdio(ctx context.Context) error {
 45	return s.sdk.Run(ctx, &sdk.StdioTransport{})
 46}
 47
 48// CallReadTool invokes the read tool without asserting MCP wire presentation.
 49func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
 50	_, output, err := s.callReadTool(ctx, nil, arguments)
 51	if err != nil {
 52		return ReadOutput{}, err
 53	}
 54
 55	return output, nil
 56}
 57
 58func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, arguments ReadArguments) (*sdk.CallToolResult, ReadOutput, error) {
 59	if arguments.Target != "shopping_list" {
 60		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
 61	}
 62
 63	shoppingList, err := s.backend.ReadShoppingList(ctx)
 64	if err != nil {
 65		return nil, ReadOutput{}, err
 66	}
 67
 68	output := ReadOutput{
 69		Aisles:  shoppingList.Aisles,
 70		Recipes: shoppingList.Recipes,
 71	}
 72
 73	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
 74}
 75
 76func readTool() *sdk.Tool {
 77	openWorld := true
 78	return &sdk.Tool{
 79		Name:        readToolName,
 80		Title:       "Read Cooked data",
 81		Description: "Read Cooked data. This slice supports target shopping_list.",
 82		Annotations: &sdk.ToolAnnotations{
 83			Title:         "Read Cooked data",
 84			ReadOnlyHint:  true,
 85			OpenWorldHint: &openWorld,
 86		},
 87	}
 88}
 89
 90func formatShoppingList(shoppingList cooked.ShoppingList) string {
 91	if len(shoppingList.Aisles) == 0 {
 92		return "Shopping list is empty."
 93	}
 94
 95	var builder strings.Builder
 96	builder.WriteString("Shopping list:\n")
 97	for _, aisle := range shoppingList.Aisles {
 98		builder.WriteString("- ")
 99		builder.WriteString(aisle.Name)
100		builder.WriteString(":")
101		if len(aisle.ProductGroups) == 0 {
102			builder.WriteString(" no items\n")
103			continue
104		}
105		builder.WriteByte('\n')
106
107		for _, product := range aisle.ProductGroups {
108			builder.WriteString("  - ")
109			builder.WriteString(product.Name)
110			if product.Quantity != "" {
111				builder.WriteString(" — ")
112				builder.WriteString(product.Quantity)
113			}
114			builder.WriteString(" (id: ")
115			builder.WriteString(product.ID)
116			builder.WriteString(")")
117			if product.Selected {
118				builder.WriteString(" selected")
119			}
120			builder.WriteByte('\n')
121		}
122	}
123
124	return strings.TrimRight(builder.String(), "\n")
125}
126
127// ReadArguments contains read tool arguments.
128type ReadArguments struct {
129	Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list in this slice."`
130}
131
132// ReadOutput is the structured output for the current read tool slice.
133type ReadOutput struct {
134	Aisles  []cooked.Aisle `json:"aisles"`
135	Recipes []string       `json:"recipes"`
136}