Quellcode durchsuchen

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 vor 2 Tagen
Ursprung
Commit
40f04b73fc
12 geänderte Dateien mit 1075 neuen und 192 gelöschten Zeilen
  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)
+	}
+}