CI / test (push) Successful in 5m35s
A tool's answer is read by the caller's policies as the prompt it becomes, so an instruction planted in a ticket is withheld and personal data redacted; its arguments are read before they leave. ctl mcp add --rate bounds each caller's calls a minute to a server (migration 0050), and five failures in a row take a server out for a minute, its tools off the list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
202 lines
7.0 KiB
Go
202 lines
7.0 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
)
|
|
|
|
func newMCPCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|
c := &cobra.Command{
|
|
Use: "mcp",
|
|
Short: "The company's own MCP servers, served through llmgw's /mcp",
|
|
Long: "An MCP server registered here — Jira, a file server, a database — is served\n" +
|
|
"through llmgw's /mcp beside llmgw's own tools, its tools named SERVER.tool.\n" +
|
|
"Its credentials stay on the control planes: a client gives only its llmgw\n" +
|
|
"key. A key sees and calls only the tools granted it, as patterns, with\n" +
|
|
"`llmgw keys limits --tools 'jira.*'` at any level of the tenancy tree;\n" +
|
|
"none granted anywhere is none. Every call is in `llmgw ctl audit`.",
|
|
}
|
|
c.AddCommand(newMCPAddCmd(client, asJSON), newMCPListCmd(client, asJSON), newMCPRemoveCmd(client),
|
|
newMCPTestCmd(client, asJSON))
|
|
return c
|
|
}
|
|
|
|
func newMCPAddCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|
var req adminapi.MCPServerRequest
|
|
var headers, refs, secretFiles []string
|
|
var bearerFile string
|
|
var disabled bool
|
|
c := &cobra.Command{
|
|
Use: "add NAME --url URL",
|
|
Short: "Register an MCP server, or change one",
|
|
Example: " llmgw ctl mcp add jira --url https://jira.internal/mcp --bearer-file /root/jira-token\n" +
|
|
" llmgw ctl mcp add llmgw --url llmgw:admin --description \"this cluster, read only\"\n" +
|
|
" llmgw ctl mcp add wiki --url https://wiki.internal/mcp --header-ref Authorization=vault:secret/mcp/wiki#token\n" +
|
|
" llmgw ctl mcp add jira --disabled # off, kept",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
var err error
|
|
if req.Headers, err = pairs(headers, "--header"); err != nil {
|
|
return err
|
|
}
|
|
if req.HeaderRefs, err = pairs(refs, "--header-ref"); err != nil {
|
|
return err
|
|
}
|
|
files, err := pairs(secretFiles, "--secret-file")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A secret read here and kept in the store, sealed: the file
|
|
// need not be on every control plane, and is not read again.
|
|
for header, file := range files {
|
|
b, err := os.ReadFile(file) //nolint:gosec // a file the operator names
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if req.Secrets == nil {
|
|
req.Secrets = map[string]string{}
|
|
}
|
|
req.Secrets[header] = strings.TrimSpace(string(b))
|
|
}
|
|
if bearerFile != "" {
|
|
b, err := os.ReadFile(bearerFile) //nolint:gosec // a file the operator names
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if req.Secrets == nil {
|
|
req.Secrets = map[string]string{}
|
|
}
|
|
req.Secrets["Authorization"] = "Bearer " + strings.TrimSpace(string(b))
|
|
}
|
|
if cmd.Flags().Changed("disabled") {
|
|
on := !disabled
|
|
req.Enabled = &on
|
|
}
|
|
body, _ := json.Marshal(req)
|
|
var v adminapi.MCPServerView
|
|
if err := client().put(cmd.Context(), "/api/v1/mcp/servers/"+args[0], body, &v); err != nil {
|
|
return err
|
|
}
|
|
if *asJSON {
|
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
|
}
|
|
state := "on"
|
|
if !v.Enabled {
|
|
state = "off"
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s, %s. Its tools are %s.TOOL in /mcp, to keys granted them "+
|
|
"(llmgw keys limits --tools '%s.*'). Check it: llmgw ctl mcp test %s\n", v.Name, v.URL, state, v.Name, v.Name, v.Name)
|
|
return nil
|
|
},
|
|
}
|
|
f := c.Flags()
|
|
f.StringVar(&req.URL, "url", "", "the server's streamable HTTP address, https://…/mcp; llmgw:admin for this cluster, read only")
|
|
f.StringVar(&req.Description, "description", "", "what it is, shown beside its tools")
|
|
f.StringArrayVar(&headers, "header", nil, "NAME=VALUE sent as written, for what is not secret; repeatable")
|
|
f.StringArrayVar(&refs, "header-ref", nil, "NAME=REF read at each call: a file on every control plane, env:, vault: or store:; repeatable")
|
|
f.StringArrayVar(&secretFiles, "secret-file", nil, "NAME=FILE read now and kept in the store, sealed: a token; repeatable")
|
|
f.StringVar(&bearerFile, "bearer-file", "", "a token FILE, read now and kept sealed, sent as Authorization: Bearer")
|
|
f.BoolVar(&disabled, "disabled", false, "keep it but serve none of its tools")
|
|
f.IntVar(&req.TimeoutSeconds, "timeout", 0, "seconds one call may take (default 60)")
|
|
f.IntVar(&req.RatePerMinute, "rate", 0, "calls a minute each caller may make to its tools (default: no bound; -1 takes one away)")
|
|
return c
|
|
}
|
|
|
|
func newMCPListCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "list",
|
|
Short: "The MCP servers /mcp serves",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
var list []adminapi.MCPServerView
|
|
if err := client().get(cmd.Context(), "/api/v1/mcp/servers", &list); err != nil {
|
|
return err
|
|
}
|
|
if *asJSON {
|
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(list)
|
|
}
|
|
if len(list) == 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "no MCP servers; add one: llmgw ctl mcp add NAME --url URL")
|
|
return nil
|
|
}
|
|
w := tabTo(cmd.OutOrStdout())
|
|
fmt.Fprintln(w, "NAME\tSTATE\tURL\tCREDENTIALS\tDESCRIPTION")
|
|
for _, s := range list {
|
|
state := "on"
|
|
if !s.Enabled {
|
|
state = "off"
|
|
}
|
|
var creds []string
|
|
for h := range s.HeaderRefs {
|
|
creds = append(creds, h)
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, state, s.URL, dash(strings.Join(creds, ",")), s.Description)
|
|
}
|
|
return w.Flush()
|
|
},
|
|
}
|
|
}
|
|
|
|
func newMCPRemoveCmd(client func() *ctlClient) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "remove NAME",
|
|
Short: "Remove an MCP server: its tools leave /mcp",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := client().delete(cmd.Context(), "/api/v1/mcp/servers/"+args[0], nil); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s removed; its tools leave /mcp within half a minute\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newMCPTestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "test NAME",
|
|
Short: "Ask an MCP server for its tools, with its credentials, as /mcp would",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
var v adminapi.MCPTestView
|
|
if err := client().post(cmd.Context(), "/api/v1/mcp/servers/"+args[0]+"/test", nil, &v); err != nil {
|
|
return err
|
|
}
|
|
if *asJSON {
|
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
|
}
|
|
if !v.OK {
|
|
return errors.New(args[0] + " did not answer: " + v.Error)
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s answers with %d tool(s):\n", args[0], len(v.Tools))
|
|
for _, t := range v.Tools {
|
|
fmt.Fprintln(cmd.OutOrStdout(), " "+t)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// pairs reads NAME=VALUE flags.
|
|
func pairs(list []string, flag string) (map[string]string, error) {
|
|
if len(list) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := map[string]string{}
|
|
for _, p := range list {
|
|
k, v, ok := strings.Cut(p, "=")
|
|
if !ok || strings.TrimSpace(k) == "" {
|
|
return nil, fmt.Errorf("%s %q is not NAME=VALUE", flag, p)
|
|
}
|
|
out[strings.TrimSpace(k)] = v
|
|
}
|
|
return out, nil
|
|
}
|