From 15ea1e11ab1d9333f039e10fe62eaf12e5c7ac57 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Mon, 13 Apr 2026 18:01:28 +0200 Subject: [PATCH] feat: adopt mcp framework cli flow --- .gitea/workflows/release.yml | 28 +++ Makefile | 3 +- README.md | 147 +++++------ cmd/email-mcp/main.go | 4 +- go.mod | 16 +- go.sum | 44 +++- internal/cli/app.go | 339 ++++++++++++++++++++++--- internal/cli/app_task6_test.go | 77 ------ internal/cli/app_task9_test.go | 17 -- internal/cli/app_test.go | 417 +++++++++++++++++++++++++++---- internal/cli/integration_test.go | 94 ------- internal/cli/setup.go | 158 +++--------- internal/cli/setup_test.go | 139 ++--------- internal/cli/wire.go | 84 +++++-- internal/cli/wire_test.go | 172 ++----------- internal/mcpserver/server.go | 2 +- mcp.toml | 4 + 17 files changed, 966 insertions(+), 779 deletions(-) delete mode 100644 internal/cli/app_task6_test.go delete mode 100644 internal/cli/app_task9_test.go delete mode 100644 internal/cli/integration_test.go create mode 100644 mcp.toml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index aac7d39..f2337ff 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -12,6 +12,7 @@ jobs: env: BINARY_NAME: email-mcp BUILD_PATH: build/email-mcp-linux-amd64 + MANIFEST_PATH: mcp.toml steps: - name: Checkout @@ -137,3 +138,30 @@ jobs: cat asset.json >&2 exit 1 fi + + - name: Upload manifest asset + env: + REPOSITORY: ${{ github.repository }} + API_URL: ${{ github.api_url }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + RELEASE_ID: ${{ steps.release.outputs.release_id }} + run: | + set -euo pipefail + + owner="${REPOSITORY%%/*}" + repo="${REPOSITORY#*/}" + asset_name="$(basename "${MANIFEST_PATH}")" + upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}" + + http_code="$(curl -sS -o asset.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${MANIFEST_PATH}" \ + "${upload_url}")" + + if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then + echo "asset upload failed with status ${http_code}" >&2 + cat asset.json >&2 + exit 1 + fi diff --git a/Makefile b/Makefile index c9c5f03..f6f83ff 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ BINARY_NAME := email-mcp BUILD_DIR := build GOCACHE ?= /tmp/$(BINARY_NAME)-gocache +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) GOOS ?= $(shell go env GOOS) GOARCH ?= $(shell go env GOARCH) @@ -17,7 +18,7 @@ OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT) build: @mkdir -p $(BUILD_DIR) $(GOCACHE) - GOCACHE=$(GOCACHE) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(OUTPUT) ./cmd/email-mcp + GOCACHE=$(GOCACHE) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X main.version=$(VERSION)" -o $(OUTPUT) ./cmd/email-mcp test: @mkdir -p $(GOCACHE) diff --git a/README.md b/README.md index b210bd8..d1cd36a 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,95 @@ # email-mcp -Serveur MCP local pour lire une boîte mail via IMAP. Le serveur parle le protocole MCP standard sur `stdio`, avec des messages **JSON-RPC 2.0** (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`). Le projet expose trois outils : +Serveur MCP local pour lire une boîte mail via IMAP. Le serveur parle le protocole MCP standard sur `stdio`, avec des messages **JSON-RPC 2.0** (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`). -- **`list_mailboxes`** — lister les boîtes IMAP visibles -- **`list_messages`** — lister les messages récents d'une boîte -- **`get_message`** — récupérer un message par UID IMAP +Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour : -Le stockage des credentials repose sur **KDE Wallet** via **D-Bus**. La V1 cible Linux avec session KDE Wallet disponible. +- la gestion de profils CLI +- le stockage JSON de configuration dans `os.UserConfigDir()` +- le stockage du mot de passe dans le wallet natif de l’OS +- le manifeste `mcp.toml` +- l’auto-update via `email-mcp update` -## Sommaire +## Commandes -- [Prérequis](#prérequis) -- [Configuration](#configuration) - - [Étape 1 : enregistrer les credentials](#étape-1--enregistrer-les-credentials) - - [Étape 2 : lancer le serveur MCP](#étape-2--lancer-le-serveur-mcp) -- [Installation](#installation) - - [Claude Code CLI](#claude-code-cli) - - [Configuration JSON manuelle](#configuration-json-manuelle) -- [Compiler depuis les sources](#compiler-depuis-les-sources) -- [Outils](#outils) - - [list_mailboxes](#list_mailboxes) - - [list_messages](#list_messages) - - [get_message](#get_message) +- `email-mcp config` : configure un profil IMAP +- `email-mcp setup` : alias de compatibilité vers `config` +- `email-mcp mcp` : lance le serveur MCP sur `stdin/stdout` +- `email-mcp update` : met à jour le binaire courant depuis la dernière release -## Prérequis +## Outils MCP -- Linux -- une session D-Bus utilisateur active -- KDE Wallet accessible sur cette session -- Go 1.25+ -- un compte IMAP fonctionnel +- `list_mailboxes` : lister les boîtes IMAP visibles +- `list_messages` : lister les messages récents d’une boîte +- `get_message` : récupérer un message par UID IMAP ## Configuration -### Étape 1 : enregistrer les credentials +La configuration est séparée en deux parties : -Le setup est interactif : +- `host` et `username` sont stockés dans `config.json` +- `password` est stocké dans le wallet système + +Le profil actif est résolu dans cet ordre : + +1. `--profile` +2. `EMAIL_MCP_PROFILE` +3. `current_profile` dans `config.json` +4. `default` + +### Configurer un profil ```sh -./email-mcp setup +./email-mcp config +``` + +Pour un profil nommé : + +```sh +./email-mcp config --profile work ``` Le binaire demande ensuite : -1. l'hôte IMAP -2. le nom d'utilisateur +1. l’hôte IMAP +2. le nom d’utilisateur 3. le mot de passe -Les credentials sont stockés dans KDE Wallet sous le profil `default`. +Si un mot de passe existe déjà dans le wallet, laisser le champ vide le conserve. -Si KDE Wallet n'est pas disponible, le setup échoue explicitement et n'écrit rien ailleurs. - -### Étape 2 : lancer le serveur MCP - -Le serveur MCP s'exécute sur `stdin/stdout` avec le handshake MCP standard : +### Lancer le serveur MCP ```sh ./email-mcp mcp ``` -Si aucun credential n'a été configuré, le serveur renvoie l'erreur : +Pour un profil nommé : + +```sh +./email-mcp mcp --profile work +``` + +Si aucun credential n’a été configuré pour le profil résolu, le serveur renvoie l’erreur : ```text -credentials not configured; run `email-mcp setup` +credentials not configured; run `email-mcp config` +``` + +## Auto-update + +`email-mcp update` lit `mcp.toml` depuis le répertoire du binaire courant, puis remonte les répertoires parents. Si aucun manifeste n’est trouvé, la commande échoue. + +```sh +./email-mcp update +``` + +Le manifeste de ce repo pointe vers l’endpoint Gitea : + +```toml +[update] +source_name = "email-mcp releases" +base_url = "https://gitea.lclr.dev" +latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest" ``` ## Installation @@ -75,7 +102,7 @@ Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` : claude mcp add email-mcp -- /absolute/path/to/bin/email-mcp mcp ``` -Le `setup` doit être exécuté une fois séparément avant d'utiliser le serveur. +La configuration se fait une fois séparément via `email-mcp config`. ### Configuration JSON manuelle @@ -96,14 +123,10 @@ Ajoute le bloc suivant à ta configuration MCP (`~/.claude.json` côté utilisat Une release est générée automatiquement quand tu pousses un tag `v*` sur le repo Gitea. -Exemple pour démarrer en `v1.0` : +Les assets publiés sont : -```sh -git tag v1.0 -git push origin v1.0 -``` - -Le workflow build alors `email-mcp` pour `linux/amd64` et joint le binaire `build/email-mcp-linux-amd64` comme asset de la release. +- `build/email-mcp-linux-amd64` +- `mcp.toml` ## Compiler depuis les sources @@ -111,7 +134,7 @@ Le workflow build alors `email-mcp` pour `linux/amd64` et joint le binaire `buil make build ``` -Le binaire est généré dans `build/email-mcp--`. +Le binaire est généré dans `build/email-mcp--` avec une version injectée depuis `git describe`. Pour cross-compiler : @@ -124,35 +147,3 @@ Pour lancer les tests : ```sh make test ``` - -## Outils - -### list_mailboxes - -Liste les boîtes IMAP visibles pour le compte configuré. Aucun paramètre. - -### list_messages - -Liste les messages récents d'une boîte. - -| Paramètre | Type | Description | -|---|---|---| -| `mailbox` | string | **Requis.** Nom de la boîte IMAP | -| `limit` | number | Nombre maximum de messages à retourner. Par défaut : `20` | - -Retourne des résumés de messages avec un `uid` IMAP stable, le sujet et l'expéditeur. - -### get_message - -Récupère un message précis à partir de sa boîte et de son UID IMAP. - -| Paramètre | Type | Description | -|---|---|---| -| `mailbox` | string | **Requis.** Nom de la boîte IMAP | -| `uid` | number | **Requis.** UID IMAP du message | - -Retourne : - -- les métadonnées du message -- les headers dans l'ordre d'origine -- un body texte décodé de manière conservative pour les contenus MIME courants diff --git a/cmd/email-mcp/main.go b/cmd/email-mcp/main.go index 305f179..0e16a83 100644 --- a/cmd/email-mcp/main.go +++ b/cmd/email-mcp/main.go @@ -6,7 +6,9 @@ import ( "email-mcp/internal/cli" ) +var version = "dev" + func main() { - app := cli.BuildApp() + app := cli.BuildApp(version) os.Exit(cli.Execute(app, os.Args[1:], os.Stderr)) } diff --git a/go.mod b/go.mod index 3511edc..b108130 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,24 @@ module email-mcp go 1.25.0 require ( + gitea.lclr.dev/AI/mcp-framework v1.0.1-0.20260413153617-3437d265d4ba github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/emersion/go-message v0.18.2 github.com/godbus/dbus/v5 v5.2.2 ) +replace gitea.lclr.dev/AI/mcp-framework => ../mcp-framework + require ( - github.com/emersion/go-message v0.18.2 // indirect + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/99designs/keyring v1.2.2 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/danieljoos/wincred v1.1.2 // indirect + github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - golang.org/x/sys v0.27.0 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect + github.com/mtibben/percent v0.2.1 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index 3e15389..be36d24 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,42 @@ +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= +github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= +github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -21,14 +52,17 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -39,3 +73,9 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/app.go b/internal/cli/app.go index bd8c6e5..9b83a9e 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -3,83 +3,359 @@ package cli import ( "context" "errors" + "flag" "fmt" "io" + "os" + "path/filepath" + "strings" + + frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli" + frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" + frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" + frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update" "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" - "email-mcp/internal/secretstore/kwallet" +) + +const ( + binaryName = "email-mcp" + defaultProfileEnv = "EMAIL_MCP_PROFILE" ) type MCPRunner interface { Run(ctx context.Context) error } +type ConfigPrompter interface { + PromptCredential(ctx context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) +} + +type profileConfigStore interface { + LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error) + SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error) +} + +type secretStore interface { + SetSecret(name, label, secret string) error + GetSecret(name string) (string, error) + DeleteSecret(name string) error +} + +type manifestLoader func(startDir string) (frameworkmanifest.File, string, error) +type executableResolver func() (string, error) + +type ProfileConfig struct { + Host string `json:"host"` + Username string `json:"username"` +} + type App struct { - prompter SetupPrompter - store secretstore.Store - runner MCPRunner - stderr io.Writer + prompter ConfigPrompter + configStore profileConfigStore + openSecretStore func() (secretStore, error) + newMailService func() mcpserver.MailService + newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner + loadManifest manifestLoader + resolveExecutable executableResolver + stdin io.Reader + stdout io.Writer + stderr io.Writer + version string } func NewApp() *App { - return BuildApp() + return BuildApp("dev") } -func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App { +func NewAppWithDependencies( + prompter ConfigPrompter, + configStore profileConfigStore, + openSecretStore func() (secretStore, error), + newMailService func() mcpserver.MailService, + newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner, + loadManifest manifestLoader, + resolveExecutable executableResolver, + stdin io.Reader, + stdout io.Writer, + stderr io.Writer, + version string, +) *App { + if stdin == nil { + stdin = strings.NewReader("") + } + if stdout == nil { + stdout = io.Discard + } if stderr == nil { stderr = io.Discard } + if version == "" { + version = "dev" + } return &App{ - prompter: prompter, - store: store, - runner: runner, - stderr: stderr, + prompter: prompter, + configStore: configStore, + openSecretStore: openSecretStore, + newMailService: newMailService, + newRunner: newRunner, + loadManifest: loadManifest, + resolveExecutable: resolveExecutable, + stdin: stdin, + stdout: stdout, + stderr: stderr, + version: version, } } func (a *App) Run(args []string) error { if len(args) == 0 { - return fmt.Errorf("usage: email-mcp ") + return fmt.Errorf("usage: email-mcp ") } switch args[0] { - case "setup": - return a.runSetup(context.Background()) + case "config", "setup": + return a.runConfig(context.Background(), args[0], args[1:]) case "mcp": - return a.runMCP(context.Background()) + return a.runMCP(context.Background(), args[1:]) + case "update": + return a.runUpdate(context.Background(), args[1:]) default: return fmt.Errorf("unknown command: %s", args[0]) } } -func (a *App) runSetup(ctx context.Context) error { +func (a *App) runConfig(ctx context.Context, command string, args []string) error { if a.prompter == nil { - return fmt.Errorf("setup prompter is not configured") + return fmt.Errorf("config prompter is not configured") } - if a.store == nil { + if a.configStore == nil { + return fmt.Errorf("config store is not configured") + } + if a.openSecretStore == nil { return fmt.Errorf("secret store is not configured") } - cred, err := a.prompter.PromptSetup(ctx) + profileFlag, err := parseProfileArgs(command, args) + if err != nil { + return err + } + + cfg, _, err := a.configStore.LoadDefault() + if err != nil { + return err + } + + profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile) + profile := cfg.Profiles[profileName] + + secrets, err := a.openSecretStore() + if err != nil { + return mapAppError(err) + } + + storedPassword, hasStoredPassword, err := loadStoredPassword(secrets, profileName) + if err != nil { + return mapAppError(err) + } + + cred, err := a.prompter.PromptCredential(ctx, secretstore.Credential{ + Host: profile.Host, + Username: profile.Username, + Password: storedPassword, + }, hasStoredPassword) if err != nil { return err } if err := cred.Validate(); err != nil { return err } - if err := a.store.Save(ctx, secretstore.DefaultAccountKey, cred); err != nil { + + if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil { return mapAppError(err) } + + if cfg.Profiles == nil { + cfg.Profiles = map[string]ProfileConfig{} + } + cfg.CurrentProfile = profileName + cfg.Profiles[profileName] = ProfileConfig{ + Host: cred.Host, + Username: cred.Username, + } + configPath, err := a.configStore.SaveDefault(cfg) + if err != nil { + return err + } + + fmt.Fprintf(a.stdout, "profile %q saved to %s\n", profileName, configPath) return nil } -func (a *App) runMCP(ctx context.Context) error { - if a.runner == nil { +func (a *App) runMCP(ctx context.Context, args []string) error { + if a.newRunner == nil { return fmt.Errorf("mcp runner is not configured") } - return mapAppError(a.runner.Run(ctx)) + if a.newMailService == nil { + return fmt.Errorf("mail service is not configured") + } + + profileFlag, err := parseProfileArgs("mcp", args) + if err != nil { + return err + } + + cred, err := a.loadCredential(profileFlag) + if err != nil { + return mapAppError(err) + } + + runner := a.newRunner(cred, a.newMailService(), a.stdin, a.stdout, a.stderr) + if runner == nil { + return fmt.Errorf("mcp runner is not configured") + } + + return mapAppError(runner.Run(ctx)) +} + +func (a *App) runUpdate(ctx context.Context, args []string) error { + if a.loadManifest == nil { + return fmt.Errorf("manifest loader is not configured") + } + if a.resolveExecutable == nil { + return fmt.Errorf("executable resolver is not configured") + } + + if err := parseUpdateArgs(args); err != nil { + return err + } + + executablePath, err := a.resolveExecutable() + if err != nil { + return fmt.Errorf("resolve executable path: %w", err) + } + + manifestFile, err := a.loadManifestForExecutable(executablePath) + if err != nil { + return err + } + + return frameworkupdate.Run(ctx, frameworkupdate.Options{ + CurrentVersion: a.version, + ExecutablePath: executablePath, + BinaryName: binaryName, + ReleaseSource: manifestFile.Update.ReleaseSource(), + Stdout: a.stdout, + }) +} + +func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) { + searchDirs := []string{filepath.Dir(executablePath), "."} + var firstErr error + + for _, dir := range searchDirs { + file, _, err := a.loadManifest(dir) + if err == nil { + return file, nil + } + if firstErr == nil { + firstErr = err + } + } + + return frameworkmanifest.File{}, fmt.Errorf("load manifest: %w", firstErr) +} + +func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error) { + if a.configStore == nil { + return secretstore.Credential{}, fmt.Errorf("config store is not configured") + } + if a.openSecretStore == nil { + return secretstore.Credential{}, fmt.Errorf("secret store is not configured") + } + + cfg, _, err := a.configStore.LoadDefault() + if err != nil { + return secretstore.Credential{}, err + } + + profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile) + profile, ok := cfg.Profiles[profileName] + if !ok { + return secretstore.Credential{}, fmt.Errorf("%w: profile %q", mcpserver.ErrCredentialsNotConfigured, profileName) + } + + secrets, err := a.openSecretStore() + if err != nil { + return secretstore.Credential{}, err + } + + password, _, err := loadStoredPassword(secrets, profileName) + if err != nil { + if errors.Is(err, frameworksecretstore.ErrNotFound) { + return secretstore.Credential{}, fmt.Errorf("%w: profile %q", mcpserver.ErrCredentialsNotConfigured, profileName) + } + return secretstore.Credential{}, err + } + + cred := secretstore.Credential{ + Host: profile.Host, + Username: profile.Username, + Password: password, + } + if err := cred.Validate(); err != nil { + return secretstore.Credential{}, fmt.Errorf("%w: profile %q is incomplete", mcpserver.ErrCredentialsNotConfigured, profileName) + } + + return cred, nil +} + +func loadStoredPassword(store secretStore, profileName string) (string, bool, error) { + password, err := store.GetSecret(passwordSecretName(profileName)) + if err != nil { + if errors.Is(err, frameworksecretstore.ErrNotFound) { + return "", false, nil + } + return "", false, err + } + + return password, true, nil +} + +func passwordSecretName(profileName string) string { + return "imap-password/" + strings.TrimSpace(profileName) +} + +func parseProfileArgs(command string, args []string) (string, error) { + flagSet := flag.NewFlagSet(command, flag.ContinueOnError) + flagSet.SetOutput(io.Discard) + + profile := flagSet.String("profile", "", "") + if err := flagSet.Parse(args); err != nil { + return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command) + } + if flagSet.NArg() != 0 { + return "", fmt.Errorf("usage: email-mcp %s [--profile NAME]", command) + } + + return strings.TrimSpace(*profile), nil +} + +func parseUpdateArgs(args []string) error { + flagSet := flag.NewFlagSet("update", flag.ContinueOnError) + flagSet.SetOutput(io.Discard) + + if err := flagSet.Parse(args); err != nil { + return fmt.Errorf("usage: email-mcp update") + } + if flagSet.NArg() != 0 { + return fmt.Errorf("usage: email-mcp update") + } + + return nil } func mapAppError(err error) error { @@ -89,15 +365,14 @@ func mapAppError(err error) error { switch { case errors.Is(err, mcpserver.ErrCredentialsNotConfigured): - return newUserFacingError("credentials not configured; run `email-mcp setup`", err) - case errors.Is(err, kwallet.ErrKWalletUnavailable): - return newUserFacingError("kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running", err) - case errors.Is(err, kwallet.ErrKWalletDisabled): - return newUserFacingError("kwallet is disabled in this KDE session", err) - case errors.Is(err, kwallet.ErrKWalletOpenFailed): - return newUserFacingError("kwallet could not be opened; unlock the wallet and try again", err) - case errors.Is(err, kwallet.ErrCredentialNotFound): - return newUserFacingError("credentials not configured; run `email-mcp setup`", err) + return newUserFacingError("credentials not configured; run `email-mcp config`", err) + case errors.Is(err, frameworksecretstore.ErrBackendUnavailable): + return newUserFacingError( + fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()), + err, + ) + case errors.Is(err, frameworksecretstore.ErrReadOnly): + return newUserFacingError("secret backend is read-only", err) default: return err } diff --git a/internal/cli/app_task6_test.go b/internal/cli/app_task6_test.go deleted file mode 100644 index 4a3c452..0000000 --- a/internal/cli/app_task6_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "errors" - "fmt" - "strings" - "testing" - - "email-mcp/internal/secretstore" - "email-mcp/internal/secretstore/kwallet" -) - -type errorStoreStub struct { - err error -} - -func (s errorStoreStub) Save(context.Context, string, secretstore.Credential) error { - return s.err -} - -func (s errorStoreStub) Load(context.Context, string) (secretstore.Credential, error) { - return secretstore.Credential{}, s.err -} - -func TestAppRunSetupMapsUnavailableWalletError(t *testing.T) { - app := NewAppWithDependencies(&promptStub{ - credential: secretstore.Credential{ - Host: "imap.example.com", - Username: "alice", - Password: "secret", - }, - }, errorStoreStub{ - err: fmt.Errorf("%w: session bus missing", kwallet.ErrKWalletUnavailable), - }, nil, &bytes.Buffer{}) - - err := app.Run([]string{"setup"}) - if err == nil { - t.Fatal("expected setup to fail") - } - if !strings.Contains(strings.ToLower(err.Error()), "kwallet is not available") { - t.Fatalf("expected mapped kwallet error, got %v", err) - } -} - -func TestMapAppErrorMapsMissingCredentialError(t *testing.T) { - err := mapAppError(fmt.Errorf("%w: missing entry", kwallet.ErrCredentialNotFound)) - if err == nil { - t.Fatal("expected mapped error") - } - if !strings.Contains(err.Error(), "run `email-mcp setup`") { - t.Fatalf("expected setup guidance, got %v", err) - } - if !errors.Is(err, kwallet.ErrCredentialNotFound) { - t.Fatalf("expected typed error to be preserved, got %v", err) - } -} - -func TestMapAppErrorPreservesUnavailableTypedError(t *testing.T) { - err := mapAppError(fmt.Errorf("%w: session bus missing", kwallet.ErrKWalletUnavailable)) - if err == nil { - t.Fatal("expected mapped error") - } - if !errors.Is(err, kwallet.ErrKWalletUnavailable) { - t.Fatalf("expected typed error to be preserved, got %v", err) - } -} - -func TestMapAppErrorLeavesUnknownErrorsUntouched(t *testing.T) { - wantErr := errors.New("boom") - - err := mapAppError(wantErr) - if !errors.Is(err, wantErr) { - t.Fatalf("expected original error, got %v", err) - } -} diff --git a/internal/cli/app_task9_test.go b/internal/cli/app_task9_test.go deleted file mode 100644 index 0c96d30..0000000 --- a/internal/cli/app_task9_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package cli - -import ( - "testing" - - "email-mcp/internal/mcpserver" -) - -func TestMapAppErrorMapsMCPMissingCredentialError(t *testing.T) { - err := mapAppError(mcpserver.ErrCredentialsNotConfigured) - if err == nil { - t.Fatal("expected mapped error") - } - if err.Error() != "credentials not configured; run `email-mcp setup`" { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 195904f..0f13b83 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -4,21 +4,33 @@ import ( "bytes" "context" "errors" + "fmt" + "io" + "os" + "path/filepath" "strings" "testing" + frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" + frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" + + "email-mcp/internal/imapclient" + "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" ) var _ func() *App = NewApp -type promptStub struct { +type configPrompterStub struct { credential secretstore.Credential err error called bool + existing secretstore.Credential + hasStored bool } -func (p *promptStub) PromptSetup(context.Context) (secretstore.Credential, error) { +func (p *configPrompterStub) PromptCredential(context.Context, secretstore.Credential, bool) (secretstore.Credential, error) { p.called = true if p.err != nil { return secretstore.Credential{}, p.err @@ -26,22 +38,88 @@ func (p *promptStub) PromptSetup(context.Context) (secretstore.Credential, error return p.credential, nil } -type storeStub struct { - saved secretstore.Credential - savedKey string +type capturingPrompterStub struct { + credential secretstore.Credential + existing secretstore.Credential + hasStored bool +} + +func (p *capturingPrompterStub) PromptCredential(_ context.Context, existing secretstore.Credential, hasStored bool) (secretstore.Credential, error) { + p.existing = existing + p.hasStored = hasStored + return p.credential, nil +} + +type configStoreStub struct { + cfg frameworkconfig.FileConfig[ProfileConfig] + loadErr error saveErr error + saved frameworkconfig.FileConfig[ProfileConfig] saveCalled bool + configPath string } -func (s *storeStub) Save(_ context.Context, key string, cred secretstore.Credential) error { +func (s *configStoreStub) LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error) { + if s.loadErr != nil { + return frameworkconfig.FileConfig[ProfileConfig]{}, "", s.loadErr + } + path := s.configPath + if path == "" { + path = "/tmp/email-mcp/config.json" + } + return s.cfg, path, nil +} + +func (s *configStoreStub) SaveDefault(cfg frameworkconfig.FileConfig[ProfileConfig]) (string, error) { s.saveCalled = true - s.savedKey = key - s.saved = cred - return s.saveErr + s.saved = cfg + if s.saveErr != nil { + return "", s.saveErr + } + path := s.configPath + if path == "" { + path = "/tmp/email-mcp/config.json" + } + return path, nil } -func (s *storeStub) Load(context.Context, string) (secretstore.Credential, error) { - return secretstore.Credential{}, nil +type secretStoreStub struct { + values map[string]string + setErr error + getErr error + setName string + setValue string + setCalled bool +} + +func (s *secretStoreStub) SetSecret(name, _ string, secret string) error { + s.setCalled = true + s.setName = name + s.setValue = secret + if s.setErr != nil { + return s.setErr + } + if s.values == nil { + s.values = map[string]string{} + } + s.values[name] = secret + return nil +} + +func (s *secretStoreStub) GetSecret(name string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + value, ok := s.values[name] + if !ok { + return "", frameworksecretstore.ErrNotFound + } + return value, nil +} + +func (s *secretStoreStub) DeleteSecret(name string) error { + delete(s.values, name) + return nil } type runnerStub struct { @@ -55,7 +133,7 @@ func (r *runnerStub) Run(context.Context) error { } func TestAppRunRejectsUnknownCommand(t *testing.T) { - app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{}) + app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") err := app.Run([]string{"unknown"}) if err == nil { @@ -64,7 +142,7 @@ func TestAppRunRejectsUnknownCommand(t *testing.T) { } func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) { - app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{}) + app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") err := app.Run(nil) if err == nil { @@ -75,87 +153,246 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) { } } -func TestAppRunSetupPromptsAndSavesDefaultCredential(t *testing.T) { - store := &storeStub{} - prompter := &promptStub{ +func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) { + prompter := &configPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } - app := NewAppWithDependencies(prompter, store, nil, &bytes.Buffer{}) + cfgStore := &configStoreStub{} + secrets := &secretStoreStub{} + output := &bytes.Buffer{} - if err := app.Run([]string{"setup"}); err != nil { + app := NewAppWithDependencies( + prompter, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + nil, + nil, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"config"}); err != nil { t.Fatalf("Run returned error: %v", err) } if !prompter.called { - t.Fatal("expected setup prompter to be called") + t.Fatal("expected config prompter to be called") } - if !store.saveCalled { - t.Fatal("expected store Save to be called") + if !secrets.setCalled { + t.Fatal("expected password to be stored") } - if store.savedKey != secretstore.DefaultAccountKey { - t.Fatalf("expected saved key %q, got %q", secretstore.DefaultAccountKey, store.savedKey) + if secrets.setName != "imap-password/default" { + t.Fatalf("unexpected secret name %q", secrets.setName) } - if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" { - t.Fatalf("unexpected saved credential: %#v", store.saved) + if !cfgStore.saveCalled { + t.Fatal("expected config to be saved") + } + if cfgStore.saved.CurrentProfile != "default" { + t.Fatalf("current profile = %q, want default", cfgStore.saved.CurrentProfile) + } + if cfgStore.saved.Profiles["default"].Host != "imap.example.com" { + t.Fatalf("unexpected saved profile: %#v", cfgStore.saved.Profiles["default"]) + } + if got := output.String(); !strings.Contains(got, `profile "default" saved`) { + t.Fatalf("unexpected output %q", got) } } -func TestAppRunSetupRejectsInvalidCredential(t *testing.T) { - store := &storeStub{} - app := NewAppWithDependencies(&promptStub{ +func TestAppRunSetupAliasesConfig(t *testing.T) { + prompter := &configPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", + Password: "secret", }, - }, store, nil, &bytes.Buffer{}) + } + cfgStore := &configStoreStub{} + secrets := &secretStoreStub{} - err := app.Run([]string{"setup"}) - if err == nil { - t.Fatal("expected setup to fail for invalid credential") + app := NewAppWithDependencies( + prompter, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + nil, + nil, + nil, + io.Discard, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"setup"}); err != nil { + t.Fatalf("setup returned error: %v", err) } - if !strings.Contains(err.Error(), "password is required") { - t.Fatalf("expected validation error, got %v", err) - } - if store.saveCalled { - t.Fatal("expected store Save not to be called when credential is invalid") + if !cfgStore.saveCalled { + t.Fatal("expected setup to save config via config command") } } -func TestAppRunReturnsPromptError(t *testing.T) { - expectedErr := errors.New("prompt failed") - app := NewAppWithDependencies(&promptStub{err: expectedErr}, &storeStub{}, nil, &bytes.Buffer{}) +func TestAppRunConfigUsesStoredValuesAsDefaults(t *testing.T) { + prompter := &capturingPrompterStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "updated-secret", + }, + } + cfgStore := &configStoreStub{ + cfg: frameworkconfig.FileConfig[ProfileConfig]{ + Version: frameworkconfig.CurrentVersion, + CurrentProfile: "work", + Profiles: map[string]ProfileConfig{ + "work": { + Host: "imap.example.com", + Username: "alice", + }, + }, + }, + } + secrets := &secretStoreStub{ + values: map[string]string{ + "imap-password/work": "stored-secret", + }, + } - err := app.Run([]string{"setup"}) - if !errors.Is(err, expectedErr) { - t.Fatalf("expected prompt error %v, got %v", expectedErr, err) + app := NewAppWithDependencies( + prompter, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + nil, + nil, + nil, + io.Discard, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"config"}); err != nil { + t.Fatalf("config returned error: %v", err) + } + if !prompter.hasStored { + t.Fatal("expected stored password to be reported") + } + if prompter.existing.Password != "stored-secret" { + t.Fatalf("expected existing password to be forwarded, got %q", prompter.existing.Password) } } -func TestAppRunMCPDelegatesToRunner(t *testing.T) { +func TestAppRunMCPDelegatesResolvedCredentialToRunner(t *testing.T) { + cfgStore := &configStoreStub{ + cfg: frameworkconfig.FileConfig[ProfileConfig]{ + Version: frameworkconfig.CurrentVersion, + CurrentProfile: "work", + Profiles: map[string]ProfileConfig{ + "work": { + Host: "imap.example.com", + Username: "alice", + }, + }, + }, + } + secrets := &secretStoreStub{ + values: map[string]string{ + "imap-password/work": "secret", + }, + } runner := &runnerStub{} - app := NewAppWithDependencies(nil, nil, runner, &bytes.Buffer{}) + var gotCredential secretstore.Credential + var gotMailService mcpserver.MailService + + app := NewAppWithDependencies( + nil, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + func() mcpserver.MailService { return wireMailServiceStub{} }, + func(cred secretstore.Credential, mail mcpserver.MailService, _ io.Reader, _ io.Writer, _ io.Writer) MCPRunner { + gotCredential = cred + gotMailService = mail + return runner + }, + nil, + nil, + nil, + nil, + &bytes.Buffer{}, + "dev", + ) if err := app.Run([]string{"mcp"}); err != nil { - t.Fatalf("expected mcp command to succeed, got %v", err) + t.Fatalf("mcp returned error: %v", err) } if !runner.called { - t.Fatal("expected MCP runner to be called") + t.Fatal("expected runner to be called") + } + if gotCredential.Password != "secret" || gotCredential.Username != "alice" { + t.Fatalf("unexpected credential %#v", gotCredential) + } + if gotMailService == nil { + t.Fatal("expected mail service to be built") + } +} + +func TestAppRunUpdateLoadsManifestNearExecutable(t *testing.T) { + tempDir := t.TempDir() + executablePath := filepath.Join(tempDir, "email-mcp") + if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + if err := os.WriteFile(filepath.Join(tempDir, "mcp.toml"), []byte(` +[update] +source_name = "test" +base_url = "http://127.0.0.1:1" +latest_release_url = "http://127.0.0.1:1/releases/latest" +`), 0o600); err != nil { + t.Fatalf("WriteFile manifest returned error: %v", err) + } + + client := &bytes.Buffer{} + app := NewAppWithDependencies( + nil, + nil, + nil, + nil, + nil, + frameworkmanifest.LoadDefault, + func() (string, error) { return executablePath, nil }, + nil, + client, + &bytes.Buffer{}, + "dev", + ) + + err := app.Run([]string{"update"}) + if err == nil { + t.Fatal("expected update to fail without a reachable release endpoint") + } + if !strings.Contains(err.Error(), "fetch latest release metadata") { + t.Fatalf("unexpected error: %v", err) } } func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { - app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{}) + app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") tests := []struct { command string want string }{ - {command: "setup", want: "setup prompter is not configured"}, + {command: "config", want: "config prompter is not configured"}, {command: "mcp", want: "mcp runner is not configured"}, + {command: "update", want: "manifest loader is not configured"}, } for _, tt := range tests { @@ -171,18 +408,90 @@ func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { } } +func TestMapAppErrorMapsMissingCredentialError(t *testing.T) { + err := mapAppError(fmt.Errorf("%w: missing profile", mcpserver.ErrCredentialsNotConfigured)) + if err == nil { + t.Fatal("expected mapped error") + } + if !strings.Contains(err.Error(), "run `email-mcp config`") { + t.Fatalf("expected config guidance, got %v", err) + } + if !errors.Is(err, mcpserver.ErrCredentialsNotConfigured) { + t.Fatalf("expected typed error to be preserved, got %v", err) + } +} + +func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) { + err := mapAppError(&frameworksecretstore.BackendUnavailableError{ + Policy: frameworksecretstore.BackendAuto, + Required: "any keyring backend", + }) + if err == nil { + t.Fatal("expected mapped error") + } + if !strings.Contains(strings.ToLower(err.Error()), "wallet") { + t.Fatalf("expected wallet guidance, got %v", err) + } +} + +func TestExecuteConfigWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) { + app := NewAppWithDependencies( + &configPrompterStub{}, + &configStoreStub{}, + func() (secretStore, error) { + return nil, &frameworksecretstore.BackendUnavailableError{ + Policy: frameworksecretstore.BackendAuto, + Required: "any keyring backend", + } + }, + nil, + nil, + nil, + nil, + nil, + nil, + &bytes.Buffer{}, + "dev", + ) + + stderr := &bytes.Buffer{} + if code := Execute(app, []string{"config"}, stderr); code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } + if got := strings.ToLower(stderr.String()); !strings.Contains(got, "wallet") { + t.Fatalf("unexpected stderr: %q", got) + } +} + func TestNewAppBuildsProductionDependencies(t *testing.T) { app := NewApp() if app == nil { t.Fatal("expected app instance") } if app.prompter == nil { - t.Fatal("expected setup prompter to be configured") + t.Fatal("expected config prompter to be configured") } - if app.store == nil { - t.Fatal("expected secret store to be configured") + if app.configStore == nil { + t.Fatal("expected config store to be configured") } - if app.runner == nil { - t.Fatal("expected MCP runner to be configured") + if app.openSecretStore == nil { + t.Fatal("expected secret store opener to be configured") + } + if app.newRunner == nil { + t.Fatal("expected MCP runner factory to be configured") } } + +type wireMailServiceStub struct{} + +func (wireMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { + return nil, nil +} + +func (wireMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { + return nil, nil +} + +func (wireMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { + return imapclient.Message{}, nil +} diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go deleted file mode 100644 index 5824782..0000000 --- a/internal/cli/integration_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "testing" - - "email-mcp/internal/imapclient" - "email-mcp/internal/mcpserver" - "email-mcp/internal/secretstore" - "email-mcp/internal/secretstore/kwallet" -) - -type entrypointPromptStub struct { - credential secretstore.Credential -} - -func (p *entrypointPromptStub) PromptSetup(context.Context) (secretstore.Credential, error) { - return p.credential, nil -} - -type entrypointStoreStub struct { - saveErr error - loadErr error -} - -func (s *entrypointStoreStub) Save(context.Context, string, secretstore.Credential) error { - return s.saveErr -} - -func (s *entrypointStoreStub) Load(context.Context, string) (secretstore.Credential, error) { - return secretstore.Credential{}, s.loadErr -} - -type entrypointMailServiceStub struct{} - -func (entrypointMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { - return nil, nil -} - -func (entrypointMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { - return nil, nil -} - -func (entrypointMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { - return imapclient.Message{}, nil -} - -func TestExecuteSetupWritesWalletGuidanceAndReturnsExitCodeOne(t *testing.T) { - app := NewAppWithDependencies( - &entrypointPromptStub{ - credential: secretstore.Credential{ - Host: "imap.example.com", - Username: "alice", - Password: "secret", - }, - }, - &entrypointStoreStub{saveErr: kwallet.ErrKWalletUnavailable}, - nil, - nil, - ) - - stderr := &bytes.Buffer{} - if code := Execute(app, []string{"setup"}, stderr); code != 1 { - t.Fatalf("expected exit code 1, got %d", code) - } - if got := stderr.String(); got != "kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running\n" { - t.Fatalf("unexpected stderr: %q", got) - } -} - -func TestExecuteMCPReturnsMissingCredentialErrorOnToolCall(t *testing.T) { - store := &entrypointStoreStub{loadErr: kwallet.ErrCredentialNotFound} - mail := entrypointMailServiceStub{} - input := bytes.NewBufferString( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0.0\"}}}\n" + - "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + - "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"list_mailboxes\"}}\n", - ) - output := &bytes.Buffer{} - runner := mcpserver.NewRunner(mcpserver.New(store, mail), input, output, &bytes.Buffer{}) - app := NewAppWithDependencies(nil, store, runner, nil) - - stderr := &bytes.Buffer{} - if code := Execute(app, []string{"mcp"}, stderr); code != 0 { - t.Fatalf("expected exit code 0, got %d; stderr: %s", code, stderr.String()) - } - - // Verify the credential error appears in the tool call response - got := output.String() - if !bytes.Contains([]byte(got), []byte("credentials not configured")) { - t.Fatalf("expected credential error in output, got %q", got) - } -} diff --git a/internal/cli/setup.go b/internal/cli/setup.go index ba402bf..1737070 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -7,40 +7,19 @@ import ( "io" "os" "strings" - "syscall" - "unsafe" + + frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli" "email-mcp/internal/secretstore" ) -type SetupPrompter interface { - PromptSetup(ctx context.Context) (secretstore.Credential, error) +type InteractiveConfigPrompter struct { + reader *bufio.Reader + stdinFile *os.File + output io.Writer } -type PasswordReader interface { - ReadPassword(prompt string) (string, error) -} - -type InteractiveSetupPrompter struct { - input *bufio.Reader - rawInput io.Reader - output io.Writer - passwordReader PasswordReader -} - -type terminalPasswordReader struct { - input io.Reader - output io.Writer - isTerminal func(io.Reader) bool - readHiddenPassword func(*os.File) ([]byte, error) - readFallbackPassword func(string) (string, error) -} - -func NewInteractiveSetupPrompter(input io.Reader, output io.Writer) *InteractiveSetupPrompter { - return NewInteractiveSetupPrompterWithPasswordReader(input, output, nil) -} - -func NewInteractiveSetupPrompterWithPasswordReader(input io.Reader, output io.Writer, passwordReader PasswordReader) *InteractiveSetupPrompter { +func NewInteractiveConfigPrompter(input io.Reader, output io.Writer) *InteractiveConfigPrompter { if input == nil { input = strings.NewReader("") } @@ -48,29 +27,29 @@ func NewInteractiveSetupPrompterWithPasswordReader(input io.Reader, output io.Wr output = io.Discard } - prompter := &InteractiveSetupPrompter{ - input: bufio.NewReader(input), - rawInput: input, - output: output, + prompter := &InteractiveConfigPrompter{ + reader: bufio.NewReader(input), + output: output, } - if passwordReader == nil { - passwordReader = newTerminalPasswordReader(input, output, prompter.prompt) + if file, ok := input.(*os.File); ok { + prompter.stdinFile = file } - prompter.passwordReader = passwordReader return prompter } -func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Credential, error) { - host, err := p.prompt("IMAP host: ") +func (p *InteractiveConfigPrompter) PromptCredential(_ context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) { + host, err := frameworkcli.PromptLine(p.reader, p.output, "IMAP host", existing.Host) if err != nil { return secretstore.Credential{}, err } - username, err := p.prompt("Username: ") + + username, err := frameworkcli.PromptLine(p.reader, p.output, "Username", existing.Username) if err != nil { return secretstore.Credential{}, err } - password, err := p.passwordReader.ReadPassword("Password: ") + + password, err := p.promptPassword(existing.Password, hasStoredPassword) if err != nil { return secretstore.Credential{}, err } @@ -83,101 +62,30 @@ func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Cre if err := cred.Validate(); err != nil { return secretstore.Credential{}, err } + return cred, nil } -func (p *InteractiveSetupPrompter) prompt(label string) (string, error) { - if _, err := fmt.Fprint(p.output, label); err != nil { - return "", err +func (p *InteractiveConfigPrompter) promptPassword(storedPassword string, hasStoredPassword bool) (string, error) { + if p.stdinFile != nil { + return frameworkcli.PromptSecret(p.stdinFile, p.output, "Password", hasStoredPassword, storedPassword) } - value, err := p.input.ReadString('\n') + if hasStoredPassword { + fmt.Fprint(p.output, "Password [stored, leave blank to keep]: ") + } else { + fmt.Fprint(p.output, "Password: ") + } + + line, err := p.reader.ReadString('\n') if err != nil && err != io.EOF { return "", err } - return strings.TrimSpace(value), nil -} -func newTerminalPasswordReader(input io.Reader, output io.Writer, fallback func(string) (string, error)) PasswordReader { - return terminalPasswordReader{ - input: input, - output: output, - isTerminal: isTerminalReader, - readHiddenPassword: readHiddenPassword, - readFallbackPassword: fallback, - } -} - -func (r terminalPasswordReader) ReadPassword(prompt string) (string, error) { - if r.isTerminal != nil && r.isTerminal(r.input) { - file, ok := r.input.(*os.File) - if ok { - if _, err := fmt.Fprint(r.output, prompt); err != nil { - return "", err - } - password, err := r.readHiddenPassword(file) - if _, printErr := fmt.Fprintln(r.output); err == nil && printErr != nil { - return "", printErr - } - if err != nil { - return "", err - } - return strings.TrimSpace(string(password)), nil - } + password := strings.TrimSpace(line) + if password == "" && hasStoredPassword { + return storedPassword, nil } - return r.readFallbackPassword(prompt) -} - -func isTerminalReader(reader io.Reader) bool { - file, ok := reader.(*os.File) - if !ok { - return false - } - return isTerminalFile(file) -} - -func isTerminalFile(file *os.File) bool { - _, err := getTermios(file.Fd()) - return err == nil -} - -func readHiddenPassword(file *os.File) ([]byte, error) { - state, err := getTermios(file.Fd()) - if err != nil { - return nil, err - } - - next := *state - next.Lflag &^= syscall.ECHO - if err := setTermios(file.Fd(), &next); err != nil { - return nil, err - } - defer func() { - _ = setTermios(file.Fd(), state) - }() - - reader := bufio.NewReader(file) - value, err := reader.ReadBytes('\n') - if err != nil && err != io.EOF { - return nil, err - } - return []byte(strings.TrimRight(string(value), "\r\n")), nil -} - -func getTermios(fd uintptr) (*syscall.Termios, error) { - state := &syscall.Termios{} - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(state))) - if errno != 0 { - return nil, errno - } - return state, nil -} - -func setTermios(fd uintptr, state *syscall.Termios) error { - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCSETS), uintptr(unsafe.Pointer(state))) - if errno != 0 { - return errno - } - return nil + return password, nil } diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index 2073e4d..be50882 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -3,21 +3,20 @@ package cli import ( "bytes" "context" - "errors" - "io" - "os" "strings" "testing" + + "email-mcp/internal/secretstore" ) -func TestInteractiveSetupPrompterPromptSetupCollectsCredential(t *testing.T) { - input := strings.NewReader(" imap.example.com \n alice \n secret \n") +func TestInteractiveConfigPrompterPromptCredentialCollectsValues(t *testing.T) { + input := strings.NewReader("imap.example.com\nalice\nsecret\n") output := &bytes.Buffer{} - prompter := NewInteractiveSetupPrompter(input, output) + prompter := NewInteractiveConfigPrompter(input, output) - cred, err := prompter.PromptSetup(context.Background()) + cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{}, false) if err != nil { - t.Fatalf("PromptSetup returned error: %v", err) + t.Fatalf("PromptCredential returned error: %v", err) } if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" { @@ -28,122 +27,24 @@ func TestInteractiveSetupPrompterPromptSetupCollectsCredential(t *testing.T) { } } -func TestInteractiveSetupPrompterPromptSetupUsesPasswordReader(t *testing.T) { +func TestInteractiveConfigPrompterPromptCredentialKeepsStoredPassword(t *testing.T) { + input := strings.NewReader("imap.example.com\nalice\n\n") output := &bytes.Buffer{} - passwordReader := &passwordReaderStub{password: "secret"} - prompter := NewInteractiveSetupPrompterWithPasswordReader( - strings.NewReader("imap.example.com\nalice\n"), - output, - passwordReader, - ) + prompter := NewInteractiveConfigPrompter(input, output) - cred, err := prompter.PromptSetup(context.Background()) + cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "stored-secret", + }, true) if err != nil { - t.Fatalf("PromptSetup returned error: %v", err) + t.Fatalf("PromptCredential returned error: %v", err) } - if cred.Password != "secret" { - t.Fatalf("expected password from password reader, got %#v", cred) + if cred.Password != "stored-secret" { + t.Fatalf("expected stored password to be preserved, got %q", cred.Password) } - if passwordReader.prompt != "Password: " { - t.Fatalf("unexpected password prompt %q", passwordReader.prompt) + if got := output.String(); !strings.Contains(got, "Password [stored, leave blank to keep]: ") { + t.Fatalf("unexpected prompts: %q", got) } } - -func TestInteractiveSetupPrompterPromptSetupRejectsMissingFields(t *testing.T) { - input := strings.NewReader("imap.example.com\nalice\n \n") - prompter := NewInteractiveSetupPrompter(input, &bytes.Buffer{}) - - _, err := prompter.PromptSetup(context.Background()) - if err == nil { - t.Fatal("expected validation error") - } - if !strings.Contains(err.Error(), "password is required") { - t.Fatalf("expected password validation error, got %v", err) - } -} - -func TestTerminalPasswordReaderReadPasswordUsesHiddenInputForTTY(t *testing.T) { - hiddenCalls := 0 - fallbackCalls := 0 - reader := terminalPasswordReader{ - input: os.Stdin, - output: &bytes.Buffer{}, - isTerminal: func(io.Reader) bool { - return true - }, - readHiddenPassword: func(file *os.File) ([]byte, error) { - hiddenCalls++ - if file != os.Stdin { - t.Fatalf("expected os.Stdin, got %v", file) - } - return []byte("secret"), nil - }, - readFallbackPassword: func(string) (string, error) { - fallbackCalls++ - return "", nil - }, - } - - password, err := reader.ReadPassword("Password: ") - if err != nil { - t.Fatalf("ReadPassword returned error: %v", err) - } - - if password != "secret" { - t.Fatalf("expected hidden password, got %q", password) - } - if hiddenCalls != 1 { - t.Fatalf("expected hidden reader to be called once, got %d", hiddenCalls) - } - if fallbackCalls != 0 { - t.Fatalf("expected fallback reader not to be called, got %d", fallbackCalls) - } -} - -func TestTerminalPasswordReaderReadPasswordFallsBackWhenInputIsNotTTY(t *testing.T) { - hiddenCalls := 0 - reader := terminalPasswordReader{ - input: strings.NewReader("ignored\n"), - output: &bytes.Buffer{}, - isTerminal: func(io.Reader) bool { - return false - }, - readHiddenPassword: func(*os.File) ([]byte, error) { - hiddenCalls++ - return nil, errors.New("should not be called") - }, - readFallbackPassword: func(prompt string) (string, error) { - if prompt != "Password: " { - t.Fatalf("unexpected prompt %q", prompt) - } - return "secret", nil - }, - } - - password, err := reader.ReadPassword("Password: ") - if err != nil { - t.Fatalf("ReadPassword returned error: %v", err) - } - - if password != "secret" { - t.Fatalf("expected fallback password, got %q", password) - } - if hiddenCalls != 0 { - t.Fatalf("expected hidden reader not to be called, got %d", hiddenCalls) - } -} - -type passwordReaderStub struct { - password string - err error - prompt string -} - -func (p *passwordReaderStub) ReadPassword(prompt string) (string, error) { - p.prompt = prompt - if p.err != nil { - return "", p.err - } - return p.password, nil -} diff --git a/internal/cli/wire.go b/internal/cli/wire.go index 0b93701..400996a 100644 --- a/internal/cli/wire.go +++ b/internal/cli/wire.go @@ -1,50 +1,68 @@ package cli import ( + "context" "io" "os" + frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" + frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" + frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" + "email-mcp/internal/imapclient" "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" - "email-mcp/internal/secretstore/kwallet" ) type runtimeFactories struct { - newPrompter func(io.Reader, io.Writer) SetupPrompter - newWalletClient func() kwallet.Client - newStore func(kwallet.Client) secretstore.Store - newMailService func() mcpserver.MailService - newRunner func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner + newPrompter func(io.Reader, io.Writer) ConfigPrompter + newConfigStore func() profileConfigStore + openSecretStore func() (secretStore, error) + newMailService func() mcpserver.MailService + newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner + loadManifest manifestLoader + resolveExecutable executableResolver } -func BuildApp() *App { - return buildApp(os.Stdin, os.Stdout, os.Stderr, runtimeFactories{}) +func BuildApp(version string) *App { + return buildApp(os.Stdin, os.Stdout, os.Stderr, version, runtimeFactories{}) } -func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, factories runtimeFactories) *App { +func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, version string, factories runtimeFactories) *App { factories = factories.withDefaults() - prompter := factories.newPrompter(stdin, stderr) - store := factories.newStore(factories.newWalletClient()) - mailService := factories.newMailService() - runner := factories.newRunner(store, mailService, stdin, stdout, stderr) - - return NewAppWithDependencies(prompter, store, runner, stderr) + return NewAppWithDependencies( + factories.newPrompter(stdin, stderr), + factories.newConfigStore(), + factories.openSecretStore, + factories.newMailService, + factories.newRunner, + factories.loadManifest, + factories.resolveExecutable, + stdin, + stdout, + stderr, + version, + ) } func (f runtimeFactories) withDefaults() runtimeFactories { if f.newPrompter == nil { - f.newPrompter = func(input io.Reader, output io.Writer) SetupPrompter { - return NewInteractiveSetupPrompter(input, output) + f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter { + return NewInteractiveConfigPrompter(input, output) } } - if f.newWalletClient == nil { - f.newWalletClient = kwallet.NewDefaultWalletClient + if f.newConfigStore == nil { + f.newConfigStore = func() profileConfigStore { + return frameworkconfig.NewStore[ProfileConfig]("email-mcp") + } } - if f.newStore == nil { - f.newStore = func(client kwallet.Client) secretstore.Store { - return kwallet.NewStore(client) + if f.openSecretStore == nil { + f.openSecretStore = func() (secretStore, error) { + return frameworksecretstore.Open(frameworksecretstore.Options{ + ServiceName: "email-mcp", + BackendPolicy: frameworksecretstore.BackendAuto, + }) } } if f.newMailService == nil { @@ -53,10 +71,28 @@ func (f runtimeFactories) withDefaults() runtimeFactories { } } if f.newRunner == nil { - f.newRunner = func(store secretstore.Store, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner { - return mcpserver.NewRunner(mcpserver.New(store, mail), input, output, errOut) + f.newRunner = func(cred secretstore.Credential, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner { + return mcpserver.NewRunner(mcpserver.New(staticCredentialStore{credential: cred}, mail), input, output, errOut) } } + if f.loadManifest == nil { + f.loadManifest = frameworkmanifest.LoadDefault + } + if f.resolveExecutable == nil { + f.resolveExecutable = os.Executable + } return f } + +type staticCredentialStore struct { + credential secretstore.Credential +} + +func (s staticCredentialStore) Save(_ context.Context, _ string, _ secretstore.Credential) error { + return nil +} + +func (s staticCredentialStore) Load(_ context.Context, _ string) (secretstore.Credential, error) { + return s.credential, nil +} diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index 16e9832..523f845 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -1,160 +1,28 @@ package cli -import ( - "bytes" - "context" - "io" - "strings" - "testing" - - "email-mcp/internal/imapclient" - "email-mcp/internal/mcpserver" - "email-mcp/internal/secretstore" - "email-mcp/internal/secretstore/kwallet" -) - -type walletClientStub struct{} - -func (walletClientStub) IsAvailable(context.Context) error { - return nil -} - -func (walletClientStub) Open(context.Context) error { - return nil -} - -func (walletClientStub) WriteEntry(context.Context, string, []byte) error { - return nil -} - -func (walletClientStub) ReadEntry(context.Context, string) ([]byte, error) { - return nil, nil -} - -type wireStoreStub struct { - saved secretstore.Credential - savedKey string - saveCalled bool -} - -func (s *wireStoreStub) Save(_ context.Context, key string, cred secretstore.Credential) error { - s.saveCalled = true - s.savedKey = key - s.saved = cred - return nil -} - -func (s *wireStoreStub) Load(context.Context, string) (secretstore.Credential, error) { - return secretstore.Credential{}, nil -} - -type wireMailServiceStub struct{} - -func (wireMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { - return nil, nil -} - -func (wireMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { - return nil, nil -} - -func (wireMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { - return imapclient.Message{}, nil -} - -func TestBuildAppWiresSetupAndMCPCommands(t *testing.T) { - stdin := strings.NewReader("unused") - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - - prompter := &promptStub{ - credential: secretstore.Credential{ - Host: "imap.example.com", - Username: "alice", - Password: "secret", - }, - } - store := &wireStoreStub{} - mail := wireMailServiceStub{} - runner := &runnerStub{} - walletClient := &walletClientStub{} - - var gotStoreClient kwallet.Client - var gotRunnerStore secretstore.Store - var gotRunnerMail mcpserver.MailService - - app := buildApp(stdin, stdout, stderr, runtimeFactories{ - newPrompter: func(in io.Reader, errOut io.Writer) SetupPrompter { - if in != stdin { - t.Fatalf("expected stdin to be forwarded to prompter") - } - if errOut != stderr { - t.Fatalf("expected stderr to be forwarded to prompter") - } - return prompter - }, - newWalletClient: func() kwallet.Client { - return walletClient - }, - newStore: func(client kwallet.Client) secretstore.Store { - gotStoreClient = client - return store - }, - newMailService: func() mcpserver.MailService { - return mail - }, - newRunner: func(store secretstore.Store, mail mcpserver.MailService, in io.Reader, out io.Writer, errOut io.Writer) MCPRunner { - gotRunnerStore = store - gotRunnerMail = mail - if in != stdin { - t.Fatalf("expected stdin to be forwarded to runner") - } - if out != stdout { - t.Fatalf("expected stdout to be forwarded to runner") - } - if errOut != stderr { - t.Fatalf("expected stderr to be forwarded to runner") - } - return runner - }, - }) - - if err := app.Run([]string{"setup"}); err != nil { - t.Fatalf("setup returned error: %v", err) - } - if !prompter.called { - t.Fatal("expected setup prompter to be called") - } - if !store.saveCalled { - t.Fatal("expected store Save to be called") - } - if store.savedKey != secretstore.DefaultAccountKey { - t.Fatalf("expected setup to save %q, got %q", secretstore.DefaultAccountKey, store.savedKey) - } - if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" { - t.Fatalf("unexpected saved credential: %#v", store.saved) - } - - if err := app.Run([]string{"mcp"}); err != nil { - t.Fatalf("mcp returned error: %v", err) - } - if !runner.called { - t.Fatal("expected MCP runner to be called") - } - if gotStoreClient != walletClient { - t.Fatal("expected wallet client to be passed to the store factory") - } - if gotRunnerStore != store { - t.Fatal("expected runner to receive the assembled store") - } - if gotRunnerMail != mail { - t.Fatal("expected runner to receive the assembled mail service") - } -} +import "testing" func TestBuildAppReturnsConfiguredApp(t *testing.T) { - app := BuildApp() + app := BuildApp("dev") if app == nil { t.Fatal("expected app instance") } + if app.prompter == nil { + t.Fatal("expected config prompter to be configured") + } + if app.configStore == nil { + t.Fatal("expected config store to be configured") + } + if app.openSecretStore == nil { + t.Fatal("expected secret store opener to be configured") + } + if app.newMailService == nil { + t.Fatal("expected mail service factory to be configured") + } + if app.newRunner == nil { + t.Fatal("expected runner factory to be configured") + } + if app.loadManifest == nil { + t.Fatal("expected manifest loader to be configured") + } } diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 7349a45..ad6aba9 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -14,7 +14,7 @@ import ( "email-mcp/internal/secretstore/kwallet" ) -var ErrCredentialsNotConfigured = errors.New("credentials not configured; run `email-mcp setup`") +var ErrCredentialsNotConfigured = errors.New("credentials not configured; run `email-mcp config`") const ( jsonRPCVersion = "2.0" diff --git a/mcp.toml b/mcp.toml new file mode 100644 index 0000000..b19ea25 --- /dev/null +++ b/mcp.toml @@ -0,0 +1,4 @@ +[update] +source_name = "email-mcp releases" +base_url = "https://gitea.lclr.dev" +latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest"