|
@@ -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)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|