Files
llmgw/internal/control/adminapi/mcp_server_test.go
T
Ilker ManapandClaude Opus 5.5 4eb49d0e4c The cluster as a read-only MCP server, llmgw:admin
Its tools say what llmgw ctl does: cluster_status, list_nodes,
list_models, list_replicas, explain_placement, node_logs; none changes
anything. The admin API serves it at POST /api/v1/mcp, and the gateway
answers a server registered as llmgw:admin in its own process, so no
token is kept and each control plane serves it. ctl mcp add takes
--bearer-file for a token sent as Authorization: Bearer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 17:18:28 +02:00

78 lines
2.8 KiB
Go

package adminapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func adminMCP(t *testing.T, a *API, method string, params any) map[string]any {
t.Helper()
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
rec := httptest.NewRecorder()
a.MCP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", bytes.NewReader(b)))
var resp struct {
Result map[string]any `json:"result"`
}
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &resp) != nil {
t.Fatalf("%s: %d %s", method, rec.Code, rec.Body.String())
}
return resp.Result
}
func TestTheClusterIsAReadOnlyMCPServer(t *testing.T) {
a, _ := setup(t, testModels())
if init := adminMCP(t, a, "initialize", map[string]any{"protocolVersion": "2025-06-18"}); init["protocolVersion"] != "2025-06-18" {
t.Errorf("initialize %v", init)
}
tools := adminMCP(t, a, "tools/list", map[string]any{})["tools"].([]any)
var names []string
for _, tl := range tools {
m := tl.(map[string]any)
names = append(names, m["name"].(string))
if m["annotations"].(map[string]any)["readOnlyHint"] != true {
t.Errorf("%s is not marked read only", m["name"])
}
}
if got := strings.Join(names, ","); got != "cluster_status,list_nodes,list_models,list_replicas,explain_placement,node_logs" {
t.Errorf("tools %s", got)
}
// Each answers what the API would.
for _, name := range []string{"cluster_status", "list_nodes", "list_models", "list_replicas"} {
res := adminMCP(t, a, "tools/call", map[string]any{"name": name, "arguments": map[string]any{}})
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
if res["isError"] == true || !json.Valid([]byte(text)) {
t.Errorf("%s: %v", name, res)
}
}
if res := adminMCP(t, a, "tools/call", map[string]any{"name": "explain_placement", "arguments": map[string]any{}}); res["isError"] != true {
t.Errorf("explain_placement without a model: %v", res)
}
if res := adminMCP(t, a, "tools/call", map[string]any{"name": "remove_model", "arguments": map[string]any{}}); res["isError"] != true {
t.Errorf("a tool that would change something: %v", res)
}
}
// The gateway reaches it in this process, as llmgw:admin.
func TestTheClustersOwnMCPServerIsReachedInProcess(t *testing.T) {
a, _ := setup(t, testModels())
c := &http.Client{Transport: InProcess(http.HandlerFunc(a.MCP))}
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
resp, err := c.Post(InProcessURL, "application/json", bytes.NewReader(b))
if err != nil || resp.StatusCode != 200 {
t.Fatalf("%v %v", resp, err)
}
defer resp.Body.Close()
var out struct {
Result struct {
Tools []any `json:"tools"`
} `json:"result"`
}
if json.NewDecoder(resp.Body).Decode(&out) != nil || len(out.Result.Tools) != 6 {
t.Errorf("tools %+v", out)
}
}