feat: secret create mcp tool
This commit is contained in:
1 parent
eb6f47c8ed
commit
593cb2bf2e
2 files changed
+88
No files matched your search
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user