| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- package transport
- import (
- "context"
- "encoding/json"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "gogs.netdome.biz/paul/go-wikijs-mcp/internal/server"
- "gogs.netdome.biz/paul/go-wikijs-mcp/internal/wikijs"
- "github.com/modelcontextprotocol/go-sdk/mcp"
- )
- func testLogger() *slog.Logger {
- return slog.New(slog.DiscardHandler)
- }
- func newTestServer(t *testing.T, wikiClient *wikijs.Client) *mcp.Server {
- t.Helper()
- return server.New(wikiClient, server.Options{Version: "test", Logger: testLogger()})
- }
- func newTestHandler(t *testing.T, authToken string) http.Handler {
- t.Helper()
- fakeWiki := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"data":{"pages":{"single":{"id":1,"path":"/start","title":"Start","description":"","tags":[],"content":"body","createdAt":"","updatedAt":"","locale":"en"}}}}`))
- }))
- t.Cleanup(fakeWiki.Close)
- wikiClient, err := wikijs.New(fakeWiki.URL, "key")
- if err != nil {
- t.Fatalf("wikijs.New() error = %v", err)
- }
- srv := newTestServer(t, wikiClient)
- return NewHTTPHandler(HTTPOptions{
- Path: "/mcp",
- AuthToken: authToken,
- Logger: testLogger(),
- NewServer: func(*http.Request) *mcp.Server { return srv },
- })
- }
- func TestHealthzIsOpen(t *testing.T) {
- ts := httptest.NewServer(newTestHandler(t, "secret"))
- t.Cleanup(ts.Close)
- resp, err := http.Get(ts.URL + "/healthz")
- if err != nil {
- t.Fatalf("GET /healthz: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want 200", resp.StatusCode)
- }
- var body map[string]string
- if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
- t.Fatalf("decode body: %v", err)
- }
- if body["status"] != "ok" {
- t.Errorf("body = %v", body)
- }
- }
- func TestMCPRequiresBearerToken(t *testing.T) {
- ts := httptest.NewServer(newTestHandler(t, "secret"))
- t.Cleanup(ts.Close)
- tests := []struct {
- name string
- header string
- want int
- }{
- {name: "missing", want: http.StatusUnauthorized},
- {name: "wrong token", header: "Bearer nope", want: http.StatusUnauthorized},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(`{}`))
- req.Header.Set("Content-Type", "application/json")
- if tt.header != "" {
- req.Header.Set("Authorization", tt.header)
- }
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- t.Fatalf("POST /mcp: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != tt.want {
- t.Errorf("status = %d, want %d", resp.StatusCode, tt.want)
- }
- if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
- t.Errorf("WWW-Authenticate = %q", got)
- }
- })
- }
- }
- func TestBearerSchemeIsCaseInsensitive(t *testing.T) {
- ts := httptest.NewServer(newTestHandler(t, "secret"))
- t.Cleanup(ts.Close)
- req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(`{}`))
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "bearer secret")
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- t.Fatalf("POST /mcp: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode == http.StatusUnauthorized {
- t.Error("lowercase bearer scheme was rejected")
- }
- }
- func TestMCPWithBearerToken(t *testing.T) {
- ts := httptest.NewServer(newTestHandler(t, "secret"))
- t.Cleanup(ts.Close)
- httpClient := &http.Client{Transport: &bearerRoundTripper{token: "secret"}}
- cs := connectHTTP(t, ts.URL+"/mcp", httpClient)
- res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
- Name: "get_page_by_id",
- Arguments: map[string]any{"id": 1},
- })
- if err != nil {
- t.Fatalf("CallTool() error = %v", err)
- }
- if res.IsError {
- t.Fatalf("IsError = true: %v", res.Content)
- }
- }
- func TestMCPWithoutAuthToken(t *testing.T) {
- ts := httptest.NewServer(newTestHandler(t, ""))
- t.Cleanup(ts.Close)
- cs := connectHTTP(t, ts.URL+"/mcp", ts.Client())
- res, err := cs.ListTools(context.Background(), nil)
- if err != nil {
- t.Fatalf("ListTools() error = %v", err)
- }
- if len(res.Tools) != 4 {
- t.Errorf("tools = %d, want 4", len(res.Tools))
- }
- }
- func TestServeHTTPRefusesNonLoopbackWithoutAuth(t *testing.T) {
- err := ServeHTTP(context.Background(), HTTPOptions{
- Addr: "0.0.0.0:0",
- Path: "/mcp",
- Logger: testLogger(),
- NewServer: func(*http.Request) *mcp.Server { return newBareServer() },
- })
- if err == nil {
- t.Fatal("ServeHTTP() error = nil, want refusal")
- }
- if !strings.Contains(err.Error(), "non-loopback") {
- t.Errorf("error = %q, want non-loopback refusal", err)
- }
- }
- func TestServeHTTPShutdown(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- done := make(chan error, 1)
- go func() {
- done <- ServeHTTP(ctx, HTTPOptions{
- Addr: "127.0.0.1:0",
- Path: "/mcp",
- Logger: testLogger(),
- NewServer: func(*http.Request) *mcp.Server { return newBareServer() },
- })
- }()
- time.Sleep(100 * time.Millisecond)
- cancel()
- select {
- case err := <-done:
- if err != nil {
- t.Fatalf("ServeHTTP() error = %v", err)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("ServeHTTP() did not stop after cancellation")
- }
- }
- func newBareServer() *mcp.Server {
- return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil)
- }
- func connectHTTP(t *testing.T, endpoint string, httpClient *http.Client) *mcp.ClientSession {
- t.Helper()
- client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil)
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- t.Cleanup(cancel)
- cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{
- Endpoint: endpoint,
- HTTPClient: httpClient,
- DisableStandaloneSSE: true,
- }, nil)
- if err != nil {
- t.Fatalf("client connect: %v", err)
- }
- t.Cleanup(func() { _ = cs.Close() })
- return cs
- }
- type bearerRoundTripper struct {
- token string
- }
- func (t *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
- req = req.Clone(req.Context())
- req.Header.Set("Authorization", "Bearer "+t.token)
- return http.DefaultTransport.RoundTrip(req)
- }
|