feat: v0 — glpi-go client, mglpi CLI, mglpi-mcp, fake-GLPI test suite [#767]

https://projects.knownelement.com/issues/767#note-4191
This commit is contained in:
2026-09-04 08:41:25 -05:00
commit 5da1eee08f
26 changed files with 4956 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
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": "<p>body</p>", "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])
}
}