Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13eeb3fe4a | |||
| d9a4bc6b14 | |||
| 32f9abe5e2 |
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user