Browse Source

MCP server (HTTP first) with shared list layer

- cheapo mcp: MCP server over streamable HTTP (--http, --token via
  $CHEAPO_MCP_TOKEN, --stateless, --session-timeout, /healthz) with stdio
  as fallback transport; stateless tool, dedup is the client's job
- tool list_offers mirrors `cheapo list` 1:1 via mode=free|upcoming|deals
  plus store/country/locale; structured content uses an offer wire type
  (RFC 3339 strings, absent instead of zero timestamps)
- internal/ops: the one fetch loop for free/upcoming/deals, shared by CLI,
  MCP and chatto-send; capability interfaces game.UpcomingProvider /
  game.DealProvider replace inline type assertions; store errors become
  per-store warnings
- internal/providers: single store selection (epic|steam|gog|all) for CLI
  and MCP
Paul Klumpp 2 ngày trước cách đây
mục cha
commit
40f04b73fc
12 tập tin đã thay đổi với 1075 bổ sung và 192 xóa
  1. 32 12
      AGENTS.md
  2. 81 30
      README.md
  3. 18 50
      cmd/cheapo/chatto.go
  4. 26 100
      cmd/cheapo/main.go
  5. 81 0
      cmd/cheapo/mcp.go
  6. 9 0
      go.mod
  7. 24 0
      go.sum
  8. 14 0
      internal/game/game.go
  9. 309 0
      internal/mcpserver/mcpserver.go
  10. 337 0
      internal/mcpserver/mcpserver_test.go
  11. 93 0
      internal/ops/ops.go
  12. 51 0
      internal/providers/providers.go

+ 32 - 12
AGENTS.md

@@ -4,9 +4,9 @@
 
 **cheapo** — CLI in Go (Cobra) to fetch and observe current free game offers.
 Later planned: exposing the tools as an MCP server (see README.md, section
-"Roadmap / MCP").
+"MCP server").
 
-## Reference project
+## Reference projects
 
 **free-games-claimer** — https://github.com/DoSpamu/free-games-claimer (fork of
 https://github.com/vogler/free-games-claimer). How it gets the free-games
@@ -18,8 +18,8 @@ info:
   `steam-games.js`, `aliexpress.js`).
 - Upstream uses **Playwright (Firefox)** with a persistent session: it opens
   the store page "Free Games", detects games via the locator
-  `a:has(span:text-is("Free Now"))`, follows the links and claims them in
-  the browser (login per cookie session, 2FA via OTP key, captcha handling).
+  `a:has(span:text-is("Free Now"))`, follows the links and claims them in the
+  browser (login per cookie session, 2FA via OTP key, captcha handling).
 - **Important (comment in `epic-games.js`, ~line 151):** the page is built
   from the JSON of the public backend:
   `https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions`
@@ -31,7 +31,6 @@ info:
   actual *claiming* of the games (like in the reference project) is deliberate
   **not** part of this project.
 
-
 ## Second reference project
 
 **free-games-notifier** — https://github.com/jaixmario/free-games-notifier
@@ -77,16 +76,37 @@ How it gets the info:
 - Module path: `gogs.netdome.biz/paul/cheapo`.
 - CLI framework: **Cobra** (`github.com/spf13/cobra`), commands in `cmd/cheapo/main.go`.
 - Internal code under `internal/` (epic: store client, watch: polling/eventing).
-- Wrap errors with context: `fmt.Errorf("epic: ...: %w", err)`.
-- User-facing strings in German (CLI output, help texts, error messages),
-  code comments in English.
 - **Provider pattern:** every store lives in `internal/<store>/` and implements
   `game.Provider` (`Name()` + `GetFreeGames(ctx) ([]game.Game, error)`); the
   shared type is `game.Game` (`internal/game`). New stores (e.g. GOG)
-  follow this pattern and are registered in `newProviders()` (`cmd/cheapo/main.go`).
+  follow this pattern and are registered in `providers.New()` (`internal/providers`)
+  — that is the only place CLI and MCP server share.
+- **MCP server:** `internal/mcpserver` wraps only the tool `list_offers`
+  (equivalent to `cheapo list`, mode via the `mode` argument) over
+  `github.com/modelcontextprotocol/go-sdk`. No store logic there;
+  responses are structured (`structuredContent`) and use the wire type
+  `offer` (RFC-3339 strings instead of `time.Time` zero values). Primary transport
+  is HTTP (`cheapo mcp --http`), stdio only as fallback. The server is
+  stateless — deduplication ("what is new?") is the client's job,
+  not the server's (no `seen` set, no state file).
+- **Shared list logic:** the modes `free`/`upcoming`/`deals` (including
+  capability interfaces `game.UpcomingProvider`/`game.DealProvider` and
+  warn-instead-of-error) live in `internal/ops` and are shared by CLI, MCP and
+  chatto-send. New list modes only there, not in `cmd/` or
+  `internal/mcpserver`.
+- User-facing strings in English (CLI output, help texts, error messages),
+  code comments in English.
+- Wrap errors with context: `fmt.Errorf("epic: ...: %w", err)`.
+- New store platforms (Prime, GOG, …) follow the provider pattern (above).
 - **Chatto client:** `internal/chatto` is a hand-built Connect-JSON client
