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:
@@ -0,0 +1,287 @@
|
||||
// Package mcp implements a minimal stdio JSON-RPC MCP (Model Context
|
||||
// Protocol) server over the glpi library — stdlib only, no third-party
|
||||
// modules. Transport is newline-delimited JSON on the reader/writer
|
||||
// pair. Tools: change_create, change_list, change_transition,
|
||||
// change_followup, ci_search, ci_show. An optional agent profile id
|
||||
// (e.g. 5 = Hotliner) is applied on the first tool call.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"git.knownelement.com/ukrrs/mopac-glpi-go/glpi"
|
||||
)
|
||||
|
||||
// protocolVersionDefault answers initialize when the client does not
|
||||
// name one.
|
||||
const protocolVersionDefault = "2025-06-18"
|
||||
|
||||
// Server is the MCP server over one glpi.Client.
|
||||
type Server struct {
|
||||
client *glpi.Client
|
||||
profile int
|
||||
|
||||
once sync.Once
|
||||
profileErr error
|
||||
}
|
||||
|
||||
// New builds a Server. profileID > 0 switches the session's active
|
||||
// profile (agent mode) on first use.
|
||||
func New(c *glpi.Client, profileID int) *Server {
|
||||
return &Server{client: c, profile: profileID}
|
||||
}
|
||||
|
||||
// Serve reads newline-delimited JSON-RPC requests until EOF, writing
|
||||
// one response line per request that carries an id (notifications are
|
||||
// acknowledged by silence).
|
||||
func (s *Server) Serve(in io.Reader, out io.Writer) error {
|
||||
sc := bufio.NewScanner(in)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
|
||||
for sc.Scan() {
|
||||
line := bytes.TrimSpace(sc.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.handle(line, out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// request is one inbound JSON-RPC message.
|
||||
type request struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
|
||||
// response is one outbound JSON-RPC message (result XOR error).
|
||||
type response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Server) handle(line []byte, out io.Writer) error {
|
||||
var req request
|
||||
if err := json.Unmarshal(line, &req); err != nil || req.Method == "" {
|
||||
return nil // cannot route a malformed line; never guess an id
|
||||
}
|
||||
if len(req.ID) == 0 || string(req.ID) == "null" {
|
||||
return nil // notification: no response per JSON-RPC
|
||||
}
|
||||
|
||||
resp := response{JSONRPC: "2.0", ID: req.ID}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
pv := req.Params.ProtocolVersion
|
||||
if pv == "" {
|
||||
pv = protocolVersionDefault
|
||||
}
|
||||
resp.Result = map[string]any{
|
||||
"protocolVersion": pv,
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": "mglpi-mcp", "version": "0.1.0"},
|
||||
}
|
||||
case "ping":
|
||||
resp.Result = map[string]any{}
|
||||
case "tools/list":
|
||||
resp.Result = map[string]any{"tools": toolDefs()}
|
||||
case "tools/call":
|
||||
resp.Result = s.callTool(req.Params.Name, req.Params.Arguments)
|
||||
default:
|
||||
resp.Result = nil
|
||||
resp.Error = &rpcError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}
|
||||
}
|
||||
return writeLine(out, resp)
|
||||
}
|
||||
|
||||
// ensureProfile applies the agent profile once per server lifetime (an
|
||||
// API op; it also warms the session).
|
||||
func (s *Server) ensureProfile() error {
|
||||
s.once.Do(func() {
|
||||
if s.profile > 0 {
|
||||
s.profileErr = s.client.ChangeActiveProfile(context.Background(), s.profile)
|
||||
}
|
||||
})
|
||||
return s.profileErr
|
||||
}
|
||||
|
||||
// callTool dispatches one tools/call. Tool failures are RESULTS with
|
||||
// isError=true (protocol errors are the -32601 path only).
|
||||
func (s *Server) callTool(name string, args json.RawMessage) map[string]any {
|
||||
if err := s.ensureProfile(); err != nil {
|
||||
return toolError(err)
|
||||
}
|
||||
var a map[string]any
|
||||
if len(args) > 0 {
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return toolError(fmt.Errorf("arguments are not an object"))
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
var payload any
|
||||
switch name {
|
||||
case "change_create":
|
||||
title, _ := a["title"].(string)
|
||||
if title == "" {
|
||||
return toolError(fmt.Errorf("change_create requires title"))
|
||||
}
|
||||
content, _ := a["content"].(string)
|
||||
id, err := s.client.CreateChange(ctx, title, content, argInt(a, "urgency", 3), argInt(a, "impact", 3))
|
||||
if err != nil {
|
||||
return toolError(err)
|
||||
}
|
||||
payload = map[string]any{"change": map[string]any{"id": id}}
|
||||
case "change_list":
|
||||
rows, err := s.client.ListChanges(ctx, argInt(a, "status", 0))
|
||||
if err != nil {
|
||||
return toolError(err)
|
||||
}
|
||||
payload = map[string]any{"changes": rows}
|
||||
case "change_transition":
|
||||
id := argInt(a, "id", 0)
|
||||
status, ok := glpi.StatusID(argString(a, "status"))
|
||||
if !ok {
|
||||
status = argInt(a, "status", 0)
|
||||
}
|
||||
if id == 0 || status == 0 {
|
||||
return toolError(fmt.Errorf("change_transition requires id and status"))
|
||||
}
|
||||
if err := s.client.TransitionChange(ctx, id, status); err != nil {
|
||||
return toolError(err)
|
||||
}
|
||||
payload = map[string]any{"transitioned": map[string]any{"id": id, "status": glpi.StatusName(status)}}
|
||||
case "change_followup":
|
||||
id := argInt(a, "id", 0)
|
||||
content, _ := a["content"].(string)
|
||||
if id == 0 || content == "" {
|
||||
return toolError(fmt.Errorf("change_followup requires id and content"))
|
||||
}
|
||||
if err := s.client.AddFollowup(ctx, id, content); err != nil {
|
||||
return toolError(err)
|
||||
}
|
||||
payload = map[string]any{"followup": map[string]any{"items_id": id}}
|
||||
case "ci_search":
|
||||
rows, err := s.client.SearchCI(ctx, argString(a, "itemtype"), argString(a, "term"))
|
||||
if err != nil {
|
||||
return toolError(err)
|
||||
}
|
||||
type result struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
results := make([]result, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
results = append(results, result{ID: r.ID, Name: r.Name})
|
||||
}
|
||||
payload = map[string]any{"results": results}
|
||||
case "ci_show":
|
||||
id := argInt(a, "id", 0)
|
||||
obj, err := s.client.GetItem(ctx, argString(a, "itemtype"), id)
|
||||
if err != nil {
|
||||
return toolError(err)
|
||||
}
|
||||
payload = obj
|
||||
default:
|
||||
return toolError(fmt.Errorf("unknown tool: %s", name))
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return toolError(fmt.Errorf("cannot encode tool result"))
|
||||
}
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": string(b)}},
|
||||
"isError": false,
|
||||
}
|
||||
}
|
||||
|
||||
func toolError(err error) map[string]any {
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
||||
"isError": true,
|
||||
}
|
||||
}
|
||||
|
||||
func argString(a map[string]any, key string) string {
|
||||
s, _ := a[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func argInt(a map[string]any, key string, def int) int {
|
||||
switch v := a[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// toolDefs is the advertised tool surface (stable order).
|
||||
func toolDefs() []map[string]any {
|
||||
schema := func(required ...string) map[string]any {
|
||||
props := map[string]any{}
|
||||
for _, p := range required {
|
||||
props[p] = map[string]any{"type": "string"}
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": required,
|
||||
}
|
||||
}
|
||||
full := func(req []string, opt map[string]string) map[string]any {
|
||||
props := map[string]any{}
|
||||
for _, p := range req {
|
||||
props[p] = map[string]any{"type": "string"}
|
||||
}
|
||||
for p, t := range opt {
|
||||
props[p] = map[string]any{"type": t}
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": req,
|
||||
}
|
||||
}
|
||||
return []map[string]any{
|
||||
{"name": "change_create", "description": "Create a GLPI change (urgency/impact 1-5, 3=medium)", "inputSchema": full([]string{"title"}, map[string]string{"content": "string", "urgency": "integer", "impact": "integer"})},
|
||||
{"name": "change_list", "description": "List GLPI changes (optional status filter)", "inputSchema": schema()},
|
||||
{"name": "change_transition", "description": "Move a change to a status (name or numeric id)", "inputSchema": schema("id", "status")},
|
||||
{"name": "change_followup", "description": "Append a followup note to a change", "inputSchema": schema("id", "content")},
|
||||
{"name": "ci_search", "description": "Search CIs of an itemtype by name substring", "inputSchema": schema("itemtype", "term")},
|
||||
{"name": "ci_show", "description": "Fetch one CI raw by itemtype and id", "inputSchema": schema("itemtype", "id")},
|
||||
}
|
||||
}
|
||||
|
||||
// writeLine emits one compact JSON response line.
|
||||
func writeLine(out io.Writer, v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = out.Write(append(b, '\n'))
|
||||
return err
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user