| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- // 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 <token>". 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)
- }
|