// 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 }