Compare commits

..

3 Commits

Author SHA1 Message Date
Joe Julian 13eeb3fe4a Add MCP entry password tool
ci / lint-test (pull_request) Successful in 1m46s
ci / build (pull_request) Successful in 2m40s
2026-05-14 09:06:59 -07:00
Joe Julian d9a4bc6b14 Package MCP server binary
ci / lint-test (pull_request) Successful in 1m44s
ci / build (pull_request) Successful in 2m44s
2026-05-14 08:57:39 -07:00
Joe Julian 32f9abe5e2 Add official MCP server
ci / lint-test (pull_request) Successful in 9m39s
ci / build (pull_request) Successful in 2m52s
2026-05-14 08:54:01 -07:00
15 changed files with 654 additions and 299 deletions
+1
View File
@@ -2,6 +2,7 @@ build/
*.apk
/keepassgo
/keepassgo-browser-bridge
/keepassgo-mcp-server
android/keepassgo-android.jar
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
packaging/archlinux/keepassgo-git/PKGBUILD
+4 -1
View File
@@ -48,7 +48,7 @@ CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(a
CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))"
endif
.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate browser-extension-firefox-dir browser-extension-firefox-lint browser-extension-firefox-build browser-extension-firefox-run browser-extension-firefox-sign
.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge mcp-server browser-extension-validate browser-extension-firefox-dir browser-extension-firefox-lint browser-extension-firefox-build browser-extension-firefox-run browser-extension-firefox-sign
apk:
@if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \
$(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \
@@ -137,6 +137,9 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
browser-bridge:
go build ./cmd/keepassgo-browser-bridge
mcp-server:
go build ./cmd/keepassgo-mcp-server
browser-extension-firefox-dir:
@mkdir -p "$(dir $(FIREFOX_EXTENSION_DIR))"
@python3 scripts/prepare_firefox_extension.py "$(FIREFOX_EXTENSION_DIR)"
+21
View File
@@ -118,6 +118,27 @@ See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
## MCP Server
KeePassGO includes a stdio Model Context Protocol server for agent and assistant integrations.
It connects to the same local authenticated gRPC API used by browser and desktop automation, so
existing token policy and approval prompts remain in force.
Build it with:
```bash
make mcp-server
```
Configure your MCP client to run `keepassgo-mcp-server` and provide an API token through
`KEEPASSGO_MCP_TOKEN`. The server also accepts `KEEPASSGO_BEARER_TOKEN` for compatibility with
existing local gRPC tooling. Set `KEEPASSGO_GRPC_ADDR` or pass `-grpc-addr` when KeePassGO is not
listening on the default local socket.
The MCP server exposes tools for session status, metadata-only entry search, browser-login matching,
and explicit credential retrieval. Metadata tools do not return passwords, notes, or custom field
values; credential retrieval uses KeePassGO's credential access policy.
## Browser Extension
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"runtime"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/mcpserver"
)
var version = "dev"
func main() {
if err := run(context.Background(), os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("keepassgo-mcp-server", flag.ContinueOnError)
grpcAddress := fs.String("grpc-addr", envOrDefault("KEEPASSGO_GRPC_ADDR", grpcaddr.Default(runtime.GOOS)), "KeePassGO gRPC address")
if err := fs.Parse(args); err != nil {
return err
}
token := mcpToken()
if token == "" {
return fmt.Errorf("KeePassGO MCP bearer token is required; set KEEPASSGO_MCP_TOKEN or KEEPASSGO_BEARER_TOKEN")
}
conn, client, callCtx, err := browserbridge.Dial(ctx, browserbridge.Connection{
GRPCAddress: strings.TrimSpace(*grpcAddress),
BearerToken: token,
})
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
server := mcpserver.New(client, mcpserver.Config{
GRPCAddress: strings.TrimSpace(*grpcAddress),
Version: version,
})
return server.Run(callCtx, &mcp.StdioTransport{})
}
func envOrDefault(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func mcpToken() string {
if token := strings.TrimSpace(os.Getenv("KEEPASSGO_MCP_TOKEN")); token != "" {
return token
}
return strings.TrimSpace(os.Getenv("KEEPASSGO_BEARER_TOKEN"))
}
+7 -1
View File
@@ -10,6 +10,8 @@ require (
gioui.org v0.9.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4
github.com/google/go-cmp v0.7.0
github.com/modelcontextprotocol/go-sdk v1.6.0
github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
google.golang.org/grpc v1.79.3
@@ -108,7 +110,7 @@ require (
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
@@ -180,6 +182,8 @@ require (
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
@@ -210,6 +214,7 @@ require (
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
@@ -223,6 +228,7 @@ require (
golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
+14
View File
@@ -269,6 +269,8 @@ github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -336,6 +338,8 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@@ -470,6 +474,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
@@ -564,6 +570,10 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -644,6 +654,8 @@ github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5Jsjqto
github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4=
github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw=
github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -794,6 +806,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+1 -127
View File
@@ -249,7 +249,6 @@ type ui struct {
entryFields widget.Editor
customFieldKeys []widget.Editor
customFieldValues []widget.Editor
copyCustomFields []widget.Clickable
historyIndex widget.Editor
groupName widget.Editor
groupParentPath widget.Editor
@@ -403,8 +402,6 @@ type ui struct {
vaultRemoteCredentialClicks []widget.Clickable
syncRemoteCredentialClicks []widget.Clickable
removeCustomFields []widget.Clickable
toggleCustomFields []widget.Clickable
revealedCustomFields map[string]bool
state appstate.State
visible []entry
currentPath []string
@@ -665,7 +662,6 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
pendingSharedLookupPath: paths.PendingSharedLookupPath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
revealedCustomFields: map[string]bool{},
lifecycleAdvancedHidden: true,
historyHidden: true,
statusBannerTTL: statusBannerDuration,
@@ -944,7 +940,6 @@ func (u *ui) handlePhoneBack() bool {
func (u *ui) resetPasswordPeek() {
u.showPassword = false
u.revealedCustomFields = map[string]bool{}
}
func (u *ui) childGroups() []string {
@@ -2158,12 +2153,6 @@ type detailViewMetrics struct {
cardGap unit.Dp
}
type extraStringView struct {
Key string
Value string
Revealed bool
}
func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions {
rows := u.detailViewRows(item)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -2191,8 +2180,6 @@ func (u *ui) detailViewRows(item entry) []layout.Widget {
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.detailNotesCard(item),
layout.Spacer{Height: metrics.cardGap}.Layout,
u.detailExtraStringsCard,
layout.Spacer{Height: metrics.cardGap}.Layout,
u.attachmentSummaryPanel,
layout.Spacer{Height: metrics.cardGap}.Layout,
u.historyPanel,
@@ -2352,115 +2339,6 @@ func (u *ui) detailNotesCard(item entry) layout.Widget {
}
}
func (u *ui) detailExtraStringsCard(gtx layout.Context) layout.Dimensions {
fields := u.detailExtraStrings()
u.ensureExtraStringClickables(len(fields))
if len(fields) == 0 {
return layout.Dimensions{}
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "EXTRA STRINGS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
}
for i, field := range fields {
index := i
item := field
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.detailExtraStringRow(gtx, index, item)
}))
if i < len(fields)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (u *ui) detailExtraStringRow(gtx layout.Context, index int, field extraStringView) layout.Dimensions {
for u.toggleCustomFields[index].Clicked(gtx) {
u.toggleExtraStringReveal(field.Key)
}
for u.copyCustomFields[index].Clicked(gtx) {
key := field.Key
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction(key) })
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(field.Key))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), field.Value)
return lbl.Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleCustomFields[index], field.Revealed)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.copyCustomFields[index], u.copyIcon, "Copy extra string")
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}),
)
}
func (u *ui) detailExtraStrings() []extraStringView {
item, ok := u.selectedEntry()
if !ok || len(item.Fields) == 0 {
return nil
}
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
slices.Sort(keys)
out := make([]extraStringView, 0, len(keys))
for _, key := range keys {
value := item.Fields[key]
revealed := u.revealedCustomFields[key]
if !revealed {
value = maskedSecretValue(value)
}
out = append(out, extraStringView{Key: key, Value: value, Revealed: revealed})
}
return out
}
func (u *ui) toggleExtraStringReveal(key string) {
if u.revealedCustomFields == nil {
u.revealedCustomFields = map[string]bool{}
}
u.revealedCustomFields[key] = !u.revealedCustomFields[key]
}
func (u *ui) ensureExtraStringClickables(count int) {
if len(u.copyCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
case appstate.SectionTemplates:
@@ -3118,11 +2996,7 @@ func (u *ui) detailPasswordValue() string {
if u.showPassword {
return item.Password
}
return maskedSecretValue(item.Password)
}
func maskedSecretValue(value string) string {
return strings.Repeat("•", max(8, len(value)))
return strings.Repeat("•", max(8, len(item.Password)))
}
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
-44
View File
@@ -82,8 +82,6 @@ func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil
u.customFieldValues = nil
u.removeCustomFields = nil
u.copyCustomFields = nil
u.toggleCustomFields = nil
if len(fields) == 0 {
u.appendCustomFieldRow("", "")
return
@@ -106,53 +104,21 @@ func (u *ui) appendCustomFieldRow(key, value string) {
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
u.copyCustomFields = append(u.copyCustomFields, widget.Clickable{})
u.toggleCustomFields = append(u.toggleCustomFields, widget.Clickable{})
}
func (u *ui) removeCustomFieldRow(index int) {
u.ensureCustomFieldRowControls()
if index < 0 || index >= len(u.customFieldKeys) {
return
}
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
u.copyCustomFields = append(u.copyCustomFields[:index], u.copyCustomFields[index+1:]...)
u.toggleCustomFields = append(u.toggleCustomFields[:index], u.toggleCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "")
}
}
func (u *ui) ensureCustomFieldRowControls() {
if len(u.customFieldValues) < len(u.customFieldKeys) {
values := make([]widget.Editor, len(u.customFieldKeys))
copy(values, u.customFieldValues)
for i := len(u.customFieldValues); i < len(values); i++ {
values[i] = widget.Editor{SingleLine: true, Submit: false}
}
u.customFieldValues = values
}
if len(u.removeCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.removeCustomFields)
u.removeCustomFields = clicks
}
if len(u.copyCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) currentCustomFields() (map[string]string, error) {
u.ensureCustomFieldRowControls()
fields := map[string]string{}
for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text())
@@ -433,16 +399,6 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
return service.Copy(model, u.state.SelectedEntryID, target)
}
func (u *ui) copySelectedCustomFieldAction(key string) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{Writer: u.clipboardWriter}
return service.CopyCustomField(model, u.state.SelectedEntryID, key)
}
func (u *ui) generatePasswordAction() error {
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil {
-1
View File
@@ -667,7 +667,6 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 0 {
u.setCustomFieldRows(nil)
}
u.ensureCustomFieldRowControls()
return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
-85
View File
@@ -3830,21 +3830,6 @@ func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing
}
}
func TestUIRemoveCustomFieldRowToleratesMissingClickables(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.customFieldKeys = []widget.Editor{{SingleLine: true}}
u.customFieldValues = []widget.Editor{{SingleLine: true}}
u.removeCustomFields = nil
u.removeCustomFieldRow(0)
if len(u.customFieldKeys) != 1 || len(u.customFieldValues) != 1 || len(u.removeCustomFields) != 1 {
t.Fatalf("custom field rows after remove with missing clickables = %d/%d/%d, want one blank row", len(u.customFieldKeys), len(u.customFieldValues), len(u.removeCustomFields))
}
}
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
t.Parallel()
@@ -9141,76 +9126,6 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
}
}
func TestUIExtraStringValuesAreMaskedUntilRevealed(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
fields := u.detailExtraStrings()
if len(fields) != 1 {
t.Fatalf("len(detailExtraStrings()) = %d, want 1", len(fields))
}
if fields[0].Value != strings.Repeat("•", len("green-light")) {
t.Fatalf("detailExtraStrings()[0].Value hidden = %q, want masked value", fields[0].Value)
}
u.toggleExtraStringReveal("OTPSeed")
fields = u.detailExtraStrings()
if fields[0].Value != "green-light" {
t.Fatalf("detailExtraStrings()[0].Value revealed = %q, want green-light", fields[0].Value)
}
}
func TestUICopyExtraStringWritesClipboardWithoutLeakingStatus(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
writer := &memoryClipboardWriter{}
u.clipboardWriter = writer
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction("OTPSeed") })
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
if u.state.StatusMessage != "copy extra string complete" {
t.Fatalf("state.StatusMessage = %q, want copy extra string complete", u.state.StatusMessage)
}
if strings.Contains(u.state.StatusMessage, "green-light") {
t.Fatalf("state.StatusMessage = %q, must not contain copied extra string value", u.state.StatusMessage)
}
}
func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) {
t.Parallel()
-16
View File
@@ -45,22 +45,6 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
return nil
}
func (s Service) CopyCustomField(model vault.Model, entryID, key string) error {
entry, err := findEntry(model, entryID)
if err != nil {
return err
}
content, ok := entry.Fields[key]
if !ok {
return ErrUnsupportedTarget
}
if err := s.writer().WriteText(content); err != nil {
return writeError{err: err}
}
return nil
}
func (s Service) writer() Writer {
if s.Writer != nil {
return s.Writer
-24
View File
@@ -48,30 +48,6 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
}
}
func TestServiceCopiesCustomField(t *testing.T) {
t.Parallel()
var writer memoryWriter
service := Service{Writer: &writer}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
}
if err := service.CopyCustomField(model, "vault-console", "OTPSeed"); err != nil {
t.Fatalf("CopyCustomField(vault-console, OTPSeed) error = %v", err)
}
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
}
func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
t.Parallel()
+311
View File
@@ -0,0 +1,311 @@
package mcpserver
import (
"context"
"fmt"
"sort"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
)
const serverName = "keepassgo"
type vaultClient interface {
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
}
type Config struct {
GRPCAddress string
Version string
}
func New(client vaultClient, cfg Config) *mcp.Server {
version := strings.TrimSpace(cfg.Version)
if version == "" {
version = "dev"
}
handlers := &handlers{
client: client,
grpcAddress: strings.TrimSpace(cfg.GRPCAddress),
}
server := mcp.NewServer(&mcp.Implementation{
Name: serverName,
Version: version,
}, &mcp.ServerOptions{
Instructions: "Use KeePassGO tools to inspect vault status, search entry metadata, find browser login matches, and request credentials through the authenticated KeePassGO gRPC API.",
})
mcp.AddTool(server, &mcp.Tool{
Name: "get_session_status",
Title: "Get Session Status",
Description: "Return KeePassGO lock, dirty, entry count, approval count, and gRPC connection metadata.",
}, handlers.getSessionStatus)
mcp.AddTool(server, &mcp.Tool{
Name: "search_entries",
Title: "Search Entries",
Description: "Search KeePassGO entry metadata by query and optional group path. Passwords, notes, and custom field values are not returned.",
}, handlers.searchEntries)
mcp.AddTool(server, &mcp.Tool{
Name: "find_browser_logins",
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",
Description: "Request the username and password for an entry and page URL through KeePassGO's credential access policy and approval flow.",
}, handlers.getBrowserCredential)
return server
}
type handlers struct {
client vaultClient
grpcAddress string
}
type statusInput struct{}
type statusOutput struct {
Connected bool `json:"connected" jsonschema:"whether the MCP server reached the KeePassGO gRPC API"`
Locked bool `json:"locked" jsonschema:"whether the active vault is locked"`
Dirty bool `json:"dirty" jsonschema:"whether the active vault has unsaved changes"`
EntryCount uint32 `json:"entryCount" jsonschema:"number of entries visible to the token"`
PendingApprovalCount uint32 `json:"pendingApprovalCount" jsonschema:"number of pending approval requests"`
TokenPendingApprovalCount uint32 `json:"tokenPendingApprovalCount" jsonschema:"number of pending approval requests for this API token"`
GRPCAddress string `json:"grpcAddress" jsonschema:"KeePassGO gRPC address used by the MCP server"`
}
func (h *handlers) getSessionStatus(ctx context.Context, _ *mcp.CallToolRequest, _ statusInput) (*mcp.CallToolResult, statusOutput, error) {
resp, err := h.client.Status(ctx)
if err != nil {
return nil, statusOutput{}, fmt.Errorf("get KeePassGO session status: %w", err)
}
return nil, statusOutput{
Connected: true,
Locked: resp.GetLocked(),
Dirty: resp.GetDirty(),
EntryCount: resp.GetEntryCount(),
PendingApprovalCount: resp.GetPendingApprovalCount(),
TokenPendingApprovalCount: resp.GetTokenPendingApprovalCount(),
GRPCAddress: h.grpcAddress,
}, nil
}
type searchEntriesInput struct {
Path []string `json:"path,omitempty" jsonschema:"optional KeePassGO group path to search within, for example [\"Root\", \"Internet\"]"`
Query string `json:"query,omitempty" jsonschema:"optional text query for entry title, username, URL, tags, or other indexed metadata"`
}
type entrySummary struct {
ID string `json:"id" jsonschema:"entry id"`
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"`
Tags []string `json:"tags,omitempty" jsonschema:"entry tags"`
FieldNames []string `json:"fieldNames,omitempty" jsonschema:"names of custom fields on the entry; values are not returned"`
HasNotes bool `json:"hasNotes" jsonschema:"whether the entry has notes; note text is not returned"`
HasPassword bool `json:"hasPassword" jsonschema:"whether the entry has a password; password text is not returned"`
}
type searchEntriesOutput struct {
Entries []entrySummary `json:"entries" jsonschema:"matching entry metadata"`
}
func (h *handlers) searchEntries(ctx context.Context, _ *mcp.CallToolRequest, input searchEntriesInput) (*mcp.CallToolResult, searchEntriesOutput, error) {
entries, err := h.client.ListEntries(ctx, cleanPath(input.Path), strings.TrimSpace(input.Query))
if err != nil {
return nil, searchEntriesOutput{}, fmt.Errorf("search KeePassGO entries: %w", err)
}
out := searchEntriesOutput{Entries: make([]entrySummary, 0, len(entries))}
for _, entry := range entries {
out.Entries = append(out.Entries, summarizeEntry(entry))
}
return nil, out, nil
}
type findBrowserLoginsInput struct {
PageURL string `json:"pageUrl" jsonschema:"page URL to match against KeePassGO browser-login entries"`
}
type browserLoginMatch struct {
ID string `json:"id" jsonschema:"entry id"`
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"`
Quality string `json:"quality,omitempty" jsonschema:"match quality reported by KeePassGO"`
}
type findBrowserLoginsOutput struct {
Matches []browserLoginMatch `json:"matches" jsonschema:"browser-login matches without passwords"`
}
func (h *handlers) findBrowserLogins(ctx context.Context, _ *mcp.CallToolRequest, input findBrowserLoginsInput) (*mcp.CallToolResult, findBrowserLoginsOutput, error) {
pageURL := strings.TrimSpace(input.PageURL)
if pageURL == "" {
return nil, findBrowserLoginsOutput{}, fmt.Errorf("pageUrl is required")
}
matches, err := h.client.FindBrowserLogins(ctx, pageURL)
if err != nil {
return nil, findBrowserLoginsOutput{}, fmt.Errorf("find KeePassGO browser logins: %w", err)
}
out := findBrowserLoginsOutput{Matches: make([]browserLoginMatch, 0, len(matches))}
for _, match := range matches {
out.Matches = append(out.Matches, browserLoginMatch{
ID: match.GetId(),
Title: match.GetTitle(),
Username: match.GetUsername(),
URL: match.GetUrl(),
Path: append([]string(nil), match.GetPath()...),
Quality: match.GetQuality(),
})
}
return nil, out, nil
}
type getBrowserCredentialInput struct {
EntryID string `json:"entryId" jsonschema:"KeePassGO entry id to request credentials for"`
PageURL string `json:"pageUrl" jsonschema:"page URL associated with the credential request"`
}
type getBrowserCredentialOutput struct {
ID string `json:"id" jsonschema:"entry id"`
Username string `json:"username,omitempty" jsonschema:"entry username"`
Password string `json:"password,omitempty" jsonschema:"entry password"`
URL string `json:"url,omitempty" jsonschema:"entry URL"`
}
func (h *handlers) getBrowserCredential(ctx context.Context, _ *mcp.CallToolRequest, input getBrowserCredentialInput) (*mcp.CallToolResult, getBrowserCredentialOutput, error) {
entryID := strings.TrimSpace(input.EntryID)
if entryID == "" {
return nil, getBrowserCredentialOutput{}, fmt.Errorf("entryId is required")
}
resp, err := h.client.GetBrowserCredential(ctx, entryID, strings.TrimSpace(input.PageURL))
if err != nil {
return nil, getBrowserCredentialOutput{}, fmt.Errorf("get KeePassGO browser credential: %w", err)
}
return nil, getBrowserCredentialOutput{
ID: resp.GetId(),
Username: resp.GetUsername(),
Password: resp.GetPassword(),
URL: resp.GetUrl(),
}, 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{}
}
fieldNames := make([]string, 0, len(entry.GetFields()))
for name := range entry.GetFields() {
fieldNames = append(fieldNames, name)
}
sort.Strings(fieldNames)
return entrySummary{
ID: entry.GetId(),
Title: entry.GetTitle(),
Username: entry.GetUsername(),
URL: entry.GetUrl(),
Path: append([]string(nil), entry.GetPath()...),
Tags: append([]string(nil), entry.GetTags()...),
FieldNames: fieldNames,
HasNotes: strings.TrimSpace(entry.GetNotes()) != "",
HasPassword: entry.GetPassword() != "",
}
}
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 {
if trimmed := strings.TrimSpace(part); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
+228
View File
@@ -0,0 +1,228 @@
package mcpserver
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/modelcontextprotocol/go-sdk/mcp"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
)
func TestServerRegistersKeePassGOTools(t *testing.T) {
t.Parallel()
session, cleanup := newTestSession(t, &fakeVaultClient{})
defer cleanup()
result, err := session.ListTools(context.Background(), nil)
if err != nil {
t.Fatalf("ListTools() error = %v", err)
}
var got []string
for _, tool := range result.Tools {
got = append(got, tool.Name)
}
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)
}
}
func TestSearchEntriesReturnsMetadataWithoutSecrets(t *testing.T) {
t.Parallel()
client := &fakeVaultClient{
entries: []*keepassgov1.Entry{{
Id: "casino-blueprint",
Title: "Casino Blueprint",
Username: "linus",
Password: "three-pin-changeup",
Url: "https://vault.example.invalid",
Notes: "meet by the fountains",
Tags: []string{"heist", "vault"},
Path: []string{"Root", "Plans"},
Fields: map[string]string{
"safe-code": "1138",
"alias": "Mr. Caldwell",
},
}},
}
session, cleanup := newTestSession(t, client)
defer cleanup()
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "search_entries",
Arguments: map[string]any{
"path": []string{" Root ", "", "Plans"},
"query": " casino ",
},
})
if err != nil {
t.Fatalf("CallTool(search_entries) error = %v", err)
}
got, ok := result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("CallTool(search_entries).StructuredContent type = %T, want map[string]any", result.StructuredContent)
}
entries, ok := got["entries"].([]any)
if !ok || len(entries) != 1 {
t.Fatalf("CallTool(search_entries).entries = %#v, want one entry", got["entries"])
}
entry, ok := entries[0].(map[string]any)
if !ok {
t.Fatalf("CallTool(search_entries).entries[0] type = %T, want map[string]any", entries[0])
}
if _, ok := entry["password"]; ok {
t.Error("CallTool(search_entries) returned password, want password omitted")
}
if _, ok := entry["notes"]; ok {
t.Error("CallTool(search_entries) returned notes, want notes omitted")
}
if _, ok := entry["fields"]; ok {
t.Error("CallTool(search_entries) returned field values, want field values omitted")
}
if diff := cmp.Diff([]string{"Root", "Plans"}, client.listEntriesPath); diff != "" {
t.Errorf("CallTool(search_entries) ListEntries path mismatch (-want +got):\n%s", diff)
}
if client.listEntriesQuery != "casino" {
t.Errorf("CallTool(search_entries) ListEntries query = %q, want %q", client.listEntriesQuery, "casino")
}
}
func TestGetBrowserCredentialReturnsCredential(t *testing.T) {
t.Parallel()
client := &fakeVaultClient{
credential: &keepassgov1.GetBrowserCredentialResponse{
Id: "benedict-account",
Username: "tess",
Password: "loaded-dice",
Url: "https://casino.example.invalid",
},
}
session, cleanup := newTestSession(t, client)
defer cleanup()
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_browser_credential",
Arguments: map[string]any{
"entryId": " benedict-account ",
"pageUrl": " https://casino.example.invalid/login ",
},
})
if err != nil {
t.Fatalf("CallTool(get_browser_credential) error = %v", err)
}
got := result.StructuredContent.(map[string]any)
if got["password"] != "loaded-dice" {
t.Errorf("CallTool(get_browser_credential).password = %v, want %q", got["password"], "loaded-dice")
}
if client.credentialID != "benedict-account" {
t.Errorf("CallTool(get_browser_credential) entry id = %q, want %q", client.credentialID, "benedict-account")
}
}
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()
ctx := context.Background()
server := New(vaultClient, Config{GRPCAddress: "unix:///tmp/keepassgo-heist.sock", Version: "test"})
client := mcp.NewClient(&mcp.Implementation{Name: "keepassgo-test"}, nil)
serverTransport, clientTransport := mcp.NewInMemoryTransports()
serverSession, err := server.Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("Server.Connect() error = %v", err)
}
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("Client.Connect() error = %v", err)
}
return clientSession, func() {
clientSession.Close()
serverSession.Close()
}
}
type fakeVaultClient struct {
status *keepassgov1.GetSessionStatusResponse
entries []*keepassgov1.Entry
matches []*keepassgov1.BrowserLoginMatch
credential *keepassgov1.GetBrowserCredentialResponse
listEntriesPath []string
listEntriesQuery string
credentialID string
credentialURL string
}
func (c *fakeVaultClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
if c.status != nil {
return c.status, nil
}
return &keepassgov1.GetSessionStatusResponse{}, nil
}
func (c *fakeVaultClient) FindBrowserLogins(_ context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
return c.matches, nil
}
func (c *fakeVaultClient) ListEntries(_ context.Context, path []string, query string) ([]*keepassgov1.Entry, error) {
c.listEntriesPath = append([]string(nil), path...)
c.listEntriesQuery = query
return c.entries, nil
}
func (c *fakeVaultClient) GetBrowserCredential(_ context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
c.credentialID = entryID
c.credentialURL = pageURL
if c.credential != nil {
return c.credential, nil
}
return &keepassgov1.GetBrowserCredentialResponse{}, nil
}
@@ -43,6 +43,7 @@ build() {
app_version="$(git describe --tags --always --dirty)"
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo-browser-bridge ./cmd/keepassgo-browser-bridge
go build -ldflags "-X main.version=${app_version}" -o keepassgo-mcp-server ./cmd/keepassgo-mcp-server
}
package() {
@@ -50,6 +51,7 @@ package() {
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm755 keepassgo-browser-bridge "${pkgdir}/usr/bin/keepassgo-browser-bridge"
install -Dm755 keepassgo-mcp-server "${pkgdir}/usr/bin/keepassgo-mcp-server"
install -Dm644 browser/extension/README.md \
"${pkgdir}/usr/share/keepassgo/browser-extension/README.md"
install -Dm644 browser/extension/background.js \