mcpserver_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. package mcpserver
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/modelcontextprotocol/go-sdk/mcp"
  12. "gogs.netdome.biz/paul/cheapo/internal/game"
  13. )
  14. // fakeProvider returns a fixed (mutable) game list, so tests can simulate a
  15. // store whose offers change between polls. Optional capabilities (upcoming,
  16. // deals) are enabled via the flags.
  17. type fakeProvider struct {
  18. name string
  19. games []game.Game
  20. err error
  21. upcoming []game.Game
  22. deals []game.Game
  23. }
  24. func (f *fakeProvider) Name() string { return f.name }
  25. func (f *fakeProvider) GetFreeGames(context.Context) ([]game.Game, error) {
  26. if f.err != nil {
  27. return nil, f.err
  28. }
  29. return f.games, nil
  30. }
  31. func (f *fakeProvider) GetUpcomingGames(context.Context) ([]game.Game, error) {
  32. if f.err != nil {
  33. return nil, f.err
  34. }
  35. return f.upcoming, nil
  36. }
  37. func (f *fakeProvider) GetDeals(context.Context) ([]game.Game, error) {
  38. if f.err != nil {
  39. return nil, f.err
  40. }
  41. return f.deals, nil
  42. }
  43. // newTestServer builds a server backed by prov instead of the real stores.
  44. func newTestServer(prov game.Provider) *Server {
  45. return New(Options{Version: "test", newProviders: func(_, _, _ string) ([]game.Provider, error) {
  46. return []game.Provider{prov}, nil
  47. }})
  48. }
  49. // newTestSession starts an in-memory server/client pair for prov.
  50. func newTestSession(t *testing.T, prov game.Provider) *mcp.ClientSession {
  51. t.Helper()
  52. ctx := context.Background()
  53. srv := newTestServer(prov)
  54. st, ct := mcp.NewInMemoryTransports()
  55. ss, err := srv.srv.Connect(ctx, st, nil)
  56. if err != nil {
  57. t.Fatalf("server connect: %v", err)
  58. }
  59. t.Cleanup(func() { _ = ss.Close() })
  60. client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
  61. cs, err := client.Connect(ctx, ct, nil)
  62. if err != nil {
  63. t.Fatalf("client connect: %v", err)
  64. }
  65. t.Cleanup(func() { _ = cs.Close() })
  66. return cs
  67. }
  68. // callTool invokes name with args and decodes the structured result into out.
  69. func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any, out any) {
  70. t.Helper()
  71. res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
  72. if err != nil {
  73. t.Fatalf("%s: %v", name, err)
  74. }
  75. if res.IsError {
  76. t.Fatalf("%s returned an error result: %+v", name, res.Content)
  77. }
  78. if res.StructuredContent == nil {
  79. t.Fatalf("%s returned no structured content", name)
  80. }
  81. raw, err := json.Marshal(res.StructuredContent)
  82. if err != nil {
  83. t.Fatalf("%s: marshal structured content: %v", name, err)
  84. }
  85. if err := json.Unmarshal(raw, out); err != nil {
  86. t.Fatalf("%s: decode %s: %v", name, raw, err)
  87. }
  88. }
  89. // TestToolsAreRegistered pins the tool names an MCP client will discover.
  90. func TestToolsAreRegistered(t *testing.T) {
  91. cs := newTestSession(t, &fakeProvider{name: "fake"})
  92. var names []string
  93. for tool, err := range cs.Tools(context.Background(), nil) {
  94. if err != nil {
  95. t.Fatalf("tools: %v", err)
  96. }
  97. names = append(names, tool.Name)
  98. }
  99. got := strings.Join(names, ",")
  100. if got != "list_offers" {
  101. t.Fatalf("tools = %q, want only list_offers", got)
  102. }
  103. }
  104. // TestListFreeGamesReturnsAllGames checks the plain listing path, including
  105. // that time.Time fields survive the JSON schema round-trip.
  106. func TestListFreeGamesReturnsAllGames(t *testing.T) {
  107. end := time.Date(2026, 11, 2, 17, 0, 0, 0, time.UTC)
  108. cs := newTestSession(t, &fakeProvider{name: "epic", games: []game.Game{
  109. {Title: "A", ID: "a", Store: "epic", URL: "https://example.test/a", EndDate: end},
  110. {Title: "B", ID: "b", Store: "epic", URL: "https://example.test/b"},
  111. }})
  112. var out gamesOut
  113. callTool(t, cs, "list_offers", map[string]any{}, &out)
  114. if len(out.Games) != 2 {
  115. t.Fatalf("games = %d, want 2", len(out.Games))
  116. }
  117. if out.Games[0].Title != "A" || out.Games[0].Ends != "2026-11-02T17:00:00Z" {
  118. t.Fatalf("first game = %+v, want A ending 2026-11-02T17:00:00Z", out.Games[0])
  119. }
  120. if out.Games[1].Ends != "" {
  121. t.Fatalf("second game = %+v, want no end date rather than a zero timestamp", out.Games[1])
  122. }
  123. if len(out.Warnings) != 0 {
  124. t.Fatalf("warnings = %v, want none", out.Warnings)
  125. }
  126. }
  127. // TestListFreeGamesEmptyIsEmptyArray pins that "no games" serializes as []
  128. // rather than null, which clients handle differently.
  129. func TestListFreeGamesEmptyIsEmptyArray(t *testing.T) {
  130. cs := newTestSession(t, &fakeProvider{name: "steam"})
  131. res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "list_offers"})
  132. if err != nil {
  133. t.Fatalf("list_offers: %v", err)
  134. }
  135. raw, err := json.Marshal(res.StructuredContent)
  136. if err != nil {
  137. t.Fatalf("marshal: %v", err)
  138. }
  139. if !strings.Contains(string(raw), `"games":[]`) {
  140. t.Fatalf("structured content = %s, want an empty games array", raw)
  141. }
  142. }
  143. // TestProviderErrorBecomesWarning pins that a broken store degrades the result
  144. // instead of failing the tool call.
  145. func TestProviderErrorBecomesWarning(t *testing.T) {
  146. prov := &fakeProvider{name: "gog", err: errors.New("boom")}
  147. cs := newTestSession(t, prov)
  148. var out gamesOut
  149. callTool(t, cs, "list_offers", map[string]any{}, &out)
  150. if len(out.Games) != 0 {
  151. t.Fatalf("games = %v, want none", out.Games)
  152. }
  153. if len(out.Warnings) != 1 || !strings.Contains(out.Warnings[0], "gog: boom") {
  154. t.Fatalf("warnings = %v, want one gog warning", out.Warnings)
  155. }
  156. }
  157. // TestUnknownStoreIsAnError checks that bad input reaches the client as a tool
  158. // error rather than an empty result.
  159. func TestUnknownStoreIsAnError(t *testing.T) {
  160. ctx := context.Background()
  161. srv := New(Options{}) // real factory: only the store name is validated
  162. st, ct := mcp.NewInMemoryTransports()
  163. if _, err := srv.srv.Connect(ctx, st, nil); err != nil {
  164. t.Fatalf("server connect: %v", err)
  165. }
  166. client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
  167. cs, err := client.Connect(ctx, ct, nil)
  168. if err != nil {
  169. t.Fatalf("client connect: %v", err)
  170. }
  171. defer cs.Close()
  172. res, err := cs.CallTool(ctx, &mcp.CallToolParams{
  173. Name: "list_offers",
  174. Arguments: map[string]any{"store": "nope"},
  175. })
  176. if err == nil && !res.IsError {
  177. t.Fatalf("unknown store: got success %+v, want an error", res)
  178. }
  179. }
  180. // TestHTTPTransport pins that a client can drive the tools over streamable
  181. // HTTP — the transport that matters for remote agents.
  182. func TestHTTPTransport(t *testing.T) {
  183. ctx := context.Background()
  184. prov := &fakeProvider{name: "epic", games: []game.Game{
  185. {Title: "A", ID: "a", Store: "epic", URL: "https://example.test/a"},
  186. }}
  187. hs := httptest.NewServer(newTestServer(prov).HTTPHandler(HTTPOptions{}))
  188. defer hs.Close()
  189. client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
  190. cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: hs.URL + "/mcp"}, nil)
  191. if err != nil {
  192. t.Fatalf("connect: %v", err)
  193. }
  194. defer cs.Close()
  195. var out gamesOut
  196. callTool(t, cs, "list_offers", map[string]any{}, &out)
  197. if len(out.Games) != 1 || out.Games[0].Title != "A" {
  198. t.Fatalf("games = %+v, want one game A", out.Games)
  199. }
  200. }
  201. // TestHTTPStateless pins that stateless mode works: no session header, every
  202. // request self-contained. The tools hold no state, so nothing is lost.
  203. func TestHTTPStateless(t *testing.T) {
  204. ctx := context.Background()
  205. prov := &fakeProvider{name: "epic", games: []game.Game{{Title: "A", ID: "a"}}}
  206. hs := httptest.NewServer(newTestServer(prov).HTTPHandler(HTTPOptions{Stateless: true}))
  207. defer hs.Close()
  208. client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
  209. cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: hs.URL + "/mcp"}, nil)
  210. if err != nil {
  211. t.Fatalf("connect: %v", err)
  212. }
  213. defer cs.Close()
  214. var out gamesOut
  215. callTool(t, cs, "list_offers", map[string]any{}, &out)
  216. if len(out.Games) != 1 || out.Games[0].Title != "A" {
  217. t.Fatalf("games = %+v, want one game A", out.Games)
  218. }
  219. }
  220. // TestHTTPTokenRequired pins that a configured token actually guards the
  221. // endpoint while /healthz stays reachable for monitoring.
  222. func TestHTTPTokenRequired(t *testing.T) {
  223. hs := httptest.NewServer(newTestServer(&fakeProvider{name: "epic"}).HTTPHandler(
  224. HTTPOptions{Token: "s3cret"}))
  225. defer hs.Close()
  226. resp, err := http.Post(hs.URL+"/mcp", "application/json", strings.NewReader(`{}`))
  227. if err != nil {
  228. t.Fatalf("post: %v", err)
  229. }
  230. resp.Body.Close()
  231. if resp.StatusCode != http.StatusUnauthorized {
  232. t.Fatalf("status = %d, want 401 without token", resp.StatusCode)
  233. }
  234. if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
  235. t.Fatalf("WWW-Authenticate = %q, want a Bearer challenge", got)
  236. }
  237. hresp, err := http.Get(hs.URL + "/healthz")
  238. if err != nil {
  239. t.Fatalf("healthz: %v", err)
  240. }
  241. hresp.Body.Close()
  242. if hresp.StatusCode != http.StatusOK {
  243. t.Fatalf("healthz status = %d, want 200 without token", hresp.StatusCode)
  244. }
  245. }
  246. // TestListModesPinsCapabilityMatrix pins the mode semantics that mirror
  247. // `cheapo list`: free from everyone, upcoming and deals only from stores with
  248. // that capability, unknown modes rejected.
  249. func TestListModesPinsCapabilityMatrix(t *testing.T) {
  250. prov := &fakeProvider{
  251. name: "epic",
  252. games: []game.Game{{Title: "Free", ID: "f"}},
  253. upcoming: []game.Game{{Title: "Soon", ID: "s"}},
  254. deals: []game.Game{{Title: "Cheap", ID: "c", DiscountPct: 50}},
  255. }
  256. cs := newTestSession(t, prov)
  257. for _, tc := range []struct {
  258. mode string
  259. title string
  260. }{
  261. {"", "Free"},
  262. {"free", "Free"},
  263. {"upcoming", "Soon"},
  264. {"deals", "Cheap"},
  265. } {
  266. var out gamesOut
  267. args := map[string]any{}
  268. if tc.mode != "" {
  269. args["mode"] = tc.mode
  270. }
  271. callTool(t, cs, "list_offers", args, &out)
  272. if len(out.Games) != 1 || out.Games[0].Title != tc.title {
  273. t.Fatalf("mode %q: games = %+v, want %q", tc.mode, out.Games, tc.title)
  274. }
  275. }
  276. // Unknown mode must surface as a tool error, not an empty list.
  277. res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
  278. Name: "list_offers",
  279. Arguments: map[string]any{"mode": "nonsense"},
  280. })
  281. if err != nil {
  282. t.Fatalf("call: %v", err)
  283. }
  284. if !res.IsError {
  285. t.Fatalf("unknown mode: got success %+v, want an error", res.StructuredContent)
  286. }
  287. var msg string
  288. for _, c := range res.Content {
  289. if tc, ok := c.(*mcp.TextContent); ok {
  290. msg += tc.Text
  291. }
  292. }
  293. if !strings.Contains(msg, "unknown mode") {
  294. t.Fatalf("error content %q, want the ParseMode message", msg)
  295. }
  296. }