Add MCP entry password tool
This commit is contained in:
@@ -55,6 +55,11 @@ func New(client vaultClient, cfg Config) *mcp.Server {
|
|||||||
Title: "Find Browser Logins",
|
Title: "Find Browser Logins",
|
||||||
Description: "Find KeePassGO browser-login matches for a page URL without returning passwords.",
|
Description: "Find KeePassGO browser-login matches for a page URL without returning passwords.",
|
||||||
}, handlers.findBrowserLogins)
|
}, 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{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "get_browser_credential",
|
Name: "get_browser_credential",
|
||||||
Title: "Get Browser Credential",
|
Title: "Get Browser Credential",
|
||||||
@@ -198,6 +203,46 @@ func (h *handlers) getBrowserCredential(ctx context.Context, _ *mcp.CallToolRequ
|
|||||||
}, nil
|
}, 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 {
|
func summarizeEntry(entry *keepassgov1.Entry) entrySummary {
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
return entrySummary{}
|
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 {
|
func cleanPath(path []string) []string {
|
||||||
out := make([]string, 0, len(path))
|
out := make([]string, 0, len(path))
|
||||||
for _, part := range path {
|
for _, part := range path {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestServerRegistersKeePassGOTools(t *testing.T) {
|
|||||||
for _, tool := range result.Tools {
|
for _, tool := range result.Tools {
|
||||||
got = append(got, tool.Name)
|
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 != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("ListTools() names mismatch (-want +got):\n%s", 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()) {
|
func newTestSession(t *testing.T, vaultClient *fakeVaultClient) (*mcp.ClientSession, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user