Files
gronod 9149ec844a fix: filter stale REST args against tool schema, keep lyrics_or_description (1.0.12)
The HA conversation agent still posts lyrics_or_description on every
search_for_item call; the go-sdk's additionalProperties:false validation
rejected the whole request, breaking search. The REST bridge now caches
each tool's input schema from tools/list and drops undeclared arguments,
reporting them as dropped_arguments, while search_for_item re-accepts the
param as a deprecated no-op for direct MCP callers.
2026-09-22 00:24:50 +01:00

166 lines
5.1 KiB
Go

package bridge
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.i3omb.com/gronod/emby-mcp/internal/config"
)
// sse writes a streamable-HTTP style response: event/data framing with one
// JSON payload.
func sse(w http.ResponseWriter, v any) {
b, _ := json.Marshal(v)
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "event: message\ndata: %s\n\n", b)
}
// mcpStub emulates the MCP endpoint: initialize hands out a session id,
// tools/list serves the canned schemas, tools/call reports the forwarded
// params through onCall.
func mcpStub(t *testing.T, tools []map[string]any, onCall func(map[string]any)) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
switch req["method"] {
case "initialize":
w.Header().Set("Mcp-Session-Id", "test-sess")
sse(w, map[string]any{"jsonrpc": "2.0", "id": req["id"],
"result": map[string]any{"protocolVersion": "2025-03-26",
"serverInfo": map[string]any{"name": "stub", "version": "1"},
"capabilities": map[string]any{}}})
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
case "tools/list":
if tools == nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
sse(w, map[string]any{"jsonrpc": "2.0", "id": req["id"],
"result": map[string]any{"tools": tools}})
case "tools/call":
params, _ := req["params"].(map[string]any)
onCall(params)
sse(w, map[string]any{"jsonrpc": "2.0", "id": req["id"],
"result": map[string]any{"content": []any{
map[string]any{"type": "text", "text": "Success"}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}
}
// newBridge wires a bridge handler to the stub MCP server and returns a test
// server serving the bridge routes.
func newBridge(t *testing.T, mcp http.HandlerFunc) *httptest.Server {
t.Helper()
mcpSrv := httptest.NewServer(mcp)
t.Cleanup(mcpSrv.Close)
t.Setenv("MCP_INTERNAL_URL", mcpSrv.URL+"/mcp")
sess = nil
mux := http.NewServeMux()
NewHandler(&config.Config{}).Register(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func callREST(t *testing.T, srv *httptest.Server, tool, body string) map[string]any {
t.Helper()
resp, err := http.Post(srv.URL+"/call/"+tool, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out
}
var searchTool = map[string]any{
"name": "search_for_item",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"title_or_album": map[string]any{"type": "string"},
"item_types": map[string]any{"type": "string"},
"lyrics_or_description": map[string]any{"type": "string"},
},
},
}
func TestCallToolDropsUndeclaredArgs(t *testing.T) {
var gotArgs map[string]any
srv := newBridge(t, mcpStub(t, []map[string]any{searchTool}, func(params map[string]any) {
gotArgs, _ = params["arguments"].(map[string]any)
}))
out := callREST(t, srv, "search_for_item",
`{"title_or_album":"Peppa Pig","lyrics_or_description":"","bogus_arg":"x"}`)
if _, ok := gotArgs["bogus_arg"]; ok {
t.Fatalf("bogus_arg was forwarded: %v", gotArgs)
}
if gotArgs["title_or_album"] != "Peppa Pig" {
t.Fatalf("title_or_album missing: %v", gotArgs)
}
if _, ok := gotArgs["lyrics_or_description"]; !ok {
t.Fatalf("declared lyrics_or_description was dropped: %v", gotArgs)
}
dropped, _ := out["dropped_arguments"].([]any)
if len(dropped) != 1 || dropped[0] != "bogus_arg" {
t.Fatalf("dropped_arguments = %v", out)
}
if out["result"] != "Success" {
t.Fatalf("result = %v", out)
}
}
func TestCallToolNoDroppedKeyWhenClean(t *testing.T) {
srv := newBridge(t, mcpStub(t, []map[string]any{searchTool}, func(map[string]any) {}))
out := callREST(t, srv, "search_for_item", `{"title_or_album":"x"}`)
if _, ok := out["dropped_arguments"]; ok {
t.Fatalf("unexpected dropped_arguments: %v", out)
}
}
func TestCallToolFailsOpenWhenSchemaUnavailable(t *testing.T) {
var gotArgs map[string]any
// tools == nil → tools/list returns 500, args must pass through.
srv := newBridge(t, mcpStub(t, nil, func(params map[string]any) {
gotArgs, _ = params["arguments"].(map[string]any)
}))
out := callREST(t, srv, "search_for_item", `{"bogus_arg":"x"}`)
if gotArgs["bogus_arg"] != "x" {
t.Fatalf("args filtered despite schema failure: %v", gotArgs)
}
if out["result"] != "Success" {
t.Fatalf("result = %v", out)
}
}
func TestCallToolUnknownToolPassesThrough(t *testing.T) {
var gotArgs map[string]any
srv := newBridge(t, mcpStub(t, []map[string]any{searchTool}, func(params map[string]any) {
gotArgs, _ = params["arguments"].(map[string]any)
}))
out := callREST(t, srv, "unregistered_tool", `{"bogus_arg":"x"}`)
if gotArgs["bogus_arg"] != "x" {
t.Fatalf("unknown tool args filtered: %v", gotArgs)
}
if out["result"] != "Success" {
t.Fatalf("result = %v", out)
}
}