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}