| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- 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)
- }
- }
|