-  (Chatto's generated code is `internal/` and not importable). Credentials ONLY via
-  `CHATTO_BASE_URL`/`CHATTO_TOKEN` env from the operator — the tool never
+  (Chatto's generated Go code is `internal/` in the Chatto repo and not
+  importable from outside). Credentials ONLY via
+  `CHATTO_BASE_URL`/`CHATTO_TOKEN` env (set by the operator) — the tool never
   fetches credentials itself, and the agent does not use its own plugin
   credentials for it.
-- New store platforms (Prime, GOG, …) follow the provider pattern (above).
+
+## Testing
+
+- Tests use table-driven tests where possible; live fixtures for HTML/API
+  responses live in `internal/<store>/testdata/`.
+- Run before every commit: `go build ./... && go vet ./... && go test ./...`

+ 81 - 30
README.md

@@ -49,7 +49,7 @@ cheapo list --upcoming
 Example output:
 
 ```
-🎮 Monument Valley
+🎮 [epic] Monument Valley
    By: Netflix, Inc.
    Instead of: 6.99 €
    Free until: 02.11.2026 17:00 (CET)
@@ -59,18 +59,62 @@ Example output:
 `cheapo watch` is suitable as a long runner for a systemd service or cron/timer;
 every newly detected game is reported on stderr as soon as a poll finds it.
 
-## Roadmap / MCP (later)
+## MCP server
 
-Planned next step: expose the tools (`list`, `watch`) as an **MCP server**,
-so they can be used e.g. by Hermes or other MCP clients. Planned:
+`cheapo mcp` exposes the free games data as an **MCP server**. Two
+transports:
 
-- Transport: stdio (classic) — one binary `cheapo mcp`
-- Tools: `list_free_games` (immediately) and `check_now` (single poll, for cron/eventing)
-- The watch logic (`internal/watch`) stays reusable; the MCP server only
-  wraps it.
-- **Not implemented yet** — deliberately a second step after the CLI stabilizes.
+```bash
+# HTTP (Streamable HTTP) — the mode for remote clients, agents, monitoring
+cheapo mcp --http 127.0.0.1:8080
+
+# stdio — only for clients that start the binary themselves
+cheapo mcp
+```
+
+Flags (HTTP): `--http <addr>`, `--path` (default `/mcp`), `--token` or
+`$CHEAPO_MCP_TOKEN` (bearer auth), `--stateless` (for proxies/load
+balancers), `--session-timeout` (default 30m). Region/locale/stores come
+from the global flags `--country`/`--locale`/`--store` and apply as
+defaults for the tool calls.
+
+**Tools:**
+
+| Tool | Purpose |
+|------|---------|
+| `list_offers` | Mirrors `cheapo list`: `mode` = `free` (default) / `upcoming` / `deals`, plus `store`, `country`, `locale` |
+
+Example call:
+
+```json
+{"name": "list_offers", "arguments": {"mode": "deals", "store": "steam"}}
+```
+
+The server is **stateless**: every call returns the complete current
+state. What counts as "new" is decided by the client — it is the only one
+that knows what it has already seen. This means no server memory, no
+state file, and no surprises after a restart.
+
+Responses are structured (MCP `structuredContent`) with `title`, `store`,
+`kind`, `seller`, `url`, `original_price`, `starts`, `ends` (RFC 3339,
+absent when the date is unknown), `id`. Unreachable stores land in
+`warnings` instead of failing the call.
+
+**Auth & operation:** without `--token` the endpoint is open — in that
+case only bind to loopback. `/healthz` answers unauthenticated with
+`{"status":"ok","version":...}` for monitoring. Example:
+
+```bash
+curl http://127.0.0.1:8080/healthz
+# MCP client (streamable HTTP): http://127.0.0.1:8080/mcp
+```
+
+**Limitations:** A long runner that posts on new free games remains
+`cheapo watch` or the Chatto daemon (below), not the MCP server — it only
+answers requests (`list_offers` covers `list`; `watch` has no
+equivalent).
 
-### More platforms
+## More platforms
 
 - **Steam** ✅ done: `cheapo list --store steam` or `--store all`.
   Free-to-keep via HTML scrape of
@@ -98,30 +142,17 @@ so they can be used e.g. by Hermes or other MCP clients. Planned:
 | Humble Bundle | ⚠️ medium | changing promo structure; no stable free-games JSON, scraping required |
 | Ubisoft Connect | ❌ | login + client required, no public API |
 
-## Architecture
-
-```
-cmd/cheapo/       Cobra CLI (list, watch; --store epic|steam|gog|all)
-internal/game/    Provider-agnostic game type + provider interface
-internal/epic/    Epic client (freeGamesPromotions, public JSON endpoint)
-internal/steam/   Steam client (search scrape + featuredcategories API)
-internal/gog/     GOG client (homepage section JSON, gogcom-store-state)
-internal/watch/   Polling/eventing across all providers: reports only new games
-internal/chatto/  Hand-built ConnectRPC client (JSON) for Chatto servers
-```
-
 ## Chatto integration
 
 The package `internal/chatto/` is a **hand-built ConnectRPC client
 (JSON codec)** for Chatto (tested against 0.5.0-beta.27+): `POST
-{base}/api/connect/chatto.api.v1.<Service>/<Method>` with bearer token. The
-Chatto Go codebase (github.com/chattocorp/chatto) keeps generated code and handlers under `internal/`
-— not importable from outside, hence the own client. Message schemas
-come from `proto/chatto/api/v1/` of the Chatto repo (field numbers
+{base}/api/connect/chatto.api.v1.<Service>/<Method>` with a bearer token. The
+Chatto Go codebase (github.com/chattocorp/chatto) keeps generated code and handlers under
+`internal/` — not importable from outside, hence the own client. Message
+schemas come from `proto/chatto/api/v1/` of the Chatto repo (field numbers
 compatible, only the JSON subset is modelled).
 
-CLI commands (credentials deliberately provided by the operator, never
-fetched automatically):
+CLI commands (credentials deliberately provided by the operator, never fetched automatically):
 
 ```bash
 # .env in the project root (godotenv), never commit it — it is in .gitignore:
@@ -134,8 +165,23 @@ cheapo chatto-send --room <room_id> --upcoming   # upcoming ones (Epic)
 ```
 
 **Still open (roadmap):** a watch-mode daemon that posts to the chat on new
-free games (state signature like free-games-notifier); MCP server;
-Discord-freestuff-like server-side integration (just an idea).
+free games (state signature like free-games-notifier); Discord-freestuff-like
+server-side integration (just an idea).
+
+## Architecture
+
+```
+cmd/cheapo/          Cobra CLI (list, watch, mcp; --store epic|steam|gog|all)
+internal/game/       Provider-agnostic game type + provider interfaces
+internal/providers/  Store selection (epic|steam|gog|all) for CLI and MCP
+internal/ops/        List modes (free/upcoming/deals) — shared by CLI and MCP
+internal/epic/       Epic client (freeGamesPromotions, public JSON endpoint)
+internal/steam/      Steam client (search scrape + featuredcategories API)
+internal/gog/        GOG client (homepage section JSON, gogcom-store-state)
+internal/chatto/     Hand-built ConnectRPC client (JSON) for Chatto servers
+internal/watch/      Polling/eventing across all providers: reports only new games
+internal/mcpserver/  MCP tool (list_offers) over HTTP/stdio
+```
 
 ## Reference: how the original project works
 
@@ -144,3 +190,8 @@ with Playwright: login (session cookies, 2FA, captcha handling), detecting
 the "Free Now" tiles on the store page and clicking through the purchase
 process. The free games **info** comes from the same backend JSON that we
 query directly — details and sources in [AGENTS.md](AGENTS.md).
+
+## License
+
+GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later).
+Copyright (C) 2026 Paul Klumpp. Full text: [LICENSE](LICENSE).

