feat: adopt mcp framework cli flow

This commit is contained in:
thibaud-leclere 2026-04-13 18:01:28 +02:00
parent 1dbb9e15d9
commit 15ea1e11ab
17 changed files with 966 additions and 779 deletions

View file

@ -12,6 +12,7 @@ jobs:
env: env:
BINARY_NAME: email-mcp BINARY_NAME: email-mcp
BUILD_PATH: build/email-mcp-linux-amd64 BUILD_PATH: build/email-mcp-linux-amd64
MANIFEST_PATH: mcp.toml
steps: steps:
- name: Checkout - name: Checkout
@ -137,3 +138,30 @@ jobs:
cat asset.json >&2 cat asset.json >&2
exit 1 exit 1
fi 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

View file

@ -1,6 +1,7 @@
BINARY_NAME := email-mcp BINARY_NAME := email-mcp
BUILD_DIR := build BUILD_DIR := build
GOCACHE ?= /tmp/$(BINARY_NAME)-gocache GOCACHE ?= /tmp/$(BINARY_NAME)-gocache
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOOS ?= $(shell go env GOOS) GOOS ?= $(shell go env GOOS)
GOARCH ?= $(shell go env GOARCH) GOARCH ?= $(shell go env GOARCH)
@ -17,7 +18,7 @@ OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
build: build:
@mkdir -p $(BUILD_DIR) $(GOCACHE) @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: test:
@mkdir -p $(GOCACHE) @mkdir -p $(GOCACHE)

147
README.md
View file

