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>
78 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|