package mcp import ( "encoding/json" "strings" "testing" "time" "git.knownelement.com/ukrrs/mopac-glpi-go/glpi" "git.knownelement.com/ukrrs/mopac-glpi-go/internal/fakeglpi" ) const ( testApp = "fake-app-token-0123456789" testUser = "fake-user-token-0123456789" ) // serve feeds one batch of JSON-RPC lines through the server and // returns the response lines. func serve(t *testing.T, s *Server, lines ...string) []map[string]any { t.Helper() var out strings.Builder if err := s.Serve(strings.NewReader(strings.Join(lines, "\n")+"\n"), &out); err != nil { t.Fatalf("Serve: %v", err) } var msgs []map[string]any for i, line := range strings.Split(strings.TrimRight(out.String(), "\n"), "\n") { if line == "" { continue } var m map[string]any if err := json.Unmarshal([]byte(line), &m); err != nil { t.Fatalf("response line %d not json: %v (%q)", i+1, err, line) } msgs = append(msgs, m) } return msgs } func newServer(t *testing.T) (*Server, *fakeglpi.Server) { t.Helper() srv := fakeglpi.New(testApp, testUser) t.Cleanup(srv.Close) c := glpi.New(glpi.Config{BaseURL: srv.URL, AppToken: testApp, UserToken: testUser, Timeout: 5 * time.Second}) return New(c, 0), srv } func rpc(id int, method string, params map[string]any) string { b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) return string(b) } func TestInitializeHandshake(t *testing.T) { s, _ := newServer(t) msgs := serve(t, s, rpc(1, "initialize", map[string]any{ "protocolVersion": "2025-06-18", "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "test", "version": "0"}, })) if len(msgs) != 1 { t.Fatalf("responses = %d, want 1", len(msgs)) } res, _ := msgs[0]["result"].(map[string]any) if res == nil { t.Fatalf("no result: %+v", msgs[0]) } pv, _ := res["protocolVersion"].(string) if pv == "" { t.Errorf("initialize response missing protocol_version: %+v", res) } info, _ := res["serverInfo"].(map[string]any) if info == nil || info["name"] != "mglpi-mcp" { t.Errorf("serverInfo = %+v", info) } } func TestNotificationProducesNoResponse(t *testing.T) { s, _ := newServer(t) // A notification (no id) must not yield a response line. msgs := serve(t, s, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) if len(msgs) != 0 { t.Fatalf("responses = %+v, want none", msgs) } } func TestToolsList(t *testing.T) { s, _ := newServer(t) msgs := serve(t, s, rpc(2, "tools/list", map[string]any{})) res, _ := msgs[0]["result"].(map[string]any) tools, _ := res["tools"].([]any) want := map[string]bool{ "change_create": false, "change_list": false, "change_transition": false, "change_followup": false, "ci_search": false, "ci_show": false, } if len(tools) != len(want) { t.Fatalf("tools = %+v, want %d", tools, len(want)) } for _, tl := range tools { tm, _ := tl.(map[string]any) name, _ := tm["name"].(string) if _, ok := want[name]; !ok { t.Errorf("unexpected tool %q", name) } if tm["inputSchema"] == nil { t.Errorf("tool %q missing inputSchema", name) } want[name] = true } } func TestToolCallsRoundTrip(t *testing.T) { s, srv := newServer(t) web := srv.AddItem("Computer", map[string]any{"name": "web-01", "serial": "ABC123"}) // change_create msgs := serve(t, s, rpc(3, "tools/call", map[string]any{ "name": "change_create", "arguments": map[string]any{ "title": "Quota accounting", "content": "

body

", "urgency": 3, "impact": 4, }, })) var created struct { Content []struct { Text string `json:"text"` } `json:"content"` } b, _ := json.Marshal(msgs[0]["result"]) if err := json.Unmarshal(b, &created); err != nil || len(created.Content) == 0 { t.Fatalf("create result = %s err %v", b, err) } var payload struct { Change struct { ID int `json:"id"` } `json:"change"` } if err := json.Unmarshal([]byte(created.Content[0].Text), &payload); err != nil || payload.Change.ID == 0 { t.Fatalf("tool text = %q err %v", created.Content[0].Text, err) } stored, ok := srv.Change(payload.Change.ID) if !ok || stored.Name != "Quota accounting" || stored.Impact != 4 { t.Fatalf("stored = %+v", stored) } // change_list msgs = serve(t, s, rpc(4, "tools/call", map[string]any{"name": "change_list", "arguments": map[string]any{}})) if !strings.Contains(msgs[0]["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string), "Quota accounting") { t.Errorf("change_list text missing created change") } // change_transition msgs = serve(t, s, rpc(5, "tools/call", map[string]any{ "name": "change_transition", "arguments": map[string]any{"id": payload.Change.ID, "status": "solved"}, })) if got, _ := srv.Change(payload.Change.ID); got.Status != fakeglpi.StatusSolved { t.Errorf("status after transition = %d", got.Status) } // change_followup msgs = serve(t, s, rpc(6, "tools/call", map[string]any{ "name": "change_followup", "arguments": map[string]any{"id": payload.Change.ID, "content": "REPORT delivered"}, })) if fups := srv.Followups(payload.Change.ID); len(fups) != 1 || fups[0].Content != "REPORT delivered" { t.Errorf("followups = %+v", srv.Followups(payload.Change.ID)) } // ci_search msgs = serve(t, s, rpc(7, "tools/call", map[string]any{ "name": "ci_search", "arguments": map[string]any{"itemtype": "Computer", "term": "web"}, })) text := msgs[0]["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) if !strings.Contains(text, "web-01") { t.Errorf("ci_search text = %q", text) } // ci_show msgs = serve(t, s, rpc(8, "tools/call", map[string]any{ "name": "ci_show", "arguments": map[string]any{"itemtype": "Computer", "id": web}, })) text = msgs[0]["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) if !strings.Contains(text, "ABC123") { t.Errorf("ci_show text = %q", text) } } func TestToolCallErrorsAreResults(t *testing.T) { s, _ := newServer(t) msgs := serve(t, s, rpc(9, "tools/call", map[string]any{"name": "no_such_tool", "arguments": map[string]any{}}), rpc(10, "tools/call", map[string]any{"name": "change_create", "arguments": map[string]any{}}), ) for i, m := range msgs { res, _ := m["result"].(map[string]any) if res == nil || res["isError"] != true { t.Fatalf("response %d = %+v, want isError result", i, m) } } } func TestUnknownMethodIsProtocolError(t *testing.T) { s, _ := newServer(t) msgs := serve(t, s, rpc(11, "resources/list", map[string]any{})) errObj, _ := msgs[0]["error"].(map[string]any) if errObj == nil || errObj["code"] != float64(-32601) { t.Fatalf("error = %+v, want -32601", errObj) } } func TestPing(t *testing.T) { s, _ := newServer(t) msgs := serve(t, s, rpc(12, "ping", map[string]any{})) if msgs[0]["result"] == nil { t.Fatalf("ping = %+v", msgs[0]) } }