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:
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

View file

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

147
README.md
View file

@ -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 sappuie 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 lOS
- le manifeste `mcp.toml`
- lauto-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 dune 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. lhôte IMAP
2. le nom dutilisateur
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 na été configuré pour le profil résolu, le serveur renvoie lerreur :
```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
@ -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-<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 :
@ -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

View file

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

16
go.mod
View file

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

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/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=

View file

@ -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
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,
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 <setup|mcp>")
return fmt.Errorf("usage: email-mcp <config|setup|mcp|update>")
}
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
}

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

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"
"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 PasswordReader interface {
ReadPassword(prompt string) (string, error)
}
type InteractiveSetupPrompter struct {
input *bufio.Reader
rawInput io.Reader
type InteractiveConfigPrompter struct {
reader *bufio.Reader
stdinFile *os.File
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,
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
}
}
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
password := strings.TrimSpace(line)
if password == "" && hasStoredPassword {
return storedPassword, nil
}
return password, nil
}

View file

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

View file

@ -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
newPrompter func(io.Reader, io.Writer) ConfigPrompter
newConfigStore func() profileConfigStore
openSecretStore func() (secretStore, error)
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 {
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
}

View file

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

View file

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

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"