mcpserver.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. // Package mcpserver exposes cheapo's free-game providers over the Model
  2. // Context Protocol.
  3. //
  4. // Two transports are supported: streamable HTTP (the useful one for remote
  5. // clients and agents) and stdio (for clients that spawn the binary locally).
  6. package mcpserver
  7. import (
  8. "context"
  9. "crypto/subtle"
  10. "encoding/json"
  11. "errors"
  12. "fmt"
  13. "log"
  14. "net"
  15. "net/http"
  16. "strings"
  17. "time"
  18. "github.com/modelcontextprotocol/go-sdk/mcp"
  19. "gogs.netdome.biz/paul/cheapo/internal/game"
  20. "gogs.netdome.biz/paul/cheapo/internal/ops"
  21. "gogs.netdome.biz/paul/cheapo/internal/providers"
  22. )
  23. // Options configures the server.
  24. type Options struct {
  25. Store string // default store selection, empty means all
  26. Country string // default Epic country, empty keeps the client default
  27. Locale string // default Epic locale, empty keeps the client default
  28. Version string // server version reported to clients
  29. // newProviders overrides provider construction. Tests use it to stay
  30. // offline; production leaves it nil.
  31. newProviders func(store, country, locale string) ([]game.Provider, error)
  32. }
  33. // Server is the cheapo MCP server. The zero value is not usable, use New.
  34. //
  35. // The server holds no state of its own: every tool call queries the stores
  36. // and returns the current answer. Deciding what is "new" is left to the
  37. // client, which knows what it has already seen.
  38. type Server struct {
  39. opts Options
  40. srv *mcp.Server
  41. }
  42. // New creates a server with the tools registered.
  43. func New(opts Options) *Server {
  44. if opts.Version == "" {
  45. opts.Version = "dev"
  46. }
  47. s := &Server{opts: opts}
  48. if s.opts.newProviders == nil {
  49. s.opts.newProviders = func(store, country, locale string) ([]game.Provider, error) {
  50. return providers.New(providers.Options{Store: store, Country: country, Locale: locale})
  51. }
  52. }
  53. s.register()
  54. return s
  55. }
  56. // register wires the tools into the underlying MCP server.
  57. func (s *Server) register() {
  58. srv := mcp.NewServer(&mcp.Implementation{
  59. Name: "cheapo",
  60. Version: s.opts.Version,
  61. Title: "cheapo — free games",
  62. }, nil)
  63. mcp.AddTool(srv, &mcp.Tool{
  64. Name: "list_offers",
  65. Title: "List game offers",
  66. Description: "Equivalent of `cheapo list`: list game offers from the Epic Games Store, Steam and GOG. " +
  67. "Mode \"free\" (default) lists current giveaways, \"upcoming\" announced future Epic " +
  68. "giveaways (Epic only), \"deals\" discounted games (Epic and Steam). " +
  69. "Returns title, store, kind, seller, original price, start and end of the free " +
  70. "period and store URL. " +
  71. "Every call returns the full current list, so the caller decides what is new to it.",
  72. }, s.listOffers)
  73. s.srv = srv
  74. }
  75. // Serve runs the MCP protocol on t until the client disconnects or ctx is
  76. // cancelled. Tests use this with an in-memory transport.
  77. func (s *Server) Serve(ctx context.Context, t mcp.Transport) error {
  78. if err := s.srv.Run(ctx, t); err != nil {
  79. return fmt.Errorf("mcp: %w", err)
  80. }
  81. return nil
  82. }
  83. // Run serves the MCP protocol on stdin/stdout. stdout carries protocol
  84. // messages only; diagnostics go to stderr.
  85. func (s *Server) Run(ctx context.Context) error {
  86. return s.Serve(ctx, &mcp.StdioTransport{})
  87. }
  88. // HTTPOptions configures the streamable HTTP transport.
  89. type HTTPOptions struct {
  90. // Path is the endpoint clients POST to. Default "/mcp".
  91. Path string
  92. // Token enables bearer-token auth when non-empty. Clients must then send
  93. // "Authorization: Bearer <token>". Empty disables auth, which is only
  94. // sensible for a loopback bind.
  95. Token string
  96. // Stateless handles every request in its own session without the
  97. // Mcp-Session-Id header. Useful behind proxies and load balancers; the
  98. // tools hold no state, so this loses nothing.
  99. Stateless bool
  100. // SessionTimeout closes idle stateful sessions. Zero means 30 minutes.
  101. SessionTimeout time.Duration
  102. }
  103. // HTTPHandler returns the streamable HTTP handler plus an unauthenticated
  104. // /healthz endpoint for monitoring.
  105. func (s *Server) HTTPHandler(opts HTTPOptions) http.Handler {
  106. path := opts.Path
  107. if path == "" {
  108. path = "/mcp"
  109. }
  110. if !strings.HasPrefix(path, "/") {
  111. path = "/" + path
  112. }
  113. timeout := opts.SessionTimeout
  114. if timeout == 0 {
  115. timeout = 30 * time.Minute
  116. }
  117. h := mcp.NewStreamableHTTPHandler(
  118. func(*http.Request) *mcp.Server { return s.srv },
  119. &mcp.StreamableHTTPOptions{
  120. Stateless: opts.Stateless,
  121. SessionTimeout: timeout,
  122. },
  123. )
  124. mux := http.NewServeMux()
  125. mux.Handle(path, requireToken(h, opts.Token))
  126. mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
  127. w.Header().Set("Content-Type", "application/json")
  128. _ = json.NewEncoder(w).Encode(map[string]string{
  129. "status": "ok",
  130. "service": "cheapo",
  131. "version": s.opts.Version,
  132. })
  133. })
  134. return mux
  135. }
  136. // RunHTTP serves the MCP protocol over streamable HTTP on addr until ctx is
  137. // cancelled. addr may use port 0 to pick a free port; the effective address
  138. // is logged to stderr.
  139. func (s *Server) RunHTTP(ctx context.Context, addr string, opts HTTPOptions) error {
  140. ln, err := net.Listen("tcp", addr)
  141. if err != nil {
  142. return fmt.Errorf("mcp: listen %s: %w", addr, err)
  143. }
  144. srv := &http.Server{
  145. Handler: s.HTTPHandler(opts),
  146. ReadHeaderTimeout: 10 * time.Second,
  147. }
  148. path := opts.Path
  149. if path == "" {
  150. path = "/mcp"
  151. }
  152. auth := "no auth"
  153. if opts.Token != "" {
  154. auth = "bearer token required"
  155. }
  156. log.Printf("cheapo mcp: listening on http://%s%s (%s, stateless=%t)",
  157. ln.Addr(), path, auth, opts.Stateless)
  158. errc := make(chan error, 1)
  159. go func() { errc <- srv.Serve(ln) }()
  160. select {
  161. case err := <-errc:
  162. if errors.Is(err, http.ErrServerClosed) {
  163. return nil
  164. }
  165. return fmt.Errorf("mcp: serve: %w", err)
  166. case <-ctx.Done():
  167. shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  168. defer cancel()
  169. if err := srv.Shutdown(shutdownCtx); err != nil {
  170. return fmt.Errorf("mcp: shutdown: %w", err)
  171. }
  172. return nil
  173. }
  174. }
  175. // requireToken wraps h with bearer-token auth. An empty token disables the
  176. // check, which is only safe for a loopback bind.
  177. func requireToken(h http.Handler, token string) http.Handler {
  178. if token == "" {
  179. return h
  180. }
  181. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  182. got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
  183. if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
  184. w.Header().Set("WWW-Authenticate", `Bearer realm="cheapo"`)
  185. http.Error(w, "unauthorized", http.StatusUnauthorized)
  186. return
  187. }
  188. h.ServeHTTP(w, r)
  189. })
  190. }
  191. // listArgs is the input of list_offers. It mirrors `cheapo list` and its
  192. // flags: mode is the command, store/country/locale are the global flags.
  193. type listArgs struct {
  194. Mode string `json:"mode,omitempty" jsonschema:"what to list: free (current giveaways, default), upcoming (announced future Epic giveaways) or deals (discounted games)"`
  195. Store string `json:"store,omitempty" jsonschema:"which store: epic, steam, gog or all (default all)"`
  196. Country string `json:"country,omitempty" jsonschema:"ISO country code for the Epic store region, e.g. DE (default DE)"`
  197. Locale string `json:"locale,omitempty" jsonschema:"Epic display locale (formatting only, e.g. de -> '22,39 €', en-US -> '€22.39'; currency comes from country)"`
  198. }
  199. // offer is the wire form of game.Game. It exists because game.Game marshals
  200. // zero time.Time values as "0001-01-01T00:00:00Z", which reads like a real
  201. // expiry date to a model. Here unknown dates are simply absent.
  202. type offer struct {
  203. Title string `json:"title" jsonschema:"game title"`
  204. Store string `json:"store" jsonschema:"store identifier: epic, steam or gog"`
  205. Kind string `json:"kind,omitempty" jsonschema:"offer kind: free-now, free-to-keep, free-weekend or upcoming"`
  206. Seller string `json:"seller,omitempty" jsonschema:"publisher or seller, if the store reports one"`
  207. URL string `json:"url" jsonschema:"store page URL"`
  208. Description string `json:"description,omitempty" jsonschema:"short store description, if available"`
  209. OriginalPrice string `json:"original_price,omitempty" jsonschema:"price before the discount, formatted by the store, e.g. 22,39 €"`
  210. FinalPrice string `json:"final_price,omitempty" jsonschema:"discounted price, deals only"`
  211. DiscountPct int `json:"discount_pct,omitempty" jsonschema:"discount in percent, deals only"`
  212. Starts string `json:"starts,omitempty" jsonschema:"RFC 3339 start of the free period (upcoming offers)"`
  213. Ends string `json:"ends,omitempty" jsonschema:"RFC 3339 end of the free period, absent if the store does not say"`
  214. ID string `json:"id" jsonschema:"store-stable game ID"`
  215. }
  216. // toOffers converts internal games to their wire form.
  217. func toOffers(games []game.Game) []offer {
  218. out := make([]offer, 0, len(games))
  219. for _, g := range games {
  220. out = append(out, offer{
  221. Title: g.Title,
  222. Store: g.Store,
  223. Kind: g.Kind,
  224. Seller: g.Seller,
  225. URL: g.URL,
  226. Description: g.Description,
  227. OriginalPrice: g.PriceFmt,
  228. FinalPrice: g.FinalPriceFmt,
  229. DiscountPct: g.DiscountPct,
  230. Starts: rfc3339(g.StartDate),
  231. Ends: rfc3339(g.EndDate),
  232. ID: g.ID,
  233. })
  234. }
  235. return out
  236. }
  237. // rfc3339 formats a timestamp, returning "" for the zero value.
  238. func rfc3339(t time.Time) string {
  239. if t.IsZero() {
  240. return ""
  241. }
  242. return t.Format(time.RFC3339)
  243. }
  244. // gamesOut is the output of list_offers.
  245. type gamesOut struct {
  246. Games []offer `json:"games" jsonschema:"free game offers, empty if none are active"`
  247. // Warnings lists stores that could not be queried. The remaining stores
  248. // are still returned, so a single flaky endpoint does not fail the call.
  249. Warnings []string `json:"warnings,omitempty" jsonschema:"stores that could not be queried; their games are missing from this result"`
  250. }
  251. func (s *Server) listOffers(ctx context.Context, _ *mcp.CallToolRequest, in listArgs) (*mcp.CallToolResult, gamesOut, error) {
  252. mode, err := ops.ParseMode(in.Mode)
  253. if err != nil {
  254. return nil, gamesOut{}, err
  255. }
  256. provs, err := s.providers(in.Store, in.Country, in.Locale)
  257. if err != nil {
  258. return nil, gamesOut{}, err
  259. }
  260. games, warnings, err := ops.List(ctx, provs, mode)
  261. if err != nil {
  262. return nil, gamesOut{}, err
  263. }
  264. return nil, gamesOut{Games: toOffers(games), Warnings: warnings}, nil
  265. }
  266. // providers builds the selected store clients, falling back to the server
  267. // defaults for regional settings.
  268. func (s *Server) providers(store, country, locale string) ([]game.Provider, error) {
  269. if store == "" {
  270. store = s.opts.Store
  271. }
  272. if country == "" {
  273. country = s.opts.Country
  274. }
  275. if locale == "" {
  276. locale = s.opts.Locale
  277. }
  278. return s.opts.newProviders(store, country, locale)
  279. }