Files

288 lines
8.5 KiB
Go

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