+ 18 - 50
cmd/cheapo/chatto.go

@@ -1,7 +1,6 @@
 package main
 
 import (
-	"context"
 	"fmt"
 	"os"
 	"strings"
@@ -11,6 +10,7 @@ import (
 
 	"gogs.netdome.biz/paul/cheapo/internal/chatto"
 	"gogs.netdome.biz/paul/cheapo/internal/game"
+	"gogs.netdome.biz/paul/cheapo/internal/ops"
 )
 
 // loadEnv reads a .env file from the working directory if present (godotenv).
@@ -20,9 +20,9 @@ func loadEnv() {
 }
 
 // chattoClientFromEnv builds a Chatto client from the CHATTO_TOKEN
-// environment variable (or .env file); CHATTO_BASE_URL defaults to the public
-// Chatto server (https://chat.chatto.run). The token is provided by the
-// operator; this tool never fetches credentials itself.
+// environment variable (or .env file); CHATTO_BASE_URL defaults to the public Chatto
+// server (https://chat.chatto.run). The token is provided
+// by the operator; this tool never fetches credentials itself.
 func chattoClientFromEnv() (*chatto.Client, error) {
 	loadEnv()
 	base := os.Getenv("CHATTO_BASE_URL")
@@ -103,33 +103,6 @@ func escapePipe(s string) string {
 	return strings.ReplaceAll(s, "|", "\\|")
 }
 
-// fetchDeals queries all selected providers for discounted deals (any
-// discount); a failing provider is reported on stderr but does not abort.
-func fetchDeals(ctx context.Context) ([]game.Game, error) {
-	provs, err := newProviders()
-	if err != nil {
-		return nil, err
-	}
-	var all []game.Game
-	var hadErr error
-	for _, p := range provs {
-		d, ok := p.(interface {
-			GetDeals(ctx context.Context) ([]game.Game, error)
-		})
-		if !ok {
-			continue // provider has no deals concept (gog)
-		}
-		games, err := d.GetDeals(ctx)
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
-			hadErr = err
-			continue
-		}
-		all = append(all, games...)
-	}
-	return all, hadErr
-}
-
 // chattoSendCmd posts the current free games to a Chatto room.
 func chattoSendCmd() *cobra.Command {
 	var (
@@ -153,26 +126,21 @@ variables (set by the operator, never fetched automatically).`,
 			if roomID == "" {
 				return fmt.Errorf("--room is required (room ID)")
 			}
+			provs, err := newProviders()
+			if err != nil {
+				return err
+			}
 
-			var games []game.Game
-			switch {
-			case deals:
-				games, err = fetchDeals(ctx)
-				if err != nil && len(games) == 0 {
-					return err
-				}
-			case upcoming:
-				// Upcoming windows only exist for Epic.
-				u := newEpic()
-				games, err = u.GetUpcomingGames(ctx)
-				if err != nil {
-					return err
-				}
-			default:
-				games, err = fetchAll(ctx)
-				if err != nil && len(games) == 0 {
-					return err
-				}
+			mode, err := ops.ParseMode(modeOf(upcoming, deals))
+			if err != nil {
+				return err
+			}
+			games, warnings, err := ops.List(ctx, provs, mode)
+			if err != nil {
+				return err
+			}
+			for _, w := range warnings {
+				fmt.Fprintf(os.Stderr, "⚠ %s\n", w)
 			}
 
 			msg, err := c.CreateMessage(ctx, roomID, formatGames(games, modeOf(upcoming, deals)), "")

+ 26 - 100
cmd/cheapo/main.go

@@ -16,10 +16,9 @@ import (
 
 	"github.com/spf13/cobra"
 
-	"gogs.netdome.biz/paul/cheapo/internal/epic"
 	"gogs.netdome.biz/paul/cheapo/internal/game"
-	"gogs.netdome.biz/paul/cheapo/internal/gog"
-	"gogs.netdome.biz/paul/cheapo/internal/steam"
+	"gogs.netdome.biz/paul/cheapo/internal/ops"
+	"gogs.netdome.biz/paul/cheapo/internal/providers"
 	"gogs.netdome.biz/paul/cheapo/internal/watch"
 )
 
@@ -47,7 +46,7 @@ References: https://github.com/vogler/free-games-claimer
 	root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
 	root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, gog, all")
 
-	root.AddCommand(listCmd(), watchCmd(), chattoSendCmd(), chattoRoomsCmd())
+	root.AddCommand(listCmd(), watchCmd(), mcpCmd(), chattoSendCmd(), chattoRoomsCmd())
 
 	if err := root.Execute(); err != nil {
 		os.Exit(1)
@@ -56,48 +55,28 @@ References: https://github.com/vogler/free-games-claimer
 
 // newProviders returns the providers selected by --store.
 func newProviders() ([]game.Provider, error) {
-	var provs []game.Provider
-	switch flagStore {
-	case "epic":
-		provs = []game.Provider{newEpic()}
-	case "steam":
-		provs = []game.Provider{steam.NewClient()}
-	case "gog":
-		provs = []game.Provider{gog.NewClient()}
-	case "all":
-		provs = []game.Provider{newEpic(), steam.NewClient(), gog.NewClient()}
-	default:
-		return nil, fmt.Errorf("unknown store %q (epic, steam, gog, all)", flagStore)
-	}
-	return provs, nil
-}
-
-func newEpic() *epic.Client {
-	c := epic.NewClient()
-	c.Country = flagCountry
-	c.Locale = flagLocale
-	return c
+	return providers.New(providers.Options{
+		Store:   flagStore,
+		Country: flagCountry,
+		Locale:  flagLocale,
+	})
 }
 
-// fetchAll queries all selected providers; a failing provider is reported on
-// stderr but does not abort the others.
-func fetchAll(ctx context.Context) ([]game.Game, error) {
+// fetchList runs ops.List over the providers selected by --store. A failing
+// provider is reported on stderr but does not abort the others.
+func fetchList(ctx context.Context, mode ops.Mode) ([]game.Game, error) {
 	provs, err := newProviders()
 	if err != nil {
 		return nil, err
 	}
-	var all []game.Game
-	var hadErr error
-	for _, p := range provs {
-		games, err := p.GetFreeGames(ctx)
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
-			hadErr = err
-			continue
-		}
-		all = append(all, games...)
+	games, warnings, err := ops.List(ctx, provs, mode)
+	if err != nil {
+		return nil, err
 	}
-	return all, hadErr
+	for _, w := range warnings {
+		fmt.Fprintf(os.Stderr, "⚠ %s\n", w)
+	}
+	return games, nil
 }
 
 // printGames renders games in CLI text form.
@@ -152,42 +131,15 @@ func listCmd() *cobra.Command {
 		Aliases: []string{"ls"},
 		Short:   "List games currently free (optionally upcoming or deals)",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			ctx := cmd.Context()
-			switch {
-			case deals:
-				games, err := listDeals(ctx)
-				if err != nil && len(games) == 0 {
-					return err
-				}
-				return printGames(cmd, games)
-			case upcoming:
-				// Upcoming windows only exist for Epic.
-				var all []game.Game
-				var hadErr error
-				provs, err := newProviders()
-				if err != nil {
-					return err
-				}
-				for _, p := range provs {
-					u, ok := p.(*epic.Client)
-					if !ok {
-						continue
-					}
-					games, err := u.GetUpcomingGames(ctx)
-					if err != nil {
-						fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
-						hadErr = err
-						continue
-					}
-					all = append(all, games...)
-				}
-				if len(all) == 0 && hadErr != nil {
-					return hadErr
-				}
-				return printGames(cmd, all)
+			mode := ops.Free
+			if upcoming {
+				mode = ops.Upcoming
+			} else if deals {
+				mode = ops.Deals
 			}
-			games, err := fetchAll(ctx)
-			if err != nil && len(games) == 0 {
+
+			games, err := fetchList(cmd.Context(), mode)
+			if err != nil {
 				return err
 			}
 			return printGames(cmd, games)
@@ -198,32 +150,6 @@ func listCmd() *cobra.Command {
 	return cmd
 }
 
-// listDeals queries all providers that expose deals.
-func listDeals(ctx context.Context) ([]game.Game, error) {
-	provs, err := newProviders()
-	if err != nil {
-		return nil, err
-	}
-	var all []game.Game
-	var hadErr error
-	for _, p := range provs {
-		d, ok := p.(interface {
-			GetDeals(ctx context.Context) ([]game.Game, error)
-		})
-		if !ok {
-			continue
-		}
-		games, err := d.GetDeals(ctx)
-		if err != nil {
-			fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
-			hadErr = err
-			continue
-		}
-		all = append(all, games...)
-	}
-	return all, hadErr
-}
-
 func watchCmd() *cobra.Command {
 	var (
 		interval time.Duration

+ 81 - 0
cmd/cheapo/mcp.go

@@ -0,0 +1,81 @@
+package main
+
+import (
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"gogs.netdome.biz/paul/cheapo/internal/mcpserver"
+)
+
+// version is the value reported to MCP clients; overridable via -ldflags.
+var version = "dev"
+
+func mcpCmd() *cobra.Command {
+	var (
+		httpAddr       string
+		httpPath       string
+		token          string
+		stateless      bool
+		sessionTimeout time.Duration
+	)
+	cmd := &cobra.Command{
+		Use:   "mcp",
+		Short: "MCP server (HTTP or stdio) for agents and MCP clients",
+		Long: `Exposes the free games data as an MCP server.
+
+Tools:
+  list_offers  Mirrors "cheapo list" — mode: free (default), upcoming,
+               deals; plus store, country, locale
+
+The server is stateless: every call returns the full current state;
+deduplication ("what is new?") is the client's job.
+
+Without --http the server runs over stdio (for clients that start the
+binary themselves). With --http it runs as an HTTP service (Streamable
+HTTP) — the mode for remote clients, multiple users and monitoring.
+
+Examples:
+  cheapo mcp --http 127.0.0.1:8080
+  cheapo mcp --http 0.0.0.0:8080 --token "$CHEAPO_MCP_TOKEN"
+  cheapo mcp                      # stdio
+
+Region/locale/stores come from the global flags (--country, --locale,
+--store) and apply as defaults for the tool calls.`,
+		Args: cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
+			defer stop()
+
+			srv := mcpserver.New(mcpserver.Options{
+				Store:   flagStore,
+				Country: flagCountry,
+				Locale:  flagLocale,
+				Version: version,
+			})
+
+			if httpAddr == "" {
+				return srv.Run(ctx)
+			}
+			if token == "" {
+				token = os.Getenv("CHEAPO_MCP_TOKEN")
+			}
+			return srv.RunHTTP(ctx, httpAddr, mcpserver.HTTPOptions{
+				Path:           httpPath,
+				Token:          token,
+				Stateless:      stateless,
+				SessionTimeout: sessionTimeout,
+			})
+		},
+	}
+
+	cmd.Flags().StringVar(&httpAddr, "http", "", "HTTP address (e.g. 127.0.0.1:8080); empty = stdio")
+	cmd.Flags().StringVar(&httpPath, "path", "/mcp", "HTTP path of the MCP endpoint")
+	cmd.Flags().StringVar(&token, "token", "", "Bearer token for HTTP (default: $CHEAPO_MCP_TOKEN)")
+	cmd.Flags().BoolVar(&stateless, "stateless", false, "HTTP without sessions (for proxies/load balancers)")
+	cmd.Flags().DurationVar(&sessionTimeout, "session-timeout", 30*time.Minute, "Idle timeout for HTTP sessions")
+	return cmd
+}

+ 9 - 0
go.mod

@@ -4,11 +4,20 @@ go 1.27.1
 
 require (
 	github.com/joho/godotenv v1.5.1
+	github.com/modelcontextprotocol/go-sdk v1.8.0
 	github.com/spf13/cobra v1.10.2
 	golang.org/x/net v0.62.0
 )
 
 require (
+	github.com/google/jsonschema-go v0.4.3 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/segmentio/asm v1.1.3 // indirect
+	github.com/segmentio/encoding v0.5.4 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
+	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+	golang.org/x/oauth2 v0.35.0 // indirect
+	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sys v0.49.0 // indirect
+	golang.org/x/time v0.15.0 // indirect
 )

+ 24 - 0
go.sum

@@ -1,14 +1,38 @@
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
+github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
 github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
 golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 14 - 0
internal/game/game.go

@@ -31,3 +31,17 @@ type Provider interface {
 	// GetFreeGames returns the games that are currently free.
 	GetFreeGames(ctx context.Context) ([]Game, error)
 }
+
+// UpcomingProvider is implemented by stores that announce future giveaways.
+// Providers without it are skipped when upcoming offers are requested.
+type UpcomingProvider interface {
+	// GetUpcomingGames returns announced games that will become free.
+	GetUpcomingGames(ctx context.Context) ([]Game, error)
+}
+
+// DealProvider is implemented by stores that expose discounted offers.
+// Providers without it are skipped when deals are requested.
+type DealProvider interface {
+	// GetDeals returns currently discounted games (free ones included).
+	GetDeals(ctx context.Context) ([]Game, error)
+}

+ 309 - 0
internal/mcpserver/mcpserver.go

@@ -0,0 +1,309 @@
+// 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)
+}

+ 337 - 0
internal/mcpserver/mcpserver_test.go

@@ -0,0 +1,337 @@
+package mcpserver
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+
+	"gogs.netdome.biz/paul/cheapo/internal/game"
+)
+
+// fakeProvider returns a fixed (mutable) game list, so tests can simulate a
+// store whose offers change between polls. Optional capabilities (upcoming,
+// deals) are enabled via the flags.
+type fakeProvider struct {
+	name     string
+	games    []game.Game
+	err      error
+	upcoming []game.Game
+	deals    []game.Game
+}
+
+func (f *fakeProvider) Name() string { return f.name }
+
+func (f *fakeProvider) GetFreeGames(context.Context) ([]game.Game, error) {
+	if f.err != nil {
+		return nil, f.err
+	}
+	return f.games, nil
+}
+
+func (f *fakeProvider) GetUpcomingGames(context.Context) ([]game.Game, error) {
+	if f.err != nil {
+		return nil, f.err
+	}
+	return f.upcoming, nil
+}
+
+func (f *fakeProvider) GetDeals(context.Context) ([]game.Game, error) {
+	if f.err != nil {
+		return nil, f.err
+	}
+	return f.deals, nil
+}
+
+// newTestServer builds a server backed by prov instead of the real stores.
+func newTestServer(prov game.Provider) *Server {
+	return New(Options{Version: "test", newProviders: func(_, _, _ string) ([]game.Provider, error) {
+		return []game.Provider{prov}, nil
+	}})
+}
+
+// newTestSession starts an in-memory server/client pair for prov.
+func newTestSession(t *testing.T, prov game.Provider) *mcp.ClientSession {
+	t.Helper()
+	ctx := context.Background()
+
+	srv := newTestServer(prov)
+
+	st, ct := mcp.NewInMemoryTransports()
+	ss, err := srv.srv.Connect(ctx, st, nil)
+	if err != nil {
+		t.Fatalf("server connect: %v", err)
+	}
+	t.Cleanup(func() { _ = ss.Close() })
+
+	client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
+	cs, err := client.Connect(ctx, ct, nil)
+	if err != nil {
+		t.Fatalf("client connect: %v", err)
+	}
+	t.Cleanup(func() { _ = cs.Close() })
+	return cs
+}
+
+// callTool invokes name with args and decodes the structured result into out.
+func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any, out any) {
+	t.Helper()
+	res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
+	if err != nil {
+		t.Fatalf("%s: %v", name, err)
+	}
+	if res.IsError {
+		t.Fatalf("%s returned an error result: %+v", name, res.Content)
+	}
+	if res.StructuredContent == nil {
+		t.Fatalf("%s returned no structured content", name)
+	}
+	raw, err := json.Marshal(res.StructuredContent)
+	if err != nil {
+		t.Fatalf("%s: marshal structured content: %v", name, err)
+	}
+	if err := json.Unmarshal(raw, out); err != nil {
+		t.Fatalf("%s: decode %s: %v", name, raw, err)
+	}
+}
+
+// TestToolsAreRegistered pins the tool names an MCP client will discover.
+func TestToolsAreRegistered(t *testing.T) {
+	cs := newTestSession(t, &fakeProvider{name: "fake"})
+
+	var names []string
+	for tool, err := range cs.Tools(context.Background(), nil) {
+		if err != nil {
+			t.Fatalf("tools: %v", err)
+		}
+		names = append(names, tool.Name)
+	}
+	got := strings.Join(names, ",")
+	if got != "list_offers" {
+		t.Fatalf("tools = %q, want only list_offers", got)
+	}
+}
+
+// TestListFreeGamesReturnsAllGames checks the plain listing path, including
+// that time.Time fields survive the JSON schema round-trip.
+func TestListFreeGamesReturnsAllGames(t *testing.T) {
+	end := time.Date(2026, 11, 2, 17, 0, 0, 0, time.UTC)
+	cs := newTestSession(t, &fakeProvider{name: "epic", games: []game.Game{
+		{Title: "A", ID: "a", Store: "epic", URL: "https://example.test/a", EndDate: end},
+		{Title: "B", ID: "b", Store: "epic", URL: "https://example.test/b"},
+	}})
+
+	var out gamesOut
+	callTool(t, cs, "list_offers", map[string]any{}, &out)
+
+	if len(out.Games) != 2 {
+		t.Fatalf("games = %d, want 2", len(out.Games))
+	}
+	if out.Games[0].Title != "A" || out.Games[0].Ends != "2026-11-02T17:00:00Z" {
+		t.Fatalf("first game = %+v, want A ending 2026-11-02T17:00:00Z", out.Games[0])
+	}
+	if out.Games[1].Ends != "" {
+		t.Fatalf("second game = %+v, want no end date rather than a zero timestamp", out.Games[1])
+	}
+	if len(out.Warnings) != 0 {
+		t.Fatalf("warnings = %v, want none", out.Warnings)
+	}
+}
+
+// TestListFreeGamesEmptyIsEmptyArray pins that "no games" serializes as []
+// rather than null, which clients handle differently.
+func TestListFreeGamesEmptyIsEmptyArray(t *testing.T) {
+	cs := newTestSession(t, &fakeProvider{name: "steam"})
+
+	res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "list_offers"})
+	if err != nil {
+		t.Fatalf("list_offers: %v", err)
+	}
+	raw, err := json.Marshal(res.StructuredContent)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	if !strings.Contains(string(raw), `"games":[]`) {
+		t.Fatalf("structured content = %s, want an empty games array", raw)
+	}
+}
+
+// TestProviderErrorBecomesWarning pins that a broken store degrades the result
+// instead of failing the tool call.
+func TestProviderErrorBecomesWarning(t *testing.T) {
+	prov := &fakeProvider{name: "gog", err: errors.New("boom")}
+	cs := newTestSession(t, prov)
+
+	var out gamesOut
+	callTool(t, cs, "list_offers", map[string]any{}, &out)
+
+	if len(out.Games) != 0 {
+		t.Fatalf("games = %v, want none", out.Games)
+	}
+	if len(out.Warnings) != 1 || !strings.Contains(out.Warnings[0], "gog: boom") {
+		t.Fatalf("warnings = %v, want one gog warning", out.Warnings)
+	}
+}
+
+// TestUnknownStoreIsAnError checks that bad input reaches the client as a tool
+// error rather than an empty result.
+func TestUnknownStoreIsAnError(t *testing.T) {
+	ctx := context.Background()
+	srv := New(Options{}) // real factory: only the store name is validated
+
+	st, ct := mcp.NewInMemoryTransports()
+	if _, err := srv.srv.Connect(ctx, st, nil); err != nil {
+		t.Fatalf("server connect: %v", err)
+	}
+	client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
+	cs, err := client.Connect(ctx, ct, nil)
+	if err != nil {
+		t.Fatalf("client connect: %v", err)
+	}
+	defer cs.Close()
+
+	res, err := cs.CallTool(ctx, &mcp.CallToolParams{
+		Name:      "list_offers",
+		Arguments: map[string]any{"store": "nope"},
+	})
+	if err == nil && !res.IsError {
+		t.Fatalf("unknown store: got success %+v, want an error", res)
+	}
+}
+
+// TestHTTPTransport pins that a client can drive the tools over streamable
+// HTTP — the transport that matters for remote agents.
+func TestHTTPTransport(t *testing.T) {
+	ctx := context.Background()
+	prov := &fakeProvider{name: "epic", games: []game.Game{
+		{Title: "A", ID: "a", Store: "epic", URL: "https://example.test/a"},
+	}}
+
+	hs := httptest.NewServer(newTestServer(prov).HTTPHandler(HTTPOptions{}))
+	defer hs.Close()
+
+	client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
+	cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: hs.URL + "/mcp"}, nil)
+	if err != nil {
+		t.Fatalf("connect: %v", err)
+	}
+	defer cs.Close()
+
+	var out gamesOut
+	callTool(t, cs, "list_offers", map[string]any{}, &out)
+	if len(out.Games) != 1 || out.Games[0].Title != "A" {
+		t.Fatalf("games = %+v, want one game A", out.Games)
+	}
+}
+
+// TestHTTPStateless pins that stateless mode works: no session header, every
+// request self-contained. The tools hold no state, so nothing is lost.
+func TestHTTPStateless(t *testing.T) {
+	ctx := context.Background()
+	prov := &fakeProvider{name: "epic", games: []game.Game{{Title: "A", ID: "a"}}}
+
+	hs := httptest.NewServer(newTestServer(prov).HTTPHandler(HTTPOptions{Stateless: true}))
+	defer hs.Close()
+
+	client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
+	cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: hs.URL + "/mcp"}, nil)
+	if err != nil {
+		t.Fatalf("connect: %v", err)
+	}
+	defer cs.Close()
+
+	var out gamesOut
+	callTool(t, cs, "list_offers", map[string]any{}, &out)
+	if len(out.Games) != 1 || out.Games[0].Title != "A" {
+		t.Fatalf("games = %+v, want one game A", out.Games)
+	}
+}
+
+// TestHTTPTokenRequired pins that a configured token actually guards the
+// endpoint while /healthz stays reachable for monitoring.
+func TestHTTPTokenRequired(t *testing.T) {
+	hs := httptest.NewServer(newTestServer(&fakeProvider{name: "epic"}).HTTPHandler(
+		HTTPOptions{Token: "s3cret"}))
+	defer hs.Close()
+
+	resp, err := http.Post(hs.URL+"/mcp", "application/json", strings.NewReader(`{}`))
+	if err != nil {
+		t.Fatalf("post: %v", err)
+	}
+	resp.Body.Close()
+	if resp.StatusCode != http.StatusUnauthorized {
+		t.Fatalf("status = %d, want 401 without token", resp.StatusCode)
+	}
+	if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
+		t.Fatalf("WWW-Authenticate = %q, want a Bearer challenge", got)
+	}
+
+	hresp, err := http.Get(hs.URL + "/healthz")
+	if err != nil {
+		t.Fatalf("healthz: %v", err)
+	}
+	hresp.Body.Close()
+	if hresp.StatusCode != http.StatusOK {
+		t.Fatalf("healthz status = %d, want 200 without token", hresp.StatusCode)
+	}
+}
+
+// TestListModesPinsCapabilityMatrix pins the mode semantics that mirror
+// `cheapo list`: free from everyone, upcoming and deals only from stores with
+// that capability, unknown modes rejected.
+func TestListModesPinsCapabilityMatrix(t *testing.T) {
+	prov := &fakeProvider{
+		name:     "epic",
+		games:    []game.Game{{Title: "Free", ID: "f"}},
+		upcoming: []game.Game{{Title: "Soon", ID: "s"}},
+		deals:    []game.Game{{Title: "Cheap", ID: "c", DiscountPct: 50}},
+	}
+	cs := newTestSession(t, prov)
+
+	for _, tc := range []struct {
+		mode  string
+		title string
+	}{
+		{"", "Free"},
+		{"free", "Free"},
+		{"upcoming", "Soon"},
+		{"deals", "Cheap"},
+	} {
+		var out gamesOut
+		args := map[string]any{}
+		if tc.mode != "" {
+			args["mode"] = tc.mode
+		}
+		callTool(t, cs, "list_offers", args, &out)
+		if len(out.Games) != 1 || out.Games[0].Title != tc.title {
+			t.Fatalf("mode %q: games = %+v, want %q", tc.mode, out.Games, tc.title)
+		}
+	}
+
+	// Unknown mode must surface as a tool error, not an empty list.
+	res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+		Name:      "list_offers",
+		Arguments: map[string]any{"mode": "nonsense"},
+	})
+	if err != nil {
+		t.Fatalf("call: %v", err)
+	}
+	if !res.IsError {
+		t.Fatalf("unknown mode: got success %+v, want an error", res.StructuredContent)
+	}
+	var msg string
+	for _, c := range res.Content {
+		if tc, ok := c.(*mcp.TextContent); ok {
+			msg += tc.Text
+		}
+	}
+	if !strings.Contains(msg, "unknown mode") {
+		t.Fatalf("error content %q, want the ParseMode message", msg)
+	}
+}

+ 93 - 0
internal/ops/ops.go

@@ -0,0 +1,93 @@
+// Package ops holds the list operations shared by the CLI and the MCP server.
+//
+// Both surfaces expose the same three modes; keeping the fetch loop here is
+// what keeps them congruent instead of drifting apart.
+package ops
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"gogs.netdome.biz/paul/cheapo/internal/game"
+)
+
+// Mode selects what List returns.
+type Mode string
+
+const (
+	// Free returns games that are free right now.
+	Free Mode = "free"
+	// Upcoming returns announced future giveaways (stores with that capability only).
+	Upcoming Mode = "upcoming"
+	// Deals returns discounted games (stores with that capability only).
+	Deals Mode = "deals"
+)
+
+// Modes lists the accepted Mode values in a stable order.
+var Modes = []Mode{Free, Upcoming, Deals}
+
+// ParseMode maps a mode name to a Mode. The empty string means Free.
+func ParseMode(name string) (Mode, error) {
+	switch m := Mode(name); m {
+	case "", Free:
+		return Free, nil
+	case Upcoming, Deals:
+		return m, nil
+	default:
+		return "", fmt.Errorf("unknown mode %q (free, upcoming, deals)", name)
+	}
+}
+
+// List queries provs for mode.
+//
+// Stores without the requested capability are skipped silently (Steam has no
+// upcoming window, GOG no deals). A failing store becomes a warning instead of
+// aborting, so one flaky endpoint does not hide the others. The error return
+// is only for an unknown mode.
+func List(ctx context.Context, provs []game.Provider, mode Mode) ([]game.Game, []string, error) {
+	m, err := ParseMode(string(mode))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var (
+		all      []game.Game
+		warnings []string
+	)
+	for _, p := range provs {
+		games, err := fetch(ctx, p, m)
+		if errors.Is(err, errUnsupported) {
+			continue
+		}
+		if err != nil {
+			warnings = append(warnings, fmt.Sprintf("%s: %v", p.Name(), err))
+			continue
+		}
+		all = append(all, games...)
+	}
+	return all, warnings, nil
+}
+
+// errUnsupported marks a provider that cannot answer the requested mode.
+var errUnsupported = errors.New("mode not supported by provider")
+
+// fetch asks a single provider for the games of mode.
+func fetch(ctx context.Context, p game.Provider, mode Mode) ([]game.Game, error) {
+	switch mode {
+	case Upcoming:
+		u, ok := p.(game.UpcomingProvider)
+		if !ok {
+			return nil, errUnsupported
+		}
+		return u.GetUpcomingGames(ctx)
+	case Deals:
+		d, ok := p.(game.DealProvider)
+		if !ok {
+			return nil, errUnsupported
+		}
+		return d.GetDeals(ctx)
+	default:
+		return p.GetFreeGames(ctx)
+	}
+}

+ 51 - 0
internal/providers/providers.go

@@ -0,0 +1,51 @@
+// Package providers builds the store clients selected by a store name.
+//
+// It is shared by the CLI (cmd/cheapo) and the MCP server so both select
+// stores and regional settings the same way.
+package providers
+
+import (
+	"fmt"
+
+	"gogs.netdome.biz/paul/cheapo/internal/epic"
+	"gogs.netdome.biz/paul/cheapo/internal/game"
+	"gogs.netdome.biz/paul/cheapo/internal/gog"
+	"gogs.netdome.biz/paul/cheapo/internal/steam"
+)
+
+// Names lists the accepted values of Options.Store.
+var Names = []string{"epic", "steam", "gog", "all"}
+
+// Options selects stores and regional settings.
+type Options struct {
+	Store   string // epic, steam, gog or all; empty means all
+	Country string // ISO country code for Epic, empty keeps the client default
+	Locale  string // store locale for Epic, empty keeps the client default
+}
+
+// New returns the providers selected by opts.Store.
+func New(opts Options) ([]game.Provider, error) {
+	newEpic := func() *epic.Client {
+		c := epic.NewClient()
+		if opts.Country != "" {
+			c.Country = opts.Country
+		}
+		if opts.Locale != "" {
+			c.Locale = opts.Locale
+		}
+		return c
+	}
+
+	switch opts.Store {
+	case "epic":
+		return []game.Provider{newEpic()}, nil
+	case "steam":
+		return []game.Provider{steam.NewClient()}, nil
+	case "gog":
+		return []game.Provider{gog.NewClient()}, nil
+	case "", "all":
+		return []game.Provider{newEpic(), steam.NewClient(), gog.NewClient()}, nil
+	default:
+		return nil, fmt.Errorf("unknown store %q (epic, steam, gog, all)", opts.Store)
+	}
+}