From 13eeb3fe4a054d81546b363c8eec9eb829c742b5 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 14 May 2026 09:06:59 -0700 Subject: [PATCH] Add MCP entry password tool --- internal/mcpserver/server.go | 80 +++++++++++++++++++++++++++++++ internal/mcpserver/server_test.go | 47 +++++++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index fa1a33c..d294aa7 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -55,6 +55,11 @@ func New(client vaultClient, cfg Config) *mcp.Server { Title: "Find Browser Logins", Description: "Find KeePassGO browser-login matches for a page URL without returning passwords.", }, handlers.findBrowserLogins) + mcp.AddTool(server, &mcp.Tool{ + Name: "get_entry_password", + Title: "Get Entry Password", + Description: "Return the password for one uniquely matched KeePassGO entry using path, query, and optional metadata filters.", + }, handlers.getEntryPassword) mcp.AddTool(server, &mcp.Tool{ Name: "get_browser_credential", Title: "Get Browser Credential", @@ -198,6 +203,46 @@ func (h *handlers) getBrowserCredential(ctx context.Context, _ *mcp.CallToolRequ }, nil } +type getEntryPasswordInput struct { + Path []string `json:"path,omitempty" jsonschema:"optional KeePassGO group path to search within"` + Query string `json:"query,omitempty" jsonschema:"text query used to find candidate entries"` + Title string `json:"title,omitempty" jsonschema:"optional exact entry title filter"` + Username string `json:"username,omitempty" jsonschema:"optional exact entry username filter"` + URL string `json:"url,omitempty" jsonschema:"optional exact entry URL filter"` +} + +type getEntryPasswordOutput struct { + ID string `json:"id,omitempty" jsonschema:"entry id, when present"` + Title string `json:"title" jsonschema:"entry title"` + Username string `json:"username,omitempty" jsonschema:"entry username"` + URL string `json:"url,omitempty" jsonschema:"entry URL"` + Path []string `json:"path,omitempty" jsonschema:"entry group path"` + Password string `json:"password" jsonschema:"entry password"` +} + +func (h *handlers) getEntryPassword(ctx context.Context, _ *mcp.CallToolRequest, input getEntryPasswordInput) (*mcp.CallToolResult, getEntryPasswordOutput, error) { + entries, err := h.client.ListEntries(ctx, cleanPath(input.Path), strings.TrimSpace(input.Query)) + if err != nil { + return nil, getEntryPasswordOutput{}, fmt.Errorf("find KeePassGO entry password candidates: %w", err) + } + entry, err := uniquePasswordEntry(entries, input) + if err != nil { + return nil, getEntryPasswordOutput{}, err + } + password := entry.GetPassword() + if password == "" { + return nil, getEntryPasswordOutput{}, fmt.Errorf("matched KeePassGO entry %q has an empty password", entry.GetTitle()) + } + return nil, getEntryPasswordOutput{ + ID: entry.GetId(), + Title: entry.GetTitle(), + Username: entry.GetUsername(), + URL: entry.GetUrl(), + Path: append([]string(nil), entry.GetPath()...), + Password: password, + }, nil +} + func summarizeEntry(entry *keepassgov1.Entry) entrySummary { if entry == nil { return entrySummary{} @@ -220,6 +265,41 @@ func summarizeEntry(entry *keepassgov1.Entry) entrySummary { } } +func uniquePasswordEntry(entries []*keepassgov1.Entry, input getEntryPasswordInput) (*keepassgov1.Entry, error) { + var matches []*keepassgov1.Entry + for _, entry := range entries { + if entry == nil { + continue + } + if !optionalEqual(input.Title, entry.GetTitle()) { + continue + } + if !optionalEqual(input.Username, entry.GetUsername()) { + continue + } + if !optionalEqual(input.URL, entry.GetUrl()) { + continue + } + matches = append(matches, entry) + } + switch len(matches) { + case 0: + return nil, fmt.Errorf("no KeePassGO entry matched the password request") + case 1: + return matches[0], nil + default: + return nil, fmt.Errorf("password request matched %d KeePassGO entries; add title, username, URL, path, or query filters", len(matches)) + } +} + +func optionalEqual(want, got string) bool { + want = strings.TrimSpace(want) + if want == "" { + return true + } + return strings.EqualFold(want, strings.TrimSpace(got)) +} + func cleanPath(path []string) []string { out := make([]string, 0, len(path)) for _, part := range path { diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 48507b1..a074055 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -24,7 +24,7 @@ func TestServerRegistersKeePassGOTools(t *testing.T) { for _, tool := range result.Tools { got = append(got, tool.Name) } - want := []string{"find_browser_logins", "get_browser_credential", "get_session_status", "search_entries"} + want := []string{"find_browser_logins", "get_browser_credential", "get_entry_password", "get_session_status", "search_entries"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("ListTools() names mismatch (-want +got):\n%s", diff) } @@ -124,6 +124,51 @@ func TestGetBrowserCredentialReturnsCredential(t *testing.T) { } } +func TestGetEntryPasswordReturnsPasswordForUniqueMetadataMatch(t *testing.T) { + t.Parallel() + + client := &fakeVaultClient{ + entries: []*keepassgov1.Entry{ + { + Id: "wrong-crew", + Title: "Home Assistant", + Username: "rusty", + Password: "wrong-token", + Url: "https://lights.example.invalid", + Path: []string{"Root", "Shared"}, + }, + { + Id: "codex-token", + Title: "Home Assistant", + Username: "codex", + Password: "right-token", + Url: "https://lights.example.invalid", + Path: []string{"Root", "Codex"}, + }, + }, + } + session, cleanup := newTestSession(t, client) + defer cleanup() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_entry_password", + Arguments: map[string]any{ + "path": []string{"Root", "Codex"}, + "query": "Home Assistant", + "title": "home assistant", + "username": "codex", + "url": "https://lights.example.invalid", + }, + }) + if err != nil { + t.Fatalf("CallTool(get_entry_password) error = %v", err) + } + got := result.StructuredContent.(map[string]any) + if got["password"] != "right-token" { + t.Errorf("CallTool(get_entry_password).password = %v, want %q", got["password"], "right-token") + } +} + func newTestSession(t *testing.T, vaultClient *fakeVaultClient) (*mcp.ClientSession, func()) { t.Helper()