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.
166 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|