feat: wire Infisical secret store into API server and MCP tools
- Wire secretsManager in NewHandler() — instantiate InfisicalBackend when OIKOS_INFISICAL_SITE_URL is set (previously always nil) - Add get_secret, list_secrets, set_secret MCP tools with nil-backend graceful degradation - Add oikos secret get|set|list CLI subcommands for Infisical - Fix Set() bug: create-before-update so new keys are created; add Type: "shared" to Update so it finds the right secret; disable SDK cache so Get returns fresh data after Set - Clean enrollment response: remove fake infisical_client_id/ infisical_client_secret stubs, store age key in Infisical for real
This commit is contained in:
@@ -97,7 +97,7 @@ func newTestPool(t *testing.T) *db.Pool {
|
||||
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
|
||||
t.Helper()
|
||||
var handler toolHandler
|
||||
for _, r := range allTools(pool, uuid.Nil) {
|
||||
for _, r := range allTools(pool, uuid.Nil, nil) {
|
||||
if r.tool.Name == name {
|
||||
handler = r.handler
|
||||
break
|
||||
|
||||
200
internal/mcp/secrets_tools_test.go
Normal file
200
internal/mcp/secrets_tools_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// mockSecretBackend implements secretBackend for testing.
|
||||
type mockSecretBackend struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *mockSecretBackend) Get(ctx context.Context, key string) (string, error) {
|
||||
v, ok := m.data[key]
|
||||
if !ok {
|
||||
return "", &secretErr{msg: "secret not found: " + key}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *mockSecretBackend) Set(ctx context.Context, key string, value string) error {
|
||||
m.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSecretBackend) List(ctx context.Context) ([]string, error) {
|
||||
keys := make([]string, 0, len(m.data))
|
||||
for k := range m.data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
type secretErr struct{ msg string }
|
||||
|
||||
func (e *secretErr) Error() string { return e.msg }
|
||||
|
||||
// findToolHandler locates a tool's handler from allTools by name.
|
||||
func findToolHandler(t *testing.T, pool interface{}, name string, sec secretBackend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t.Helper()
|
||||
for _, r := range allTools(nil, uuid.Nil, sec) {
|
||||
if r.tool.Name == name {
|
||||
return r.handler
|
||||
}
|
||||
}
|
||||
t.Fatalf("tool %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func callToolJSON(t *testing.T, name string, sec secretBackend, args map[string]any) any {
|
||||
t.Helper()
|
||||
handler := findToolHandler(t, nil, name, sec)
|
||||
argBytes, _ := json.Marshal(args)
|
||||
req := &mcp.CallToolRequest{
|
||||
Params: &mcp.CallToolParamsRaw{Arguments: argBytes},
|
||||
}
|
||||
result, err := handler(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("tool %q error: %v", name, err)
|
||||
}
|
||||
if len(result.Content) == 0 {
|
||||
t.Fatalf("tool %q returned no content", name)
|
||||
}
|
||||
tc := result.Content[0].(*mcp.TextContent)
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(tc.Text), &out); err != nil {
|
||||
// Not JSON — return raw string
|
||||
return tc.Text
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func callToolText(t *testing.T, name string, sec secretBackend, args map[string]any) string {
|
||||
t.Helper()
|
||||
handler := findToolHandler(t, nil, name, sec)
|
||||
argBytes, _ := json.Marshal(args)
|
||||
req := &mcp.CallToolRequest{
|
||||
Params: &mcp.CallToolParamsRaw{Arguments: argBytes},
|
||||
}
|
||||
result, err := handler(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("tool %q error: %v", name, err)
|
||||
}
|
||||
if len(result.Content) == 0 {
|
||||
t.Fatalf("tool %q returned no content", name)
|
||||
}
|
||||
return result.Content[0].(*mcp.TextContent).Text
|
||||
}
|
||||
|
||||
func TestGetSecret(t *testing.T) {
|
||||
sec := &mockSecretBackend{
|
||||
data: map[string]string{
|
||||
"matrix-token": "bot-token-123",
|
||||
"clients/host:hubris/age-key": "AGE-SECRET-KEY",
|
||||
},
|
||||
}
|
||||
|
||||
// Get existing key
|
||||
val := callToolText(t, "get_secret", sec, map[string]any{"key": "matrix-token"})
|
||||
if val != "bot-token-123" {
|
||||
t.Errorf("get_secret = %q, want bot-token-123", val)
|
||||
}
|
||||
|
||||
// Get missing key
|
||||
errText := callToolText(t, "get_secret", sec, map[string]any{"key": "nonexistent"})
|
||||
if errText == "" {
|
||||
t.Error("expected error for missing key")
|
||||
}
|
||||
|
||||
// Missing key arg
|
||||
errText = callToolText(t, "get_secret", sec, map[string]any{})
|
||||
if errText != "error: key is required" {
|
||||
t.Errorf("missing key error = %q, want error: key is required", errText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSecrets(t *testing.T) {
|
||||
sec := &mockSecretBackend{
|
||||
data: map[string]string{
|
||||
"clients/host:hubris/age-key": "val1",
|
||||
"clients/host:strong/age-key": "val2",
|
||||
"shared/matrix-token": "val3",
|
||||
},
|
||||
}
|
||||
|
||||
// List all
|
||||
out := callToolJSON(t, "list_secrets", sec, map[string]any{})
|
||||
keys, ok := out.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("list_secrets returned non-array: %T", out)
|
||||
}
|
||||
if len(keys) != 3 {
|
||||
t.Errorf("list_secrets count = %d, want 3", len(keys))
|
||||
}
|
||||
|
||||
// List with prefix filter
|
||||
out = callToolJSON(t, "list_secrets", sec, map[string]any{"path_prefix": "clients/"})
|
||||
keys, ok = out.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("filtered list returned non-array: %T", out)
|
||||
}
|
||||
if len(keys) != 2 {
|
||||
t.Errorf("filtered list count = %d, want 2", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSecret(t *testing.T) {
|
||||
sec := &mockSecretBackend{
|
||||
data: map[string]string{},
|
||||
}
|
||||
|
||||
// Set a key
|
||||
result := callToolText(t, "set_secret", sec, map[string]any{"key": "test-key", "value": "test-value"})
|
||||
if result != "secret test-key stored" {
|
||||
t.Errorf("set_secret = %q, want 'secret test-key stored'", result)
|
||||
}
|
||||
|
||||
// Verify it was stored
|
||||
val, err := sec.Get(context.Background(), "test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("verify get: %v", err)
|
||||
}
|
||||
if val != "test-value" {
|
||||
t.Errorf("stored value = %q, want test-value", val)
|
||||
}
|
||||
|
||||
// Missing key
|
||||
errText := callToolText(t, "set_secret", sec, map[string]any{})
|
||||
if errText != "error: key is required" {
|
||||
t.Errorf("missing key error = %q", errText)
|
||||
}
|
||||
|
||||
// Missing value
|
||||
errText = callToolText(t, "set_secret", sec, map[string]any{"key": "x"})
|
||||
if errText != "error: value is required" {
|
||||
t.Errorf("missing value error = %q", errText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretToolsNilBackend(t *testing.T) {
|
||||
// All tools should return a graceful error when no backend is configured
|
||||
errText := callToolText(t, "get_secret", nil, map[string]any{"key": "x"})
|
||||
if errText != "error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)" {
|
||||
t.Errorf("nil backend get_secret = %q", errText)
|
||||
}
|
||||
|
||||
errText = callToolText(t, "list_secrets", nil, map[string]any{})
|
||||
if errText != "error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)" {
|
||||
t.Errorf("nil backend list_secrets = %q", errText)
|
||||
}
|
||||
|
||||
errText = callToolText(t, "set_secret", nil, map[string]any{"key": "x", "value": "y"})
|
||||
if errText != "error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)" {
|
||||
t.Errorf("nil backend set_secret = %q", errText)
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,19 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
||||
return s
|
||||
}
|
||||
|
||||
// secretBackend is the interface MCP tools use to access the secrets store.
|
||||
// Defined here to avoid importing the full secrets package (which brings in
|
||||
// the Infisical SDK). Mirrors the subset of secrets.Backend used by tools.
|
||||
type secretBackend interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Set(ctx context.Context, key string, value string) error
|
||||
List(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID) http.Handler {
|
||||
s := newServer(pool, agentID)
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secretBackend) http.Handler {
|
||||
s := newServer(pool, agentID, sec)
|
||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
if token != "" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||
@@ -65,11 +74,11 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID) http.Handler {
|
||||
// toolHandler is the function signature registered via AddTool.
|
||||
type toolHandler = mcp.ToolHandler
|
||||
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec secretBackend) *mcp.Server {
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
for _, t := range allTools(pool, agentID) {
|
||||
for _, t := range allTools(pool, agentID, sec) {
|
||||
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestNewServerRegistersTools(t *testing.T) {
|
||||
}()
|
||||
// pool is only used inside tool handlers (invoked per-call), not at
|
||||
// registration time, so a nil pool is safe for this construction test.
|
||||
s := newServer(nil, uuid.Nil)
|
||||
s := newServer(nil, uuid.Nil, nil)
|
||||
if s == nil {
|
||||
t.Fatal("newServer returned nil")
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type toolReg struct {
|
||||
// allTools returns every MCP tool registration. Tool definitions, schemas,
|
||||
// descriptions, and handler bodies are kept verbatim from the former inline
|
||||
// newServer registrations.
|
||||
func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
|
||||
func allTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
||||
return []toolReg{
|
||||
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
||||
InputSchema: objSchema(),
|
||||
@@ -1629,6 +1629,81 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
|
||||
ORDER BY e.slug
|
||||
LIMIT $3`, key, val, limit), nil
|
||||
}},
|
||||
|
||||
// ── Stage 5: Secret store (Infisical) ────────────────────────
|
||||
{tool: &mcp.Tool{Name: "get_secret", Description: "Retrieve a secret value from the Infisical vault. Returns the secret value. Use for service credentials, tokens, and keys needed to operate the homelab.",
|
||||
InputSchema: objSchema(
|
||||
prop{"key", "string", "Secret key to retrieve (e.g. 'matrix-token', 'clients/host:hubris/age-key')"},
|
||||
prop{"path", "string", "Secret path prefix (default '/')"},
|
||||
prop{"environment", "string", "Environment slug (default 'dev')"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if sec == nil {
|
||||
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
|
||||
}
|
||||
args := argsMap(req)
|
||||
key, _ := args["key"].(string)
|
||||
if key == "" {
|
||||
return textResult("error: key is required"), nil
|
||||
}
|
||||
val, err := sec.Get(ctx, key)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
return textResult(val), nil
|
||||
}},
|
||||
|
||||
{tool: &mcp.Tool{Name: "list_secrets", Description: "List secret keys in the Infisical vault. Returns key names only (no values). Filter by path prefix to scope to a client or shared path.",
|
||||
InputSchema: objSchema(
|
||||
prop{"path_prefix", "string", "Filter to keys matching this prefix (e.g. 'clients/', 'shared/', 'config/')"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if sec == nil {
|
||||
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
|
||||
}
|
||||
args := argsMap(req)
|
||||
prefix, _ := args["path_prefix"].(string)
|
||||
|
||||
keys, err := sec.List(ctx)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if prefix != "" {
|
||||
filtered := keys[:0]
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
filtered = append(filtered, k)
|
||||
}
|
||||
}
|
||||
keys = filtered
|
||||
}
|
||||
data, _ := json.MarshalIndent(keys, "", " ")
|
||||
return textResult(string(data)), nil
|
||||
}},
|
||||
|
||||
{tool: &mcp.Tool{Name: "set_secret", Description: "Store or update a secret in the Infisical vault. Use when discovering new credentials that need to be persisted. Requires operator approval (config_mutation).",
|
||||
InputSchema: objSchema(
|
||||
prop{"key", "string", "Secret key to store"},
|
||||
prop{"value", "string", "Secret value to store"},
|
||||
prop{"path", "string", "Secret path prefix (default '/')"},
|
||||
prop{"environment", "string", "Environment slug (default 'dev')"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if sec == nil {
|
||||
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
|
||||
}
|
||||
args := argsMap(req)
|
||||
key, _ := args["key"].(string)
|
||||
value, _ := args["value"].(string)
|
||||
if key == "" {
|
||||
return textResult("error: key is required"), nil
|
||||
}
|
||||
if value == "" {
|
||||
return textResult("error: value is required"), nil
|
||||
}
|
||||
if err := sec.Set(ctx, key, value); err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("secret %s stored", key)), nil
|
||||
}},
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user