Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions pkg/tools/kv/namespace.go
Original file line number Diff line number Diff line change
@@ -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)
}
109 changes: 109 additions & 0 deletions pkg/tools/kv/read_secret_metadata.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +79 to +85

fullPath := fmt.Sprintf("%s/metadata/%s", mount, strings.TrimPrefix(path, "/"))
secret, err := vault.Logical().Read(fullPath)
Comment on lines +87 to +88
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
Comment on lines +102 to +108
}
95 changes: 95 additions & 0 deletions pkg/tools/kv/read_secret_metadata_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
129 changes: 129 additions & 0 deletions pkg/tools/kv/write_secret_metadata.go
Original file line number Diff line number Diff line change
@@ -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\"}')."),
),
Comment on lines +38 to +41
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")
Comment on lines +53 to +54

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
}
Comment on lines +71 to +74

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
Comment on lines +127 to +128
}
6 changes: 6 additions & 0 deletions pkg/tools/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading