Files
gronod 9339237cde fix: make lyrics_or_description optional in search schema (1.0.13)
Re-adding the field in 1.0.12 made it required (go-sdk requires all non-omitempty fields), so callers that dropped the key failed validation. Mark it omitempty so it is accepted but never required.
2026-09-22 00:43:10 +01:00

343 lines
11 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
"git.i3omb.com/gronod/emby-mcp/internal/state"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// newToolTestClient wires an MCP client session to a server backed by the
// given Emby stub handler.
func newToolTestClient(t *testing.T, handler http.HandlerFunc) *mcp.ClientSession {
t.Helper()
embySrv := httptest.NewServer(handler)
t.Cleanup(embySrv.Close)
st := state.New(emby.NewClient(embySrv.URL, emby.Options{VerifySSL: true}), "u1", 0)
srv := New(st)
ctx := context.Background()
t1, t2 := mcp.NewInMemoryTransports()
if _, err := srv.Connect(ctx, t1, nil); err != nil {
t.Fatal(err)
}
client := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil)
cs, err := client.Connect(ctx, t2, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cs.Close() })
return cs
}
func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string {
t.Helper()
res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return res.Content[0].(*mcp.TextContent).Text
}
func qg(q map[string][]string, k string) string {
if v := q[k]; len(v) > 0 {
return v[0]
}
return ""
}
func sessionJSON(cmds []string) []map[string]any {
return []map[string]any{{
"Id": "sess1", "Client": "Emby Web", "PlayableMediaTypes": []string{"Video"},
"SupportedCommands": cmds,
"NowPlayingItem": map[string]any{
"Id": "ep1", "Name": "Ep1", "SeriesName": "Show", "MediaType": "Video",
"MediaSources": []map[string]any{
{"MediaStreams": []map[string]any{
{"Index": 0, "Type": "Video"},
{"Index": 1, "Type": "Audio", "Language": "eng", "DisplayTitle": "English"},
{"Index": 2, "Type": "Subtitle", "Language": "eng", "DisplayTitle": "English (SRT)"},
{"Index": 3, "Type": "Subtitle", "Language": "fra", "DisplayTitle": "French (SRT)", "IsForced": true},
}},
},
},
"PlayState": map[string]any{"PositionTicks": 610000000, "CanSeek": true, "AudioStreamIndex": 1},
}}
}
func TestRetrieveEpisodeListTool(t *testing.T) {
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/Shows/s1/Episodes" {
t.Errorf("unexpected path %s", r.URL.Path)
}
json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 2,
"Items": []map[string]any{
{"Id": "e1", "Name": "Ep1", "Type": "Episode", "ParentIndexNumber": 1, "IndexNumber": 1},
{"Id": "e2", "Name": "Ep2", "Type": "Episode", "ParentIndexNumber": 1, "IndexNumber": 2,
"UserData": map[string]any{"Played": true}},
},
})
})
txt := callTool(t, cs, "retrieve_episode_list", map[string]any{"series_id": "s1", "season_id": ""})
var r chunkResult
if err := json.Unmarshal([]byte(txt), &r); err != nil {
t.Fatalf("result = %q", txt)
}
if len(r.Items) != 2 || r.Items[1].ItemID != "e2" || !r.Items[1].Played {
t.Fatalf("items = %+v", r.Items)
}
}
func TestRetrieveNextEpisodeByName(t *testing.T) {
var nextUpCalled bool
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/Users/u1/Items":
if r.URL.Query().Get("IncludeItemTypes") != "Series" {
t.Errorf("query = %v", r.URL.Query())
}
json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{"Id": "s1", "Name": "Family Guy", "Type": "Series"}},
})
case "/Shows/NextUp":
nextUpCalled = true
json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{
"Id": "e9", "Name": "Next Ep", "Type": "Episode",
"ParentIndexNumber": 24, "IndexNumber": 9, "SeriesName": "Family Guy",
}},
})
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
txt := callTool(t, cs, "retrieve_next_episode",
map[string]any{"series_name": "family guy", "series_id": "", "mode": ""})
if !nextUpCalled {
t.Fatal("NextUp not called")
}
var ep map[string]any
if err := json.Unmarshal([]byte(txt), &ep); err != nil {
t.Fatalf("result = %q", txt)
}
if ep["item_id"] != "e9" || ep["series_name"] != "Family Guy" || ep["series_id"] != "s1" {
t.Fatalf("ep = %v", ep)
}
}
func TestRetrieveNextEpisodeAmbiguous(t *testing.T) {
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "s1", "Name": "Family Guy", "Type": "Series"},
{"Id": "s2", "Name": "Family Guy Specials", "Type": "Series"},
},
})
})
txt := callTool(t, cs, "retrieve_next_episode",
map[string]any{"series_name": "family", "series_id": "", "mode": "next_unplayed"})
if !strings.Contains(txt, "Candidates") || !strings.Contains(txt, "s2") {
t.Fatalf("expected ambiguity error, got %q", txt)
}
}
func TestRetrieveNextEpisodeLatest(t *testing.T) {
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/Shows/s1/Episodes" {
t.Errorf("unexpected path %s", r.URL.Path)
}
if r.URL.Query().Get("SortOrder") != "Descending" {
t.Errorf("query = %v", r.URL.Query())
}
json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{"Id": "e15", "Name": "Latest", "Type": "Episode"}},
})
})
txt := callTool(t, cs, "retrieve_next_episode",
map[string]any{"series_name": "", "series_id": "s1", "mode": "latest"})
if !strings.Contains(txt, `"item_id":"e15"`) {
t.Fatalf("result = %q", txt)
}
}
func TestRetrieveNowPlaying(t *testing.T) {
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(sessionJSON([]string{"SetSubtitleStreamIndex"}))
})
txt := callTool(t, cs, "retrieve_now_playing", map[string]any{"session_id": "sess1"})
var np nowPlaying
if err := json.Unmarshal([]byte(txt), &np); err != nil {
t.Fatalf("result = %q", txt)
}
if np.ItemID != "ep1" || len(np.SubtitleStreams) != 2 || len(np.AudioStreams) != 1 {
t.Fatalf("np = %+v", np)
}
if np.AudioStreams[0].IsSelected != true || np.PositionMilliseconds != 61000 {
t.Fatalf("np = %+v", np)
}
if np.SubtitleStreams[1].IsForced != true {
t.Fatalf("subs = %+v", np.SubtitleStreams)
}
}
func TestSetSubtitleSupported(t *testing.T) {
var cmdBody map[string]any
applied := false
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/Sessions":
s := sessionJSON([]string{"SetSubtitleStreamIndex"})
if applied {
s[0]["PlayState"].(map[string]any)["SubtitleStreamIndex"] = 3
}
json.NewEncoder(w).Encode(s)
case "/Sessions/sess1/Command":
json.NewDecoder(r.Body).Decode(&cmdBody)
applied = true
w.WriteHeader(200)
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "french"})
if txt != "Success" {
t.Fatalf("result = %q", txt)
}
if cmdBody["Name"] != "SetSubtitleStreamIndex" {
t.Fatalf("body = %v", cmdBody)
}
args := cmdBody["Arguments"].(map[string]any)
if args["Index"] != "3" {
t.Fatalf("args = %v", args)
}
}
func TestSetSubtitleOff(t *testing.T) {
var cmdBody map[string]any
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/Sessions" {
s := sessionJSON([]string{"SetSubtitleStreamIndex"})
s[0]["PlayState"].(map[string]any)["SubtitleStreamIndex"] = -1
json.NewEncoder(w).Encode(s)
return
}
json.NewDecoder(r.Body).Decode(&cmdBody)
w.WriteHeader(200)
})
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "off"})
if txt != "Success" {
t.Fatalf("result = %q", txt)
}
if cmdBody["Arguments"].(map[string]any)["Index"] != "-1" {
t.Fatalf("body = %v", cmdBody)
}
}
func TestSetSubtitleFallbackRestart(t *testing.T) {
var playBody map[string]any
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/Sessions":
// No SupportedCommands — DLNA-style session.
json.NewEncoder(w).Encode(sessionJSON(nil))
case "/Sessions/sess1/Playing":
json.NewDecoder(r.Body).Decode(&playBody)
w.WriteHeader(200)
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "2"})
if !strings.Contains(txt, "Success") {
t.Fatalf("result = %q", txt)
}
if playBody["SubtitleStreamIndex"] != float64(2) {
t.Fatalf("body = %v", playBody)
}
if playBody["StartPositionTicks"] != float64(610000000) {
t.Fatalf("expected resume position, body = %v", playBody)
}
}
func TestSetSubtitleIgnoredCommandFallsBack(t *testing.T) {
var playBody map[string]any
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/Sessions":
// Claims support but PlayState never reflects the selection.
json.NewEncoder(w).Encode(sessionJSON([]string{"SetSubtitleStreamIndex"}))
case "/Sessions/sess1/Command":
w.WriteHeader(200)
case "/Sessions/sess1/Playing":
json.NewDecoder(r.Body).Decode(&playBody)
w.WriteHeader(200)
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "2"})
if !strings.Contains(txt, "restarted") {
t.Fatalf("result = %q", txt)
}
if playBody["SubtitleStreamIndex"] != float64(2) {
t.Fatalf("body = %v", playBody)
}
}
func TestSetSubtitleNoMatch(t *testing.T) {
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(sessionJSON(nil))
})
txt := callTool(t, cs, "set_subtitle", map[string]any{"session_id": "sess1", "track": "klingon"})
if !strings.Contains(txt, "ERROR") || !strings.Contains(txt, "Available tracks") {
t.Fatalf("result = %q", txt)
}
}
func TestSearchForItemItemTypes(t *testing.T) {
var q map[string][]string
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
q = r.URL.Query()
json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{"Id": "s1", "Name": "Family Guy", "Type": "Series"}},
})
})
// No library selected — allowed because item_types is set.
txt := callTool(t, cs, "search_for_item", map[string]any{
"title_or_album": "family guy", "artist_name": "", "genre_name": "",
"broadcast_release_years": "", "lyrics_or_description": "", "item_types": "Series",
})
if !strings.Contains(txt, "Family Guy") {
t.Fatalf("result = %q", txt)
}
if qg(q, "IncludeItemTypes") != "Series" {
t.Fatalf("query = %v", q)
}
if _, ok := q["MediaTypes"]; ok {
t.Fatal("MediaTypes should be omitted when item_types is set")
}
}
func TestSearchForItemWithoutLegacyLyricsArg(t *testing.T) {
cs := newToolTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{"Id": "s1", "Name": "Family Guy", "Type": "Series"}},
})
})
// lyrics_or_description is optional: omitting it must not fail validation.
txt := callTool(t, cs, "search_for_item", map[string]any{
"title_or_album": "family guy", "artist_name": "", "genre_name": "",
"broadcast_release_years": "", "item_types": "Series",
})
if !strings.Contains(txt, "Family Guy") {
t.Fatalf("result = %q", txt)
}
}