From 39712ffeba8423fcfbc44561ffc583d3423cb447 Mon Sep 17 00:00:00 2001 From: jm-merchan Date: Fri, 12 Jun 2026 11:55:32 +0200 Subject: [PATCH] Add KV metadata MCP tools --- pkg/tools/kv/namespace.go | 18 +++ pkg/tools/kv/read_secret_metadata.go | 109 ++++++++++++++++++ pkg/tools/kv/read_secret_metadata_test.go | 95 ++++++++++++++++ pkg/tools/kv/write_secret_metadata.go | 129 ++++++++++++++++++++++ pkg/tools/tools.go | 6 + 5 files changed, 357 insertions(+) create mode 100644 pkg/tools/kv/namespace.go create mode 100644 pkg/tools/kv/read_secret_metadata.go create mode 100644 pkg/tools/kv/read_secret_metadata_test.go create mode 100644 pkg/tools/kv/write_secret_metadata.go diff --git a/pkg/tools/kv/namespace.go b/pkg/tools/kv/namespace.go new file mode 100644 index 0000000..2d8bf11 --- /dev/null +++ b/pkg/tools/kv/namespace.go @@ -0,0 +1,18 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package kv + +import ( + "strings" + + "github.com/hashicorp/vault/api" +) + +func withOptionalNamespace(vault *api.Client, namespace string) *api.Client { + ns := strings.TrimSpace(namespace) + if ns == "" { + return vault + } + return vault.WithNamespace(ns) +} \ No newline at end of file diff --git a/pkg/tools/kv/read_secret_metadata.go b/pkg/tools/kv/read_secret_metadata.go new file mode 100644 index 0000000..d3e2a0f --- /dev/null +++ b/pkg/tools/kv/read_secret_metadata.go @@ -0,0 +1,109 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package kv + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/hashicorp/vault-mcp-server/pkg/client" + "github.com/hashicorp/vault-mcp-server/pkg/utils" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + log "github.com/sirupsen/logrus" +) + +// ReadSecretMetadata creates a tool for reading KV v2 metadata for a secret path. +func ReadSecretMetadata(logger *log.Logger) server.ServerTool { + return server.ServerTool{ + Tool: mcp.NewTool("read_secret_metadata", + mcp.WithDescription("Read metadata for a secret from a KV v2 mount at a specific path in Vault."), + mcp.WithToolAnnotation( + mcp.ToolAnnotation{ + ReadOnlyHint: utils.ToBoolPtr(true), + }, + ), + mcp.WithString("mount", + mcp.Required(), + mcp.Description("The mount path of the secret engine. For example, if you want to read from 'secrets/application/credentials', this should be 'secrets' without the trailing slash."), + ), + mcp.WithString("path", + mcp.Required(), + mcp.Description("The full path to read metadata for without the mount prefix. For example, if you want to read from 'secrets/application/credentials', this should be 'application/credentials'."), + ), + mcp.WithString("namespace", + mcp.DefaultString(""), + mcp.Description("Optional Vault namespace override for this call (for example: 'admin/team-03'). If not set, uses the MCP session namespace."), + ), + ), + Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return readSecretMetadataHandler(ctx, req, logger) + }, + } +} + +func readSecretMetadataHandler(ctx context.Context, req mcp.CallToolRequest, logger *log.Logger) (*mcp.CallToolResult, error) { + logger.Debug("Handling read_secret_metadata request") + + args, ok := req.Params.Arguments.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("Missing or invalid arguments format"), nil + } + + mount, err := utils.ExtractMountPath(args) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + path, ok := args["path"].(string) + if !ok || path == "" { + return mcp.NewToolResultError("Missing or invalid 'path' parameter"), nil + } + namespace, _ := args["namespace"].(string) + + vault, err := client.GetVaultClientFromContext(ctx, logger) + if err != nil { + logger.WithError(err).Error("Failed to get Vault client") + return mcp.NewToolResultError(fmt.Sprintf("Failed to get Vault client: %v", err)), nil + } + vault = withOptionalNamespace(vault, namespace) + + mounts, err := vault.Sys().ListMounts() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list mounts: %v", err)), nil + } + + m, exists := mounts[mount+"/"] + if !exists { + return mcp.NewToolResultError(fmt.Sprintf("mount path '%s' does not exist. Use 'create_mount' with the type kv2 to create the mount.", mount)), nil + } + if m.Type != "kv" || m.Options["version"] != "2" { + return mcp.NewToolResultError(fmt.Sprintf("mount path '%s' is not KV v2. Metadata is only available on KV v2 mounts.", mount)), nil + } + + fullPath := fmt.Sprintf("%s/metadata/%s", mount, strings.TrimPrefix(path, "/")) + secret, err := vault.Logical().Read(fullPath) + if err != nil { + logger.WithError(err).WithFields(log.Fields{ + "mount": mount, + "path": path, + "full_path": fullPath, + }).Error("Failed to read secret metadata") + return mcp.NewToolResultError(fmt.Sprintf("Failed to read secret metadata: %v", err)), nil + } + + if secret == nil || secret.Data == nil { + return mcp.NewToolResultError(fmt.Sprintf("Secret metadata not found at path '%s' in mount '%s'.", path, mount)), nil + } + + jsonData, err := json.Marshal(secret.Data) + if err != nil { + logger.WithError(err).Error("Failed to marshal secret metadata to JSON") + return mcp.NewToolResultError(fmt.Sprintf("Error marshaling JSON: %v", err)), nil + } + + return mcp.NewToolResultText(string(jsonData)), nil +} \ No newline at end of file diff --git a/pkg/tools/kv/read_secret_metadata_test.go b/pkg/tools/kv/read_secret_metadata_test.go new file mode 100644 index 0000000..709b7e4 --- /dev/null +++ b/pkg/tools/kv/read_secret_metadata_test.go @@ -0,0 +1,95 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package kv + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadSecretMetadataHandler_SuccessV2(t *testing.T) { + logger := newLogger() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/sys/mounts", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, mountsV2Response("secrets")) + }) + mux.HandleFunc("/v1/secrets/metadata/app/config", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "data": map[string]interface{}{ + "created_time": "2026-06-10T12:00:00Z", + "updated_time": "2026-06-10T13:00:00Z", + "current_version": 3, + "oldest_version": 0, + "max_versions": 0, + }, + }) + }) + + ctx, cleanup := newTestContext(t, mux) + defer cleanup() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "read_secret_metadata", + Arguments: map[string]interface{}{ + "mount": "secrets", + "path": "app/config", + }, + }, + } + + result, err := readSecretMetadataHandler(ctx, req, logger) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError, "expected success, got error: %s", getResultText(result)) + + var payload map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &payload) + require.NoError(t, err) + assert.Equal(t, "2026-06-10T12:00:00Z", payload["created_time"]) + assert.Equal(t, float64(3), payload["current_version"]) +} + +func TestReadSecretMetadataHandler_RejectsKVv1(t *testing.T) { + logger := newLogger() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/sys/mounts", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "data": map[string]interface{}{ + "legacy/": map[string]interface{}{ + "type": "kv", + "options": map[string]interface{}{ + "version": "1", + }, + }, + }, + }) + }) + + ctx, cleanup := newTestContext(t, mux) + defer cleanup() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "read_secret_metadata", + Arguments: map[string]interface{}{ + "mount": "legacy", + "path": "app/config", + }, + }, + } + + result, err := readSecretMetadataHandler(ctx, req, logger) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError, "expected error for KV v1 mount") + assert.Contains(t, getResultText(result), "not KV v2") +} \ No newline at end of file diff --git a/pkg/tools/kv/write_secret_metadata.go b/pkg/tools/kv/write_secret_metadata.go new file mode 100644 index 0000000..dc2cd28 --- /dev/null +++ b/pkg/tools/kv/write_secret_metadata.go @@ -0,0 +1,129 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package kv + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/hashicorp/vault-mcp-server/pkg/client" + "github.com/hashicorp/vault-mcp-server/pkg/utils" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + log "github.com/sirupsen/logrus" +) + +// WriteSecretMetadata creates a tool for writing custom metadata on a KV v2 secret path. +func WriteSecretMetadata(logger *log.Logger) server.ServerTool { + return server.ServerTool{ + Tool: mcp.NewTool("write_secret_metadata", + mcp.WithDescription("Write or update custom metadata (owner, email, app, or any key-value pairs) for a secret in a KV v2 mount. Only updates the metadata fields provided; does not affect the secret data or versions."), + mcp.WithToolAnnotation( + mcp.ToolAnnotation{ + DestructiveHint: utils.ToBoolPtr(false), + IdempotentHint: utils.ToBoolPtr(true), + }, + ), + mcp.WithString("mount", + mcp.Required(), + mcp.Description("The mount path of the KV v2 secret engine (e.g. 'secrets')."), + ), + mcp.WithString("path", + mcp.Required(), + mcp.Description("The path to the secret without the mount prefix (e.g. 'admin-demo-secret')."), + ), + mcp.WithString("custom_metadata", + mcp.Required(), + mcp.Description("JSON object with custom metadata key-value pairs to set (e.g. '{\"owner\":\"team-admin\",\"email\":\"admin@example.com\",\"app\":\"my-app\"}')."), + ), + mcp.WithString("namespace", + mcp.DefaultString(""), + mcp.Description("Optional Vault namespace override for this call (for example: 'admin/team-03'). If not set, uses the MCP session namespace."), + ), + ), + Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return writeSecretMetadataHandler(ctx, req, logger) + }, + } +} + +func writeSecretMetadataHandler(ctx context.Context, req mcp.CallToolRequest, logger *log.Logger) (*mcp.CallToolResult, error) { + logger.Debug("Handling write_secret_metadata request") + + args, ok := req.Params.Arguments.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("Missing or invalid arguments format"), nil + } + + mount, err := utils.ExtractMountPath(args) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + path, ok := args["path"].(string) + if !ok || path == "" { + return mcp.NewToolResultError("Missing or invalid 'path' parameter"), nil + } + + customMetadataRaw, ok := args["custom_metadata"].(string) + if !ok || strings.TrimSpace(customMetadataRaw) == "" { + return mcp.NewToolResultError("Missing or invalid 'custom_metadata' parameter (must be a JSON object string)"), nil + } + + customMetadata := map[string]interface{}{} + if err := json.Unmarshal([]byte(customMetadataRaw), &customMetadata); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("'custom_metadata' is not valid JSON: %v", err)), nil + } + + namespace, _ := args["namespace"].(string) + + vault, err := client.GetVaultClientFromContext(ctx, logger) + if err != nil { + logger.WithError(err).Error("Failed to get Vault client") + return mcp.NewToolResultError(fmt.Sprintf("Failed to get Vault client: %v", err)), nil + } + vault = withOptionalNamespace(vault, namespace) + + mounts, err := vault.Sys().ListMounts() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list mounts: %v", err)), nil + } + + m, exists := mounts[mount+"/"] + if !exists { + return mcp.NewToolResultError(fmt.Sprintf("mount '%s' not found", mount)), nil + } + if m.Type != "kv" { + return mcp.NewToolResultError(fmt.Sprintf("mount '%s' is not a KV engine (type: %s)", mount, m.Type)), nil + } + if m.Options == nil || m.Options["version"] != "2" { + return mcp.NewToolResultError(fmt.Sprintf("mount '%s' is KV v1; custom metadata is only supported on KV v2", mount)), nil + } + + metadataPath := strings.TrimSuffix(mount, "/") + "/metadata/" + strings.TrimPrefix(path, "/") + + payload := map[string]interface{}{ + "custom_metadata": customMetadata, + } + + _, err = vault.Logical().Write(metadataPath, payload) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to write metadata: %v", err)), nil + } + + result := map[string]interface{}{ + "message": fmt.Sprintf("Custom metadata written successfully for path '%s' in mount '%s'", path, mount), + "path": path, + "mount": mount, + "custom_metadata": customMetadata, + } + if namespace != "" { + result["namespace"] = namespace + } + + out, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(out)), nil +} \ No newline at end of file diff --git a/pkg/tools/tools.go b/pkg/tools/tools.go index 0a92838..e5d7ba0 100644 --- a/pkg/tools/tools.go +++ b/pkg/tools/tools.go @@ -30,6 +30,12 @@ func InitTools(hcServer *server.MCPServer, logger *log.Logger) { readSecretTool := kv.ReadSecret(logger) hcServer.AddTool(readSecretTool.Tool, readSecretTool.Handler) + readSecretMetadataTool := kv.ReadSecretMetadata(logger) + hcServer.AddTool(readSecretMetadataTool.Tool, readSecretMetadataTool.Handler) + + writeSecretMetadataTool := kv.WriteSecretMetadata(logger) + hcServer.AddTool(writeSecretMetadataTool.Tool, writeSecretMetadataTool.Handler) + writeSecretTool := kv.WriteSecret(logger) hcServer.AddTool(writeSecretTool.Tool, writeSecretTool.Handler)