Compare commits

..

6 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
joejulian 11e883279d Merge pull request 'Bump release version to 0.8.2' (#13) from release/v0.8.2 into main
ci / lint-test (push) Successful in 4m36s
ci / build (push) Successful in 5m48s
2026-04-28 04:55:42 +00:00
Joe Julian e305a25802 Bump release version to 0.8.2
ci / lint-test (pull_request) Successful in 5m3s
ci / build (pull_request) Successful in 6m3s
ci / lint-test (push) Successful in 5m3s
ci / build (push) Successful in 5m28s
2026-04-27 21:35:23 -07:00
joejulian 8b4609c141 Merge pull request 'Scope Android autofill candidates' (#12) from bugfix/android-autofill-relevant-picker into main
ci / lint-test (push) Successful in 5m15s
ci / build (push) Successful in 6m41s
Reviewed-on: #12
2026-04-28 04:29:30 +00:00
12 changed files with 657 additions and 6 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
+5 -2
View File
@@ -5,7 +5,7 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
APK_VERSION ?= 0.8.2.298
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
APK_ARCH ?= arm64,amd64
@@ -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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KeePassGO Browser",
"version": "0.1.0",
"version": "0.8.2",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
"host_permissions": ["http://*/*", "https://*/*"],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "KeePassGO Browser",
"version": "0.1.0",
"version": "0.8.2",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"icons": {
"16": "icons/icon-16.png",
+1 -1
View File
@@ -12,7 +12,7 @@ const (
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1"
DefaultVersion = "0.8.2.298"
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
DefaultMinSDK = "28"
DefaultTargetSDK = "35"
+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=
+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 \