llmgw ctl mcp add|list|remove|test and /api/v1/mcp/servers keep the servers; a token given with --secret-file is kept in the store, sealed, and named by reference. keys limits --tools, and the console's limits form, grant tools as patterns. Documented in install.md and clients.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
108 lines
3.2 KiB
Go
108 lines
3.2 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mcpclient/mcptest"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
type memMCP struct {
|
|
mu sync.Mutex
|
|
servers map[string]store.MCPServer
|
|
secrets map[string]string
|
|
}
|
|
|
|
func (m *memMCP) MCPServers(context.Context) ([]store.MCPServer, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var out []store.MCPServer
|
|
for _, s := range m.servers {
|
|
out = append(out, s)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *memMCP) MCPServer(_ context.Context, name string) (store.MCPServer, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
s, ok := m.servers[name]
|
|
if !ok {
|
|
return s, store.ErrNotFound
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (m *memMCP) PutMCPServer(_ context.Context, s store.MCPServer) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.servers[s.Name] = s
|
|
return nil
|
|
}
|
|
|
|
func (m *memMCP) DeleteMCPServer(_ context.Context, name string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.servers[name]; !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
delete(m.servers, name)
|
|
return nil
|
|
}
|
|
|
|
func (m *memMCP) SetClusterSecret(_ context.Context, name, value string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.secrets[name] = value
|
|
return nil
|
|
}
|
|
|
|
func TestAnMCPServerIsRegisteredWithItsTokenKeptAsASecretAndTried(t *testing.T) {
|
|
srv := mcptest.New()
|
|
defer srv.Close()
|
|
a, _ := setup(t, testModels())
|
|
st := &memMCP{servers: map[string]store.MCPServer{}, secrets: map[string]string{}}
|
|
a.SetMCP(st)
|
|
secrets.SetStore(func(_ context.Context, name string) (string, error) {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
return st.secrets[name], nil
|
|
})
|
|
t.Cleanup(func() { secrets.SetStore(nil) })
|
|
|
|
var v MCPServerView
|
|
body := `{"url":"` + srv.URL + `","description":"tickets","headers":{"X-Org":"acme"},"secrets":{"Authorization":"Bearer t0ken"}}`
|
|
if code := call(t, a, "PUT", "/api/v1/mcp/servers/jira", body, &v); code != 200 {
|
|
t.Fatalf("add: %d", code)
|
|
}
|
|
// The token is in the store, named by reference; the row has none.
|
|
if st.secrets["mcp/jira/Authorization"] != "Bearer t0ken" || v.HeaderRefs["Authorization"] != "store:mcp/jira/Authorization" ||
|
|
strings.Contains(v.Headers["Authorization"], "t0ken") || !v.Enabled {
|
|
t.Errorf("view %+v, secrets %v", v, st.secrets)
|
|
}
|
|
var tv MCPTestView
|
|
if code := call(t, a, "POST", "/api/v1/mcp/servers/jira/test", "", &tv); code != 200 || !tv.OK || len(tv.Tools) != 3 || tv.Tools[1] != "jira.echo" {
|
|
t.Errorf("test: %d %+v", code, tv)
|
|
}
|
|
if code := call(t, a, "PUT", "/api/v1/mcp/servers/Bad.Name", body, nil); code != 400 {
|
|
t.Errorf("a name with a dot: %d", code)
|
|
}
|
|
if code := call(t, a, "PUT", "/api/v1/mcp/servers/x", `{"url":"ftp://x"}`, nil); code != 400 {
|
|
t.Errorf("not an http address: %d", code)
|
|
}
|
|
// Switched off, kept; then removed.
|
|
if code := call(t, a, "PUT", "/api/v1/mcp/servers/jira", `{"enabled":false}`, &v); code != 200 || v.Enabled || v.URL != srv.URL {
|
|
t.Errorf("off: %d %+v", code, v)
|
|
}
|
|
if code := call(t, a, "DELETE", "/api/v1/mcp/servers/jira", "", nil); code != 200 {
|
|
t.Errorf("remove: %d", code)
|
|
}
|
|
if code := call(t, a, "DELETE", "/api/v1/mcp/servers/jira", "", nil); code != 404 {
|
|
t.Errorf("remove again: %d", code)
|
|
}
|
|
}
|