Item-shaped results now omit empty fields and drop low-value metadata (overview, lyrics, file_path, creation_date, premiere_date, bitrate); absent numeric fields drop the key instead of emitting "". Player sessions report hh:mm:ss times only; playlists drop overview/date_created. The lyrics_or_description search parameter is removed, along with the unused DTO fields, and upstream Fields requests are slimmed to match. Module path renamed to git.i3omb.com/gronod/emby-mcp.
214 lines
6.4 KiB
Go
214 lines
6.4 KiB
Go
package mcphttp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.i3omb.com/gronod/emby-mcp/internal/config"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// fakeEmby implements just enough of the Emby API for auth + one tool call.
|
|
func fakeEmby(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodPost && r.URL.Path == "/Users/AuthenticateByName":
|
|
var body struct {
|
|
Username string
|
|
Pw string
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
if body.Username != "alice" || body.Pw != "pw" {
|
|
http.Error(w, "bad creds", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"AccessToken": "atok",
|
|
"User": map[string]any{"Id": "u1", "Name": "alice"},
|
|
})
|
|
case r.Method == http.MethodGet && r.URL.Path == "/Sessions":
|
|
// Token resolution: the auth probe's session.
|
|
json.NewEncoder(w).Encode([]map[string]any{
|
|
{"Id": "s1", "DeviceId": authDeviceID, "UserId": "u1"},
|
|
})
|
|
case r.Method == http.MethodGet && r.URL.Path == "/Users/u2":
|
|
json.NewEncoder(w).Encode(map[string]any{"Id": "u2", "Name": "bob"})
|
|
case r.Method == http.MethodGet && r.URL.Path == "/Library/MediaFolders":
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"TotalRecordCount": 1,
|
|
"Items": []map[string]any{
|
|
{"Name": "Music", "Type": "CollectionFolder", "CollectionType": "music", "Id": "L1"},
|
|
},
|
|
})
|
|
default:
|
|
t.Errorf("unexpected call %s %s", r.Method, r.URL.Path)
|
|
http.Error(w, "nope", http.StatusNotFound)
|
|
}
|
|
}))
|
|
}
|
|
|
|
func testConfig(embyURL string) *config.Config {
|
|
return &config.Config{
|
|
ServerURL: embyURL,
|
|
VerifySSL: true,
|
|
Transport: config.TransportHTTP,
|
|
ListenAddr: "127.0.0.1:0",
|
|
SessionTimeout: time.Minute,
|
|
MaxChunkSize: 10,
|
|
}
|
|
}
|
|
|
|
type headerTransport struct{ headers map[string]string }
|
|
|
|
func (t headerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
for k, v := range t.headers {
|
|
r.Header.Set(k, v)
|
|
}
|
|
return http.DefaultTransport.RoundTrip(r)
|
|
}
|
|
|
|
func mcpClient(t *testing.T, endpoint, authHeader string, extra map[string]string) *mcp.ClientSession {
|
|
t.Helper()
|
|
h := map[string]string{"Authorization": authHeader}
|
|
for k, v := range extra {
|
|
h[k] = v
|
|
}
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil)
|
|
cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
|
|
Endpoint: endpoint,
|
|
HTTPClient: &http.Client{Transport: headerTransport{h}},
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
return cs
|
|
}
|
|
|
|
func TestHTTPBearerEndToEnd(t *testing.T) {
|
|
embySrv := fakeEmby(t)
|
|
defer embySrv.Close()
|
|
srv := httptest.NewServer(NewHandler(testConfig(embySrv.URL), "testhost"))
|
|
defer srv.Close()
|
|
|
|
cs := mcpClient(t, srv.URL+Endpoint, "Bearer usertoken", nil)
|
|
defer cs.Close()
|
|
|
|
res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "retrieve_library_list"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := res.Content[0].(*mcp.TextContent).Text
|
|
if !strings.Contains(text, "Music") {
|
|
t.Fatalf("result = %s", text)
|
|
}
|
|
}
|
|
|
|
func TestHTTPBasicEndToEnd(t *testing.T) {
|
|
embySrv := fakeEmby(t)
|
|
defer embySrv.Close()
|
|
srv := httptest.NewServer(NewHandler(testConfig(embySrv.URL), "testhost"))
|
|
defer srv.Close()
|
|
|
|
cs := mcpClient(t, srv.URL+Endpoint,
|
|
"Basic "+base64.StdEncoding.EncodeToString([]byte("alice:pw")), nil)
|
|
defer cs.Close()
|
|
|
|
res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "retrieve_library_list"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(res.Content[0].(*mcp.TextContent).Text, "Music") {
|
|
t.Fatalf("result = %v", res.Content[0])
|
|
}
|
|
}
|
|
|
|
func TestHTTPBearerWithUserIDHeader(t *testing.T) {
|
|
embySrv := fakeEmby(t)
|
|
defer embySrv.Close()
|
|
srv := httptest.NewServer(NewHandler(testConfig(embySrv.URL), "testhost"))
|
|
defer srv.Close()
|
|
|
|
cs := mcpClient(t, srv.URL+Endpoint, "Bearer apikey", map[string]string{HeaderUserID: "u2"})
|
|
defer cs.Close()
|
|
}
|
|
|
|
func TestHTTPUnauthorized(t *testing.T) {
|
|
embySrv := fakeEmby(t)
|
|
defer embySrv.Close()
|
|
srv := httptest.NewServer(NewHandler(testConfig(embySrv.URL), "testhost"))
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Post(srv.URL+Endpoint, "application/json", strings.NewReader(`{}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
|
|
// Bad basic credentials → 401.
|
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+Endpoint, strings.NewReader(`{}`))
|
|
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("alice:wrong")))
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// A session created by one user must reject requests authenticated as another.
|
|
func TestHTTPSessionHijackRejected(t *testing.T) {
|
|
embySrv := fakeEmby(t)
|
|
defer embySrv.Close()
|
|
srv := httptest.NewServer(NewHandler(testConfig(embySrv.URL), "testhost"))
|
|
defer srv.Close()
|
|
|
|
init := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}`
|
|
newReq := func(auth, sessionID string) *http.Request {
|
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+Endpoint, bytes.NewReader([]byte(init)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
|
req.Header.Set("Authorization", auth)
|
|
if sessionID != "" {
|
|
req.Header.Set("Mcp-Session-Id", sessionID)
|
|
}
|
|
return req
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(newReq("Bearer usertoken", ""))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sessionID := resp.Header.Get("Mcp-Session-Id")
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK || sessionID == "" {
|
|
t.Fatalf("initialize: status=%d session=%q", resp.StatusCode, sessionID)
|
|
}
|
|
|
|
// Same session, different user (u2 via disambiguation header) → 403.
|
|
hijack := newReq("Bearer apikey", sessionID)
|
|
hijack.Header.Set(HeaderUserID, "u2")
|
|
resp, err = http.DefaultClient.Do(hijack)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("hijack status = %d", resp.StatusCode)
|
|
}
|
|
}
|