feat: secret create mcp tool
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 2m17s

This commit is contained in:
mrhid6 committed 2026-10-13 08:47:12 +00:00
1 parent eb6f47c8ed
commit 593cb2bf2e
2 files changed
+88

No files matched your search

+72
View File
@@ -3,6 +3,8 @@ package mcp
import (
"context"
"fmt"
"regexp"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
@@ -319,3 +321,73 @@ func init() {
},
})
}
var secretNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
// buildSecrets validates the arguments and returns the group and values to
// store. Names follow the same rule as the REST handlers (api.validName).
func buildSecrets(args map[string]any) (string, map[string]string, error) {
group := stringArg(args, "group")
if !secretNamePattern.MatchString(group) {
return "", nil, fmt.Errorf("group is required and may only contain letters, digits, '.', '_' and '-'")
}
raw, _ := args["values"].(map[string]any)
if len(raw) == 0 {
return "", nil, fmt.Errorf("values must contain at least one key")
}
values := make(map[string]string, len(raw))
for k, v := range raw {
if !secretNamePattern.MatchString(k) {
return "", nil, fmt.Errorf("invalid key name: %s", k)
}
s, ok := v.(string)
if !ok {
return "", nil, fmt.Errorf("value for key %s must be a string", k)
}
values[k] = s
}
return group, values, nil
}
func init() {
All().Register(Tool{
Name: "create_secret",
Args: []ToolArg{
{Name: "group", Type: ArgString, Description: "Secret group name; created if it does not exist.", Required: true},
{Name: "values", Type: ArgTagMap, Description: "Key/value pairs to store, encrypted at rest.", Required: true},
},
Write: true,
Scope: "secrets:write",
Description: "Add new secret keys to a group, creating the group if needed. " +
"Existing keys are never overwritten - the call is refused if any key already " +
"exists, so rotate or replace a secret in the Vantage UI. Values are not returned.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
group, values, err := buildSecrets(args)
if err != nil {
return nil, err
}
// UpsertSecrets overwrites silently. A model replacing a live
// credential would break whatever uses it, so only additions
// are allowed from here.
existing, err := services.GetSecretGroup(c.InstanceID, group)
if err != nil {
return nil, fmt.Errorf("could not read secret group: %w", err)
}
for _, s := range existing {
if _, clash := values[s.Key]; clash {
return nil, fmt.Errorf("key %q already exists in group %q; change it in the Vantage UI", s.Key, group)
}
}
if err := services.UpsertSecrets(c.InstanceID, group, values); err != nil {
return nil, fmt.Errorf("could not save the secrets: %w", err)
}
keys := services.SortedKeys(values)
LogCreated(c, "secret", group, strings.Join(keys, ", "))
return map[string]any{
"group": group,
"keys": keys,
"note": "Saved. Values are not readable through MCP.",
}, nil
},
})
}
+16
View File
@@ -226,3 +226,19 @@ func TestBuildMonitorWithoutRunnerIsDisabled(t *testing.T) {
t.Error("monitor created enabled")
}
}
func TestBuildSecretsValidates(t *testing.T) {
if _, _, err := buildSecrets(map[string]any{"group": "bad group", "values": map[string]any{"k": "v"}}); err == nil {
t.Error("accepted invalid group name")
}
if _, _, err := buildSecrets(map[string]any{"group": "prod"}); err == nil {
t.Error("accepted empty values")
}
if _, _, err := buildSecrets(map[string]any{"group": "prod", "values": map[string]any{"k": 1.0}}); err == nil {
t.Error("accepted non-string value")
}
g, v, err := buildSecrets(map[string]any{"group": "prod", "values": map[string]any{"DB_PASS": "x"}})
if err != nil || g != "prod" || v["DB_PASS"] != "x" {
t.Errorf("valid input rejected: %v", err)
}
}