From 32f9abe5e2aa71a75ce6109a74f5ebb67dd5cfaf Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 14 May 2026 08:54:01 -0700 Subject: [PATCH 1/3] Add official MCP server --- .gitignore | 1 + Makefile | 5 +- README.md | 21 +++ cmd/keepassgo-mcp-server/main.go | 65 +++++++++ go.mod | 8 +- go.sum | 14 ++ internal/mcpserver/server.go | 231 ++++++++++++++++++++++++++++++ internal/mcpserver/server_test.go | 183 +++++++++++++++++++++++ 8 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 cmd/keepassgo-mcp-server/main.go create mode 100644 internal/mcpserver/server.go create mode 100644 internal/mcpserver/server_test.go diff --git a/.gitignore b/.gitignore index 9fbfb97..55bfc7e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index 514e7a6..6f07a6e 100644 --- a/Makefile +++ b/Makefile @@ -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)" diff --git a/README.md b/README.md index cb24e6d..37da0c1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/keepassgo-mcp-server/main.go b/cmd/keepassgo-mcp-server/main.go new file mode 100644 index 0000000..e3b137d --- /dev/null +++ b/cmd/keepassgo-mcp-server/main.go @@ -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")) +} diff --git a/go.mod b/go.mod index 09692f2..a71ee37 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 06dc5b8..493df42 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..fa1a33c --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,231 @@ +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_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 +} + +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 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 +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..48507b1 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,183 @@ +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_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 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 +} -- 2.52.0 From d9a4bc6b1431c324fab887becf228ccc0189e5af Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 14 May 2026 08:57:39 -0700 Subject: [PATCH 2/3] Package MCP server binary --- packaging/archlinux/keepassgo-git/PKGBUILD.tmpl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl index 8301fd9..41342d8 100644 --- a/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl +++ b/packaging/archlinux/keepassgo-git/PKGBUILD.tmpl @@ -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 \ -- 2.52.0 From 13eeb3fe4a054d81546b363c8eec9eb829c742b5 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Thu, 14 May 2026 09:06:59 -0700 Subject: [PATCH 3/3] 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() -- 2.52.0