Files
llmgw/internal/cli/ctl_mcp.go
T
Ilker ManapandClaude Opus 5.5 3a4488b41e
CI / test (push) Successful in 5m35s
The company's MCP tools under the guardrails, a rate and a breaker
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>
2026-10-20 23:00:22 +02:00

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
}