/admin/mcp, for administrators only, since a server's credential reaches whatever it guards: each server's state, who has been granted its tools, its rate, the headers it is sent by name with the sealed ones marked, and its address; adding one with a bearer token or another header kept in the store; testing it with its credential and listing its tools; granting them to a team, project, org, person or key, written into that level's limits beside its other grants and its rate and budget; revoking; changing; replacing the credential; turning off and on; removing; and how a person points an agent at the gateway's /mcp with a key of their own. English and Turkish. The secret's field is named so the audit line redacts it. The admin API's server merge and test are package functions now, adminapi.MCPServerFrom and adminapi.TestMCPServer, shared by both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
8.7 KiB
Go
258 lines
8.7 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mcpclient"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// MCPStore keeps the company's MCP servers and the secrets their headers
|
|
// carry.
|
|
type MCPStore interface {
|
|
MCPServers(ctx context.Context) ([]store.MCPServer, error)
|
|
MCPServer(ctx context.Context, name string) (store.MCPServer, error)
|
|
PutMCPServer(ctx context.Context, m store.MCPServer) error
|
|
DeleteMCPServer(ctx context.Context, name string) error
|
|
SetClusterSecret(ctx context.Context, name, value string) error
|
|
}
|
|
|
|
// SetMCP enables the endpoints for the company's MCP servers.
|
|
func (a *API) SetMCP(st MCPStore) { a.mcp = st }
|
|
|
|
// MCPServerView is a server as the API shows it: header values only for the
|
|
// headers sent as written, and for the others the reference read.
|
|
type MCPServerView struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Description string `json:"description,omitempty"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
HeaderRefs map[string]string `json:"header_refs,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
|
RatePerMinute int `json:"rate_per_minute,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
}
|
|
|
|
// MCPServerRequest adds a server or changes one. Secrets are header values
|
|
// to keep in the store, sealed, each then read by reference; HeaderRefs name
|
|
// secrets kept elsewhere (a file, env:, vault:).
|
|
type MCPServerRequest struct {
|
|
URL string `json:"url"`
|
|
Description string `json:"description,omitempty"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
HeaderRefs map[string]string `json:"header_refs,omitempty"`
|
|
Secrets map[string]string `json:"secrets,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
|
// RatePerMinute bounds each caller's calls to its tools; a negative
|
|
// number takes the bound away.
|
|
RatePerMinute int `json:"rate_per_minute,omitempty"`
|
|
}
|
|
|
|
// MCPTestView is what the server said when asked: its tools, or why not.
|
|
type MCPTestView struct {
|
|
OK bool `json:"ok"`
|
|
Tools []string `json:"tools,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func mcpServerView(m store.MCPServer) MCPServerView {
|
|
return MCPServerView{Name: m.Name, URL: m.URL, Description: m.Description, Headers: m.Headers,
|
|
HeaderRefs: m.HeaderRefs, Enabled: m.Enabled, TimeoutSeconds: int(m.Timeout / time.Second), RatePerMinute: m.RatePerMinute,
|
|
CreatedAt: m.CreatedAt, CreatedBy: m.CreatedBy}
|
|
}
|
|
|
|
func (a *API) registerMCP(mux Mux) {
|
|
need := func(w http.ResponseWriter) bool {
|
|
if a.mcp == nil {
|
|
writeError(w, http.StatusNotImplemented, "this control plane has no store to keep MCP servers in")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
mux.HandleFunc("POST /api/v1/mcp", a.MCP)
|
|
mux.HandleFunc("GET /api/v1/mcp/servers", func(w http.ResponseWriter, r *http.Request) {
|
|
if !need(w) {
|
|
return
|
|
}
|
|
list, err := a.mcp.MCPServers(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
out := make([]MCPServerView, len(list))
|
|
for i, m := range list {
|
|
out[i] = mcpServerView(m)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/mcp/servers/{name}", func(w http.ResponseWriter, r *http.Request) {
|
|
if !need(w) {
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
var req MCPServerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
m, err := a.mcpServerFrom(r.Context(), name, req, actorOf(r))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := a.mcp.PutMCPServer(r.Context(), m); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, mcpServerView(m))
|
|
})
|
|
mux.HandleFunc("DELETE /api/v1/mcp/servers/{name}", func(w http.ResponseWriter, r *http.Request) {
|
|
if !need(w) {
|
|
return
|
|
}
|
|
switch err := a.mcp.DeleteMCPServer(r.Context(), r.PathValue("name")); {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
writeError(w, http.StatusNotFound, "no MCP server "+r.PathValue("name"))
|
|
case err != nil:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
default:
|
|
writeJSON(w, http.StatusOK, map[string]string{"removed": r.PathValue("name")})
|
|
}
|
|
})
|
|
mux.HandleFunc("POST /api/v1/mcp/servers/{name}/test", func(w http.ResponseWriter, r *http.Request) {
|
|
if !need(w) {
|
|
return
|
|
}
|
|
m, err := a.mcp.MCPServer(r.Context(), r.PathValue("name"))
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "no MCP server "+r.PathValue("name"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, a.testMCPServer(r.Context(), m))
|
|
})
|
|
}
|
|
|
|
// mcpServerFrom is the server a request makes, over the one of that name if
|
|
// there is one: its secrets kept in the store, and named by reference.
|
|
func (a *API) mcpServerFrom(ctx context.Context, name string, req MCPServerRequest, by string) (store.MCPServer, error) {
|
|
return MCPServerFrom(ctx, a.mcp, name, req, by)
|
|
}
|
|
|
|
// MCPServerFrom is the server a request makes, over the one of that name in
|
|
// st if there is one: its secrets kept in the store, sealed, and named by
|
|
// reference. Not stored; the caller puts it. For the API and the admin page.
|
|
func MCPServerFrom(ctx context.Context, st MCPStore, name string, req MCPServerRequest, by string) (store.MCPServer, error) {
|
|
if !store.ValidMCPServerName(name) {
|
|
return store.MCPServer{}, errors.New("a server's name is lowercase letters, digits, - and _, starting with a letter: " +
|
|
"its tools are NAME.tool")
|
|
}
|
|
m, err := st.MCPServer(ctx, name)
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
m = store.MCPServer{Name: name, Enabled: true, CreatedBy: by}
|
|
case err != nil:
|
|
return m, err
|
|
}
|
|
if req.URL != "" {
|
|
m.URL = req.URL
|
|
}
|
|
if u, err := url.Parse(m.URL); m.URL != AdminMCPAddress && (err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "") {
|
|
return m, errors.New("the server's url is an http:// or https:// address, or " + AdminMCPAddress + " for this cluster's own")
|
|
}
|
|
if req.Description != "" {
|
|
m.Description = req.Description
|
|
}
|
|
if req.Headers != nil {
|
|
m.Headers = req.Headers
|
|
}
|
|
if req.HeaderRefs != nil {
|
|
m.HeaderRefs = req.HeaderRefs
|
|
}
|
|
if m.HeaderRefs == nil {
|
|
m.HeaderRefs = map[string]string{}
|
|
}
|
|
for header, value := range req.Secrets {
|
|
if strings.TrimSpace(header) == "" || value == "" {
|
|
return m, errors.New("a secret header needs a name and a value")
|
|
}
|
|
key := "mcp/" + name + "/" + header
|
|
if err := st.SetClusterSecret(ctx, key, value); err != nil {
|
|
return m, err
|
|
}
|
|
m.HeaderRefs[header] = "store:" + key
|
|
delete(m.Headers, header)
|
|
}
|
|
if req.Enabled != nil {
|
|
m.Enabled = *req.Enabled
|
|
}
|
|
if req.TimeoutSeconds > 0 {
|
|
m.Timeout = time.Duration(req.TimeoutSeconds) * time.Second
|
|
}
|
|
switch {
|
|
case req.RatePerMinute > 0:
|
|
m.RatePerMinute = req.RatePerMinute
|
|
case req.RatePerMinute < 0:
|
|
m.RatePerMinute = 0
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// testMCPServer asks a server for its tools, with its credentials, as /mcp
|
|
// would.
|
|
func (a *API) testMCPServer(ctx context.Context, m store.MCPServer) MCPTestView {
|
|
return TestMCPServer(ctx, m, http.HandlerFunc(a.MCP))
|
|
}
|
|
|
|
// TestMCPServer asks a server for its tools, with its credentials, as /mcp
|
|
// would; local answers for this cluster's own (AdminMCPAddress), and
|
|
// without it that one is not asked.
|
|
func TestMCPServer(ctx context.Context, m store.MCPServer, local http.Handler) MCPTestView {
|
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
c := &mcpclient.Client{URL: m.URL, Header: func(ctx context.Context) (http.Header, error) {
|
|
h := http.Header{}
|
|
for k, v := range m.Headers {
|
|
h.Set(k, v)
|
|
}
|
|
for k, ref := range m.HeaderRefs {
|
|
v, err := secrets.ReadString(ctx, ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.Set(k, v)
|
|
}
|
|
return h, nil
|
|
}}
|
|
if m.URL == AdminMCPAddress {
|
|
if local == nil {
|
|
return MCPTestView{Error: "this cluster's own tools are answered only by a control plane"}
|
|
}
|
|
c.URL, c.HTTP = InProcessURL, &http.Client{Transport: InProcess(local)}
|
|
}
|
|
tools, err := c.Tools(ctx)
|
|
if err != nil {
|
|
return MCPTestView{Error: err.Error()}
|
|
}
|
|
v := MCPTestView{OK: true}
|
|
for _, t := range tools {
|
|
v.Tools = append(v.Tools, m.Name+"."+t.Name)
|
|
}
|
|
sort.Strings(v.Tools)
|
|
return v
|
|
}
|