// Package mcpserver exposes cheapo's free-game providers over the Model // Context Protocol. // // Two transports are supported: streamable HTTP (the useful one for remote // clients and agents) and stdio (for clients that spawn the binary locally). package mcpserver import ( "context" "crypto/subtle" "encoding/json" "errors" "fmt" "log" "net" "net/http" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "gogs.netdome.biz/paul/cheapo/internal/game" "gogs.netdome.biz/paul/cheapo/internal/ops" "gogs.netdome.biz/paul/cheapo/internal/providers" ) // Options configures the server. type Options struct { Store string // default store selection, empty means all Country string // default Epic country, empty keeps the client default Locale string // default Epic locale, empty keeps the client default Version string // server version reported to clients // newProviders overrides provider construction. Tests use it to stay // offline; production leaves it nil. newProviders func(store, country, locale string) ([]game.Provider, error) } // Server is the cheapo MCP server. The zero value is not usable, use New. // // The server holds no state of its own: every tool call queries the stores // and returns the current answer. Deciding what is "new" is left to the // client, which knows what it has already seen. type Server struct { opts Options srv *mcp.Server } // New creates a server with the tools registered. func New(opts Options) *Server { if opts.Version == "" { opts.Version = "dev" } s := &Server{opts: opts} if s.opts.newProviders == nil { s.opts.newProviders = func(store, country, locale string) ([]game.Provider, error) { return providers.New(providers.Options{Store: store, Country: country, Locale: locale}) } } s.register() return s } // register wires the tools into the underlying MCP server. func (s *Server) register() { srv := mcp.NewServer(&mcp.Implementation{ Name: "cheapo", Version: s.opts.Version, Title: "cheapo — free games", }, nil) mcp.AddTool(srv, &mcp.Tool{ Name: "list_offers", Title: "List game offers", Description: "Equivalent of `cheapo list`: list game offers from the Epic Games Store, Steam and GOG. " + "Mode \"free\" (default) lists current giveaways, \"upcoming\" announced future Epic " + "giveaways (Epic only), \"deals\" discounted games (Epic and Steam). " + "Returns title, store, kind, seller, original price, start and end of the free " + "period and store URL. " + "Every call returns the full current list, so the caller decides what is new to it.", }, s.listOffers) s.srv = srv } // Serve runs the MCP protocol on t until the client disconnects or ctx is // cancelled. Tests use this with an in-memory transport. func (s *Server) Serve(ctx context.Context, t mcp.Transport) error { if err := s.srv.Run(ctx, t); err != nil { return fmt.Errorf("mcp: %w", err) } return nil } // Run serves the MCP protocol on stdin/stdout. stdout carries protocol // messages only; diagnostics go to stderr. func (s *Server) Run(ctx context.Context) error { return s.Serve(ctx, &mcp.StdioTransport{}) } // HTTPOptions configures the streamable HTTP transport. type HTTPOptions struct { // Path is the endpoint clients POST to. Default "/mcp". Path string // Token enables bearer-token auth when non-empty. Clients must then send // "Authorization: Bearer ". Empty disables auth, which is only // sensible for a loopback bind. Token string // Stateless handles every request in its own session without the // Mcp-Session-Id header. Useful behind proxies and load balancers; the // tools hold no state, so this loses nothing. Stateless bool // SessionTimeout closes idle stateful sessions. Zero means 30 minutes. SessionTimeout time.Duration } // HTTPHandler returns the streamable HTTP handler plus an unauthenticated // /healthz endpoint for monitoring. func (s *Server) HTTPHandler(opts HTTPOptions) http.Handler { path := opts.Path if path == "" { path = "/mcp" } if !strings.HasPrefix(path, "/") { path = "/" + path } timeout := opts.SessionTimeout if timeout == 0 { timeout = 30 * time.Minute } h := mcp.NewStreamableHTTPHandler( func(*http.Request) *mcp.Server { return s.srv }, &mcp.StreamableHTTPOptions{ Stateless: opts.Stateless, SessionTimeout: timeout, }, ) mux := http.NewServeMux() mux.Handle(path, requireToken(h, opts.Token)) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "status": "ok", "service": "cheapo", "version": s.opts.Version, }) }) return mux } // RunHTTP serves the MCP protocol over streamable HTTP on addr until ctx is // cancelled. addr may use port 0 to pick a free port; the effective address // is logged to stderr. func (s *Server) RunHTTP(ctx context.Context, addr string, opts HTTPOptions) error { ln, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("mcp: listen %s: %w", addr, err) } srv := &http.Server{ Handler: s.HTTPHandler(opts), ReadHeaderTimeout: 10 * time.Second, } path := opts.Path if path == "" { path = "/mcp" } auth := "no auth" if opts.Token != "" { auth = "bearer token required" } log.Printf("cheapo mcp: listening on http://%s%s (%s, stateless=%t)", ln.Addr(), path, auth, opts.Stateless) errc := make(chan error, 1) go func() { errc <- srv.Serve(ln) }() select { case err := <-errc: if errors.Is(err, http.ErrServerClosed) { return nil } return fmt.Errorf("mcp: serve: %w", err) case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("mcp: shutdown: %w", err) } return nil } } // requireToken wraps h with bearer-token auth. An empty token disables the // check, which is only safe for a loopback bind. func requireToken(h http.Handler, token string) http.Handler { if token == "" { return h } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 { w.Header().Set("WWW-Authenticate", `Bearer realm="cheapo"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } h.ServeHTTP(w, r) }) } // listArgs is the input of list_offers. It mirrors `cheapo list` and its // flags: mode is the command, store/country/locale are the global flags. type listArgs struct { Mode string `json:"mode,omitempty" jsonschema:"what to list: free (current giveaways, default), upcoming (announced future Epic giveaways) or deals (discounted games)"` Store string `json:"store,omitempty" jsonschema:"which store: epic, steam, gog or all (default all)"` Country string `json:"country,omitempty" jsonschema:"ISO country code for the Epic store region, e.g. DE (default DE)"` Locale string `json:"locale,omitempty" jsonschema:"store locale, e.g. de or en-US (default de)"` } // offer is the wire form of game.Game. It exists because game.Game marshals // zero time.Time values as "0001-01-01T00:00:00Z", which reads like a real // expiry date to a model. Here unknown dates are simply absent. type offer struct { Title string `json:"title" jsonschema:"game title"` Store string `json:"store" jsonschema:"store identifier: epic, steam or gog"` Kind string `json:"kind,omitempty" jsonschema:"offer kind: free-now, free-to-keep, free-weekend or upcoming"` Seller string `json:"seller,omitempty" jsonschema:"publisher or seller, if the store reports one"` URL string `json:"url" jsonschema:"store page URL"` Description string `json:"description,omitempty" jsonschema:"short store description, if available"` OriginalPrice string `json:"original_price,omitempty" jsonschema:"price before the discount, formatted by the store, e.g. 22,39 €"` FinalPrice string `json:"final_price,omitempty" jsonschema:"discounted price, deals only"` DiscountPct int `json:"discount_pct,omitempty" jsonschema:"discount in percent, deals only"` Starts string `json:"starts,omitempty" jsonschema:"RFC 3339 start of the free period (upcoming offers)"` Ends string `json:"ends,omitempty" jsonschema:"RFC 3339 end of the free period, absent if the store does not say"` ID string `json:"id" jsonschema:"store-stable game ID"` } // toOffers converts internal games to their wire form. func toOffers(games []game.Game) []offer { out := make([]offer, 0, len(games)) for _, g := range games { out = append(out, offer{ Title: g.Title, Store: g.Store, Kind: g.Kind, Seller: g.Seller, URL: g.URL, Description: g.Description, OriginalPrice: g.PriceFmt, FinalPrice: g.FinalPriceFmt, DiscountPct: g.DiscountPct, Starts: rfc3339(g.StartDate), Ends: rfc3339(g.EndDate), ID: g.ID, }) } return out } // rfc3339 formats a timestamp, returning "" for the zero value. func rfc3339(t time.Time) string { if t.IsZero() { return "" } return t.Format(time.RFC3339) } // gamesOut is the output of list_offers. type gamesOut struct { Games []offer `json:"games" jsonschema:"free game offers, empty if none are active"` // Warnings lists stores that could not be queried. The remaining stores // are still returned, so a single flaky endpoint does not fail the call. Warnings []string `json:"warnings,omitempty" jsonschema:"stores that could not be queried; their games are missing from this result"` } func (s *Server) listOffers(ctx context.Context, _ *mcp.CallToolRequest, in listArgs) (*mcp.CallToolResult, gamesOut, error) { mode, err := ops.ParseMode(in.Mode) if err != nil { return nil, gamesOut{}, err } provs, err := s.providers(in.Store, in.Country, in.Locale) if err != nil { return nil, gamesOut{}, err } games, warnings, err := ops.List(ctx, provs, mode) if err != nil { return nil, gamesOut{}, err } return nil, gamesOut{Games: toOffers(games), Warnings: warnings}, nil } // providers builds the selected store clients, falling back to the server // defaults for regional settings. func (s *Server) providers(store, country, locale string) ([]game.Provider, error) { if store == "" { store = s.opts.Store } if country == "" { country = s.opts.Country } if locale == "" { locale = s.opts.Locale } return s.opts.newProviders(store, country, locale) }