@ -1,68 +1,95 @@
# email-mcp # 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 Le binaire sappuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
- **`list_messages`** — lister les messages récents d'une boîte
- **`get_message`** — récupérer un message par UID IMAP
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 lOS
- le manifeste `mcp.toml`
- lauto-update via `email-mcp update`
## Sommaire ## Commandes
- [Prérequis](#prérequis) - `email-mcp config` : configure un profil IMAP
- [Configuration](#configuration) - `email-mcp setup` : alias de compatibilité vers `config`
- [Étape 1 : enregistrer les credentials](#étape-1--enregistrer-les-credentials) - `email-mcp mcp` : lance le serveur MCP sur `stdin/stdout`
- [Étape 2 : lancer le serveur MCP](#étape-2--lancer-le-serveur-mcp) - `email-mcp update` : met à jour le binaire courant depuis la dernière release
- [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)
## Prérequis ## Outils MCP
- Linux - `list_mailboxes` : lister les boîtes IMAP visibles
- une session D-Bus utilisateur active - `list_messages` : lister les messages récents dune boîte
- KDE Wallet accessible sur cette session - `get_message` : récupérer un message par UID IMAP
- Go 1.25+
- un compte IMAP fonctionnel
## Configuration ## 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 ```sh
./email-mcp setup ./email-mcp config
```
Pour un profil nommé :
```sh
./email-mcp config --profile work
``` ```
Le binaire demande ensuite : Le binaire demande ensuite :
1. l'hôte IMAP 1. lhôte IMAP
2. le nom d'utilisateur 2. le nom dutilisateur
3. le mot de passe 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. ### Lancer le serveur MCP
### Étape 2 : lancer le serveur MCP
Le serveur MCP s'exécute sur `stdin/stdout` avec le handshake MCP standard :
```sh ```sh
./email-mcp mcp ./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 na été configuré pour le profil résolu, le serveur renvoie lerreur :
```text ```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 nest trouvé, la commande échoue.
```sh
./email-mcp update
```
Le manifeste de ce repo pointe vers lendpoint 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 ## 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 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 ### 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. 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 - `build/email-mcp-linux-amd64`
git tag v1.0 - `mcp.toml`
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.
## Compiler depuis les sources ## Compiler depuis les sources
@ -111,7 +134,7 @@ Le workflow build alors `email-mcp` pour `linux/amd64` et joint le binaire `buil
make build make build
``` ```
Le binaire est généré dans `build/email-mcp-<goos>-<goarch>`. Le binaire est généré dans `build/email-mcp-<goos>-<goarch>` avec une version injectée depuis `git describe`.
Pour cross-compiler : Pour cross-compiler :
@ -124,35 +147,3 @@ Pour lancer les tests :
```sh ```sh
make test 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

View file

@ -6,7 +6,9 @@ import (
"email-mcp/internal/cli" "email-mcp/internal/cli"
) )
var version = "dev"
func main() { func main() {
app := cli.BuildApp() app := cli.BuildApp(version)
os.Exit(cli.Execute(app, os.Args[1:], os.Stderr)) os.Exit(cli.Execute(app, os.Args[1:], os.Stderr))
} }

16
go.mod
View file

@ -3,12 +3,24 @@ module email-mcp
go 1.25.0 go 1.25.0
require ( 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-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/godbus/dbus/v5 v5.2.2 github.com/godbus/dbus/v5 v5.2.2
) )
replace gitea.lclr.dev/AI/mcp-framework => ../mcp-framework
require ( 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 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
) )

44
go.sum
View file

@ -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 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= 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 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= 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 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 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 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 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= 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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-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-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-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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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-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.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.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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 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= 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=

View file

@ -3,83 +3,359 @@ package cli
import ( import (
"context" "context"
"errors" "errors"
"flag"
"fmt" "fmt"
"io" "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/mcpserver"
"email-mcp/internal/secretstore" "email-mcp/internal/secretstore"
"email-mcp/internal/secretstore/kwallet" )
const (
binaryName = "email-mcp"
defaultProfileEnv = "EMAIL_MCP_PROFILE"
) )
type MCPRunner interface { type MCPRunner interface {
Run(ctx context.Context) error 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 { type App struct {
prompter SetupPrompter prompter ConfigPrompter
store secretstore.Store configStore profileConfigStore
runner MCPRunner openSecretStore func() (secretStore, error)
stderr io.Writer 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 { 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 { if stderr == nil {
stderr = io.Discard stderr = io.Discard
} }
if version == "" {
version = "dev"
}
return &App{ return &App{
prompter: prompter, prompter: prompter,
store: store, configStore: configStore,
runner: runner, openSecretStore: openSecretStore,
stderr: stderr, newMailService: newMailService,
newRunner: newRunner,
loadManifest: loadManifest,
resolveExecutable: resolveExecutable,
stdin: stdin,
stdout: stdout,
stderr: stderr,
version: version,
} }
} }
func (a *App) Run(args []string) error { func (a *App) Run(args []string) error {
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("usage: email-mcp <setup|mcp>") return fmt.Errorf("usage: email-mcp <config|setup|mcp|update>")
} }
switch args[0] { switch args[0] {
case "setup": case "config", "setup":
return a.runSetup(context.Background()) return a.runConfig(context.Background(), args[0], args[1:])
case "mcp": case "mcp":
return a.runMCP(context.Background()) return a.runMCP(context.Background(), args[1:])
case "update":
return a.runUpdate(context.Background(), args[1:])
default: default:
return fmt.Errorf("unknown command: %s", args[0]) 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 { 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") 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 { if err != nil {
return err return err
} }
if err := cred.Validate(); err != nil { if err := cred.Validate(); err != nil {
return err 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) 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 return nil
} }
func (a *App) runMCP(ctx context.Context) error { func (a *App) runMCP(ctx context.Context, args []string) error {
if a.runner == nil { if a.newRunner == nil {
return fmt.Errorf("mcp runner is not configured") 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 { func mapAppError(err error) error {
@ -89,15 +365,14 @@ func mapAppError(err error) error {
switch { switch {
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured): case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
return newUserFacingError("credentials not configured; run `email-mcp setup`", err) return newUserFacingError("credentials not configured; run `email-mcp config`", err)
case errors.Is(err, kwallet.ErrKWalletUnavailable): case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
return newUserFacingError("kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running", err) return newUserFacingError(
case errors.Is(err, kwallet.ErrKWalletDisabled): fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()),
return newUserFacingError("kwallet is disabled in this KDE session", err) 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, frameworksecretstore.ErrReadOnly):
case errors.Is(err, kwallet.ErrCredentialNotFound): return newUserFacingError("secret backend is read-only", err)
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
default: default:
return err return err
} }

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -4,21 +4,33 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io"
"os"
"path/filepath"
"strings" "strings"
"testing" "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" "email-mcp/internal/secretstore"
) )
var _ func() *App = NewApp var _ func() *App = NewApp
type promptStub struct { type configPrompterStub struct {
credential secretstore.Credential credential secretstore.Credential
err error err error
called bool 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 p.called = true
if p.err != nil { if p.err != nil {
return secretstore.Credential{}, p.err return secretstore.Credential{}, p.err
@ -26,22 +38,88 @@ func (p *promptStub) PromptSetup(context.Context) (secretstore.Credential, error
return p.credential, nil return p.credential, nil
} }
type storeStub struct { type capturingPrompterStub struct {
saved secretstore.Credential credential secretstore.Credential
savedKey string 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 saveErr error
saved frameworkconfig.FileConfig[ProfileConfig]
saveCalled bool 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.saveCalled = true
s.savedKey = key s.saved = cfg
s.saved = cred if s.saveErr != nil {
return s.saveErr 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) { type secretStoreStub struct {
return secretstore.Credential{}, nil 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 { type runnerStub struct {
@ -55,7 +133,7 @@ func (r *runnerStub) Run(context.Context) error {
} }
func TestAppRunRejectsUnknownCommand(t *testing.T) { 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"}) err := app.Run([]string{"unknown"})
if err == nil { if err == nil {
@ -64,7 +142,7 @@ func TestAppRunRejectsUnknownCommand(t *testing.T) {
} }
func TestAppRunShowsUsageWhenNoArgsProvided(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) err := app.Run(nil)
if err == nil { if err == nil {
@ -75,87 +153,246 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
} }
} }
func TestAppRunSetupPromptsAndSavesDefaultCredential(t *testing.T) { func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) {
store := &storeStub{} prompter := &configPrompterStub{
prompter := &promptStub{
credential: secretstore.Credential{ credential: secretstore.Credential{
Host: "imap.example.com", Host: "imap.example.com",
Username: "alice", Username: "alice",
Password: "secret", 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) t.Fatalf("Run returned error: %v", err)
} }
if !prompter.called { if !prompter.called {
t.Fatal("expected setup prompter to be called") t.Fatal("expected config prompter to be called")
} }
if !store.saveCalled { if !secrets.setCalled {
t.Fatal("expected store Save to be called") t.Fatal("expected password to be stored")
} }
if store.savedKey != secretstore.DefaultAccountKey { if secrets.setName != "imap-password/default" {
t.Fatalf("expected saved key %q, got %q", secretstore.DefaultAccountKey, store.savedKey) t.Fatalf("unexpected secret name %q", secrets.setName)
} }
if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" { if !cfgStore.saveCalled {
t.Fatalf("unexpected saved credential: %#v", store.saved) 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) { func TestAppRunSetupAliasesConfig(t *testing.T) {
store := &storeStub{} prompter := &configPrompterStub{
app := NewAppWithDependencies(&promptStub{
credential: secretstore.Credential{ credential: secretstore.Credential{
Host: "imap.example.com", Host: "imap.example.com",
Username: "alice", Username: "alice",
Password: "secret",
}, },
}, store, nil, &bytes.Buffer{}) }
cfgStore := &configStoreStub{}
secrets := &secretStoreStub{}
err := app.Run([]string{"setup"}) app := NewAppWithDependencies(
if err == nil { prompter,
t.Fatal("expected setup to fail for invalid credential") 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") { if !cfgStore.saveCalled {
t.Fatalf("expected validation error, got %v", err) t.Fatal("expected setup to save config via config command")
}
if store.saveCalled {
t.Fatal("expected store Save not to be called when credential is invalid")
} }
} }
func TestAppRunReturnsPromptError(t *testing.T) { func TestAppRunConfigUsesStoredValuesAsDefaults(t *testing.T) {
expectedErr := errors.New("prompt failed") prompter := &capturingPrompterStub{
app := NewAppWithDependencies(&promptStub{err: expectedErr}, &storeStub{}, nil, &bytes.Buffer{}) 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"}) app := NewAppWithDependencies(
if !errors.Is(err, expectedErr) { prompter,
t.Fatalf("expected prompt error %v, got %v", expectedErr, err) 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{} 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 { 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 { 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) { 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 { tests := []struct {
command string command string
want 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: "mcp", want: "mcp runner is not configured"},
{command: "update", want: "manifest loader is not configured"},
} }
for _, tt := range tests { 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) { func TestNewAppBuildsProductionDependencies(t *testing.T) {
app := NewApp() app := NewApp()
if app == nil { if app == nil {
t.Fatal("expected app instance") t.Fatal("expected app instance")
} }
if app.prompter == nil { if app.prompter == nil {
t.Fatal("expected setup prompter to be configured") t.Fatal("expected config prompter to be configured")
} }
if app.store == nil { if app.configStore == nil {
t.Fatal("expected secret store to be configured") t.Fatal("expected config store to be configured")
} }
if app.runner == nil { if app.openSecretStore == nil {
t.Fatal("expected MCP runner to be configured") 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
}

View file

@ -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)
}
}

View file

@ -7,40 +7,19 @@ import (
"io" "io"
"os" "os"
"strings" "strings"
"syscall"
"unsafe" frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
"email-mcp/internal/secretstore" "email-mcp/internal/secretstore"
) )
type SetupPrompter interface { type InteractiveConfigPrompter struct {
PromptSetup(ctx context.Context) (secretstore.Credential, error) reader *bufio.Reader
stdinFile *os.File
output io.Writer
} }
type PasswordReader interface { func NewInteractiveConfigPrompter(input io.Reader, output io.Writer) *InteractiveConfigPrompter {
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 {
if input == nil { if input == nil {
input = strings.NewReader("") input = strings.NewReader("")
} }
@ -48,29 +27,29 @@ func NewInteractiveSetupPrompterWithPasswordReader(input io.Reader, output io.Wr
output = io.Discard output = io.Discard
} }
prompter := &InteractiveSetupPrompter{ prompter := &InteractiveConfigPrompter{
input: bufio.NewReader(input), reader: bufio.NewReader(input),
rawInput: input, output: output,
output: output,
} }
if passwordReader == nil { if file, ok := input.(*os.File); ok {
passwordReader = newTerminalPasswordReader(input, output, prompter.prompt) prompter.stdinFile = file
} }
prompter.passwordReader = passwordReader
return prompter return prompter
} }
func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Credential, error) { func (p *InteractiveConfigPrompter) PromptCredential(_ context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) {
host, err := p.prompt("IMAP host: ") host, err := frameworkcli.PromptLine(p.reader, p.output, "IMAP host", existing.Host)
if err != nil { if err != nil {
return secretstore.Credential{}, err return secretstore.Credential{}, err
} }
username, err := p.prompt("Username: ")
username, err := frameworkcli.PromptLine(p.reader, p.output, "Username", existing.Username)
if err != nil { if err != nil {
return secretstore.Credential{}, err return secretstore.Credential{}, err
} }
password, err := p.passwordReader.ReadPassword("Password: ")
password, err := p.promptPassword(existing.Password, hasStoredPassword)
if err != nil { if err != nil {
return secretstore.Credential{}, err return secretstore.Credential{}, err
} }
@ -83,101 +62,30 @@ func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Cre
if err := cred.Validate(); err != nil { if err := cred.Validate(); err != nil {
return secretstore.Credential{}, err return secretstore.Credential{}, err
} }
return cred, nil return cred, nil
} }
func (p *InteractiveSetupPrompter) prompt(label string) (string, error) { func (p *InteractiveConfigPrompter) promptPassword(storedPassword string, hasStoredPassword bool) (string, error) {
if _, err := fmt.Fprint(p.output, label); err != nil { if p.stdinFile != nil {
return "", err 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 { if err != nil && err != io.EOF {
return "", err return "", err
} }
return strings.TrimSpace(value), nil
}
func newTerminalPasswordReader(input io.Reader, output io.Writer, fallback func(string) (string, error)) PasswordReader { password := strings.TrimSpace(line)
return terminalPasswordReader{ if password == "" && hasStoredPassword {
input: input, return storedPassword, nil
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
}
} }
return r.readFallbackPassword(prompt) return password, nil
}
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
} }

View file

@ -3,21 +3,20 @@ package cli
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"io"
"os"
"strings" "strings"
"testing" "testing"
"email-mcp/internal/secretstore"
) )
func TestInteractiveSetupPrompterPromptSetupCollectsCredential(t *testing.T) { func TestInteractiveConfigPrompterPromptCredentialCollectsValues(t *testing.T) {
input := strings.NewReader(" imap.example.com \n alice \n secret \n") input := strings.NewReader("imap.example.com\nalice\nsecret\n")
output := &bytes.Buffer{} 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 { 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" { 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{} output := &bytes.Buffer{}
passwordReader := &passwordReaderStub{password: "secret"} prompter := NewInteractiveConfigPrompter(input, output)
prompter := NewInteractiveSetupPrompterWithPasswordReader(
strings.NewReader("imap.example.com\nalice\n"),
output,
passwordReader,
)
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 { if err != nil {
t.Fatalf("PromptSetup returned error: %v", err) t.Fatalf("PromptCredential returned error: %v", err)
} }
if cred.Password != "secret" { if cred.Password != "stored-secret" {
t.Fatalf("expected password from password reader, got %#v", cred) t.Fatalf("expected stored password to be preserved, got %q", cred.Password)
} }
if passwordReader.prompt != "Password: " { if got := output.String(); !strings.Contains(got, "Password [stored, leave blank to keep]: ") {
t.Fatalf("unexpected password prompt %q", passwordReader.prompt) 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
}

View file

@ -1,50 +1,68 @@
package cli package cli
import ( import (
"context"
"io" "io"
"os" "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/imapclient"
"email-mcp/internal/mcpserver" "email-mcp/internal/mcpserver"
"email-mcp/internal/secretstore" "email-mcp/internal/secretstore"
"email-mcp/internal/secretstore/kwallet"
) )
type runtimeFactories struct { type runtimeFactories struct {
newPrompter func(io.Reader, io.Writer) SetupPrompter newPrompter func(io.Reader, io.Writer) ConfigPrompter
newWalletClient func() kwallet.Client newConfigStore func() profileConfigStore
newStore func(kwallet.Client) secretstore.Store openSecretStore func() (secretStore, error)
newMailService func() mcpserver.MailService newMailService func() mcpserver.MailService
newRunner func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
loadManifest manifestLoader
resolveExecutable executableResolver
} }
func BuildApp() *App { func BuildApp(version string) *App {
return buildApp(os.Stdin, os.Stdout, os.Stderr, runtimeFactories{}) 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() factories = factories.withDefaults()
prompter := factories.newPrompter(stdin, stderr) return NewAppWithDependencies(
store := factories.newStore(factories.newWalletClient()) factories.newPrompter(stdin, stderr),
mailService := factories.newMailService() factories.newConfigStore(),
runner := factories.newRunner(store, mailService, stdin, stdout, stderr) factories.openSecretStore,
factories.newMailService,
return NewAppWithDependencies(prompter, store, runner, stderr) factories.newRunner,
factories.loadManifest,
factories.resolveExecutable,
stdin,
stdout,
stderr,
version,
)
} }
func (f runtimeFactories) withDefaults() runtimeFactories { func (f runtimeFactories) withDefaults() runtimeFactories {
if f.newPrompter == nil { if f.newPrompter == nil {
f.newPrompter = func(input io.Reader, output io.Writer) SetupPrompter { f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter {
return NewInteractiveSetupPrompter(input, output) return NewInteractiveConfigPrompter(input, output)
} }
} }
if f.newWalletClient == nil { if f.newConfigStore == nil {
f.newWalletClient = kwallet.NewDefaultWalletClient f.newConfigStore = func() profileConfigStore {
return frameworkconfig.NewStore[ProfileConfig]("email-mcp")
}
} }
if f.newStore == nil { if f.openSecretStore == nil {
f.newStore = func(client kwallet.Client) secretstore.Store { f.openSecretStore = func() (secretStore, error) {
return kwallet.NewStore(client) return frameworksecretstore.Open(frameworksecretstore.Options{
ServiceName: "email-mcp",
BackendPolicy: frameworksecretstore.BackendAuto,
})
} }
} }
if f.newMailService == nil { if f.newMailService == nil {
@ -53,10 +71,28 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
} }
} }
if f.newRunner == nil { if f.newRunner == nil {
f.newRunner = func(store secretstore.Store, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner { f.newRunner = func(cred secretstore.Credential, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner {
return mcpserver.NewRunner(mcpserver.New(store, mail), input, output, errOut) 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 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
}

View file

@ -1,160 +1,28 @@
package cli package cli
import ( import "testing"
"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")
}
}
func TestBuildAppReturnsConfiguredApp(t *testing.T) { func TestBuildAppReturnsConfiguredApp(t *testing.T) {
app := BuildApp() app := BuildApp("dev")
if app == nil { if app == nil {
t.Fatal("expected app instance") 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")
}
} }

View file

@ -14,7 +14,7 @@ import (
"email-mcp/internal/secretstore/kwallet" "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 ( const (
jsonRPCVersion = "2.0" jsonRPCVersion = "2.0"

4
mcp.toml Normal file
View file

@ -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"