feat: adopt mcp framework cli flow
This commit is contained in:
parent
1dbb9e15d9
commit
15ea1e11ab
17 changed files with 966 additions and 779 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
Makefile
3
Makefile
|
|
@ -1,6 +1,7 @@
|
|||
BINARY_NAME := email-mcp
|
||||
BUILD_DIR := build
|
||||
GOCACHE ?= /tmp/$(BINARY_NAME)-gocache
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
|
||||
GOOS ?= $(shell go env GOOS)
|
||||
GOARCH ?= $(shell go env GOARCH)
|
||||
|
|
@ -17,7 +18,7 @@ OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
|
|||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR) $(GOCACHE)
|
||||
GOCACHE=$(GOCACHE) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(OUTPUT) ./cmd/email-mcp
|
||||
GOCACHE=$(GOCACHE) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X main.version=$(VERSION)" -o $(OUTPUT) ./cmd/email-mcp
|
||||
|
||||
test:
|
||||
@mkdir -p $(GOCACHE)
|
||||
|
|
|
|||
147
README.md
147
README.md
|
|
@ -1,68 +1,95 @@
|
|||
# email-mcp
|
||||
|
||||
Serveur MCP local pour lire une boîte mail via IMAP. Le serveur parle le protocole MCP standard sur `stdio`, avec des messages **JSON-RPC 2.0** (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`). Le projet expose trois outils :
|
||||
Serveur MCP local pour lire une boîte mail via IMAP. Le serveur parle le protocole MCP standard sur `stdio`, avec des messages **JSON-RPC 2.0** (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`).
|
||||
|
||||
- **`list_mailboxes`** — lister les boîtes IMAP visibles
|
||||
- **`list_messages`** — lister les messages récents d'une boîte
|
||||
- **`get_message`** — récupérer un message par UID IMAP
|
||||
Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
|
||||
|
||||
Le stockage des credentials repose sur **KDE Wallet** via **D-Bus**. La V1 cible Linux avec session KDE Wallet disponible.
|
||||
- la gestion de profils CLI
|
||||
- le stockage JSON de configuration dans `os.UserConfigDir()`
|
||||
- le stockage du mot de passe dans le wallet natif de l’OS
|
||||
- le manifeste `mcp.toml`
|
||||
- l’auto-update via `email-mcp update`
|
||||
|
||||
## Sommaire
|
||||
## Commandes
|
||||
|
||||
- [Prérequis](#prérequis)
|
||||
- [Configuration](#configuration)
|
||||
- [Étape 1 : enregistrer les credentials](#étape-1--enregistrer-les-credentials)
|
||||
- [Étape 2 : lancer le serveur MCP](#étape-2--lancer-le-serveur-mcp)
|
||||
- [Installation](#installation)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [Configuration JSON manuelle](#configuration-json-manuelle)
|
||||
- [Compiler depuis les sources](#compiler-depuis-les-sources)
|
||||
- [Outils](#outils)
|
||||
- [list_mailboxes](#list_mailboxes)
|
||||
- [list_messages](#list_messages)
|
||||
- [get_message](#get_message)
|
||||
- `email-mcp config` : configure un profil IMAP
|
||||
- `email-mcp setup` : alias de compatibilité vers `config`
|
||||
- `email-mcp mcp` : lance le serveur MCP sur `stdin/stdout`
|
||||
- `email-mcp update` : met à jour le binaire courant depuis la dernière release
|
||||
|
||||
## Prérequis
|
||||
## Outils MCP
|
||||
|
||||
- Linux
|
||||
- une session D-Bus utilisateur active
|
||||
- KDE Wallet accessible sur cette session
|
||||
- Go 1.25+
|
||||
- un compte IMAP fonctionnel
|
||||
- `list_mailboxes` : lister les boîtes IMAP visibles
|
||||
- `list_messages` : lister les messages récents d’une boîte
|
||||
- `get_message` : récupérer un message par UID IMAP
|
||||
|
||||
## Configuration
|
||||
|
||||
### Étape 1 : enregistrer les credentials
|
||||
La configuration est séparée en deux parties :
|
||||
|
||||
Le setup est interactif :
|
||||
- `host` et `username` sont stockés dans `config.json`
|
||||
- `password` est stocké dans le wallet système
|
||||
|
||||
Le profil actif est résolu dans cet ordre :
|
||||
|
||||
1. `--profile`
|
||||
2. `EMAIL_MCP_PROFILE`
|
||||
3. `current_profile` dans `config.json`
|
||||
4. `default`
|
||||
|
||||
### Configurer un profil
|
||||
|
||||
```sh
|
||||
./email-mcp setup
|
||||
./email-mcp config
|
||||
```
|
||||
|
||||
Pour un profil nommé :
|
||||
|
||||
```sh
|
||||
./email-mcp config --profile work
|
||||
```
|
||||
|
||||
Le binaire demande ensuite :
|
||||
|
||||
1. l'hôte IMAP
|
||||
2. le nom d'utilisateur
|
||||
1. l’hôte IMAP
|
||||
2. le nom d’utilisateur
|
||||
3. le mot de passe
|
||||
|
||||
Les credentials sont stockés dans KDE Wallet sous le profil `default`.
|
||||
Si un mot de passe existe déjà dans le wallet, laisser le champ vide le conserve.
|
||||
|
||||
Si KDE Wallet n'est pas disponible, le setup échoue explicitement et n'écrit rien ailleurs.
|
||||
|
||||
### Étape 2 : lancer le serveur MCP
|
||||
|
||||
Le serveur MCP s'exécute sur `stdin/stdout` avec le handshake MCP standard :
|
||||
### Lancer le serveur MCP
|
||||
|
||||
```sh
|
||||
./email-mcp mcp
|
||||
```
|
||||
|
||||
Si aucun credential n'a été configuré, le serveur renvoie l'erreur :
|
||||
Pour un profil nommé :
|
||||
|
||||
```sh
|
||||
./email-mcp mcp --profile work
|
||||
```
|
||||
|
||||
Si aucun credential n’a été configuré pour le profil résolu, le serveur renvoie l’erreur :
|
||||
|
||||
```text
|
||||
credentials not configured; run `email-mcp setup`
|
||||
credentials not configured; run `email-mcp config`
|
||||
```
|
||||
|
||||
## Auto-update
|
||||
|
||||
`email-mcp update` lit `mcp.toml` depuis le répertoire du binaire courant, puis remonte les répertoires parents. Si aucun manifeste n’est trouvé, la commande échoue.
|
||||
|
||||
```sh
|
||||
./email-mcp update
|
||||
```
|
||||
|
||||
Le manifeste de ce repo pointe vers l’endpoint Gitea :
|
||||
|
||||
```toml
|
||||
[update]
|
||||
source_name = "email-mcp releases"
|
||||
base_url = "https://gitea.lclr.dev"
|
||||
latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest"
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
|
@ -75,7 +102,7 @@ Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` :
|
|||
claude mcp add email-mcp -- /absolute/path/to/bin/email-mcp mcp
|
||||
```
|
||||
|
||||
Le `setup` doit être exécuté une fois séparément avant d'utiliser le serveur.
|
||||
La configuration se fait une fois séparément via `email-mcp config`.
|
||||
|
||||
### Configuration JSON manuelle
|
||||
|
||||
|
|
@ -96,14 +123,10 @@ Ajoute le bloc suivant à ta configuration MCP (`~/.claude.json` côté utilisat
|
|||
|
||||
Une release est générée automatiquement quand tu pousses un tag `v*` sur le repo Gitea.
|
||||
|
||||
Exemple pour démarrer en `v1.0` :
|
||||
Les assets publiés sont :
|
||||
|
||||
```sh
|
||||
git tag v1.0
|
||||
git push origin v1.0
|
||||
```
|
||||
|
||||
Le workflow build alors `email-mcp` pour `linux/amd64` et joint le binaire `build/email-mcp-linux-amd64` comme asset de la release.
|
||||
- `build/email-mcp-linux-amd64`
|
||||
- `mcp.toml`
|
||||
|
||||
## Compiler depuis les sources
|
||||
|
||||
|
|
@ -111,7 +134,7 @@ Le workflow build alors `email-mcp` pour `linux/amd64` et joint le binaire `buil
|
|||
make build
|
||||
```
|
||||
|
||||
Le binaire est généré dans `build/email-mcp-<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
|
||||
|
|
|
|||
|
|
@ -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
16
go.mod
|
|
@ -3,12 +3,24 @@ module email-mcp
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
gitea.lclr.dev/AI/mcp-framework v1.0.1-0.20260413153617-3437d265d4ba
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
)
|
||||
|
||||
replace gitea.lclr.dev/AI/mcp-framework => ../mcp-framework
|
||||
|
||||
require (
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
||||
github.com/99designs/keyring v1.2.2 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/danieljoos/wincred v1.1.2 // indirect
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||
github.com/mtibben/percent v0.2.1 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
)
|
||||
|
|
|
|||
44
go.sum
44
go.sum
|
|
@ -1,11 +1,42 @@
|
|||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
||||
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
||||
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
|
||||
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
|
||||
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
|
||||
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
|
|
@ -21,14 +52,17 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
|
|
@ -39,3 +73,9 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -3,83 +3,359 @@ package cli
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update"
|
||||
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
)
|
||||
|
||||
const (
|
||||
binaryName = "email-mcp"
|
||||
defaultProfileEnv = "EMAIL_MCP_PROFILE"
|
||||
)
|
||||
|
||||
type MCPRunner interface {
|
||||
Run(ctx context.Context) error
|
||||
}
|
||||
|
||||
type ConfigPrompter interface {
|
||||
PromptCredential(ctx context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error)
|
||||
}
|
||||
|
||||
type profileConfigStore interface {
|
||||
LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error)
|
||||
SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error)
|
||||
}
|
||||
|
||||
type secretStore interface {
|
||||
SetSecret(name, label, secret string) error
|
||||
GetSecret(name string) (string, error)
|
||||
DeleteSecret(name string) error
|
||||
}
|
||||
|
||||
type manifestLoader func(startDir string) (frameworkmanifest.File, string, error)
|
||||
type executableResolver func() (string, error)
|
||||
|
||||
type ProfileConfig struct {
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
prompter SetupPrompter
|
||||
store secretstore.Store
|
||||
runner MCPRunner
|
||||
stderr io.Writer
|
||||
prompter ConfigPrompter
|
||||
configStore profileConfigStore
|
||||
openSecretStore func() (secretStore, error)
|
||||
newMailService func() mcpserver.MailService
|
||||
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
||||
loadManifest manifestLoader
|
||||
resolveExecutable executableResolver
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
version string
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
return BuildApp()
|
||||
return BuildApp("dev")
|
||||
}
|
||||
|
||||
func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App {
|
||||
func NewAppWithDependencies(
|
||||
prompter ConfigPrompter,
|
||||
configStore profileConfigStore,
|
||||
openSecretStore func() (secretStore, error),
|
||||
newMailService func() mcpserver.MailService,
|
||||
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner,
|
||||
loadManifest manifestLoader,
|
||||
resolveExecutable executableResolver,
|
||||
stdin io.Reader,
|
||||
stdout io.Writer,
|
||||
stderr io.Writer,
|
||||
version string,
|
||||
) *App {
|
||||
if stdin == nil {
|
||||
stdin = strings.NewReader("")
|
||||
}
|
||||
if stdout == nil {
|
||||
stdout = io.Discard
|
||||
}
|
||||
if stderr == nil {
|
||||
stderr = io.Discard
|
||||
}
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
return &App{
|
||||
prompter: prompter,
|
||||
store: store,
|
||||
runner: runner,
|
||||
stderr: stderr,
|
||||
prompter: prompter,
|
||||
configStore: configStore,
|
||||
openSecretStore: openSecretStore,
|
||||
newMailService: newMailService,
|
||||
newRunner: newRunner,
|
||||
loadManifest: loadManifest,
|
||||
resolveExecutable: resolveExecutable,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: email-mcp <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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,40 +7,19 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
|
||||
"email-mcp/internal/secretstore"
|
||||
)
|
||||
|
||||
type SetupPrompter interface {
|
||||
PromptSetup(ctx context.Context) (secretstore.Credential, error)
|
||||
type InteractiveConfigPrompter struct {
|
||||
reader *bufio.Reader
|
||||
stdinFile *os.File
|
||||
output io.Writer
|
||||
}
|
||||
|
||||
type PasswordReader interface {
|
||||
ReadPassword(prompt string) (string, error)
|
||||
}
|
||||
|
||||
type InteractiveSetupPrompter struct {
|
||||
input *bufio.Reader
|
||||
rawInput io.Reader
|
||||
output io.Writer
|
||||
passwordReader PasswordReader
|
||||
}
|
||||
|
||||
type terminalPasswordReader struct {
|
||||
input io.Reader
|
||||
output io.Writer
|
||||
isTerminal func(io.Reader) bool
|
||||
readHiddenPassword func(*os.File) ([]byte, error)
|
||||
readFallbackPassword func(string) (string, error)
|
||||
}
|
||||
|
||||
func NewInteractiveSetupPrompter(input io.Reader, output io.Writer) *InteractiveSetupPrompter {
|
||||
return NewInteractiveSetupPrompterWithPasswordReader(input, output, nil)
|
||||
}
|
||||
|
||||
func NewInteractiveSetupPrompterWithPasswordReader(input io.Reader, output io.Writer, passwordReader PasswordReader) *InteractiveSetupPrompter {
|
||||
func NewInteractiveConfigPrompter(input io.Reader, output io.Writer) *InteractiveConfigPrompter {
|
||||
if input == nil {
|
||||
input = strings.NewReader("")
|
||||
}
|
||||
|
|
@ -48,29 +27,29 @@ func NewInteractiveSetupPrompterWithPasswordReader(input io.Reader, output io.Wr
|
|||
output = io.Discard
|
||||
}
|
||||
|
||||
prompter := &InteractiveSetupPrompter{
|
||||
input: bufio.NewReader(input),
|
||||
rawInput: input,
|
||||
output: output,
|
||||
prompter := &InteractiveConfigPrompter{
|
||||
reader: bufio.NewReader(input),
|
||||
output: output,
|
||||
}
|
||||
if passwordReader == nil {
|
||||
passwordReader = newTerminalPasswordReader(input, output, prompter.prompt)
|
||||
if file, ok := input.(*os.File); ok {
|
||||
prompter.stdinFile = file
|
||||
}
|
||||
prompter.passwordReader = passwordReader
|
||||
|
||||
return prompter
|
||||
}
|
||||
|
||||
func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Credential, error) {
|
||||
host, err := p.prompt("IMAP host: ")
|
||||
func (p *InteractiveConfigPrompter) PromptCredential(_ context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) {
|
||||
host, err := frameworkcli.PromptLine(p.reader, p.output, "IMAP host", existing.Host)
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
username, err := p.prompt("Username: ")
|
||||
|
||||
username, err := frameworkcli.PromptLine(p.reader, p.output, "Username", existing.Username)
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
password, err := p.passwordReader.ReadPassword("Password: ")
|
||||
|
||||
password, err := p.promptPassword(existing.Password, hasStoredPassword)
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
|
@ -83,101 +62,30 @@ func (p *InteractiveSetupPrompter) PromptSetup(context.Context) (secretstore.Cre
|
|||
if err := cred.Validate(); err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func (p *InteractiveSetupPrompter) prompt(label string) (string, error) {
|
||||
if _, err := fmt.Fprint(p.output, label); err != nil {
|
||||
return "", err
|
||||
func (p *InteractiveConfigPrompter) promptPassword(storedPassword string, hasStoredPassword bool) (string, error) {
|
||||
if p.stdinFile != nil {
|
||||
return frameworkcli.PromptSecret(p.stdinFile, p.output, "Password", hasStoredPassword, storedPassword)
|
||||
}
|
||||
|
||||
value, err := p.input.ReadString('\n')
|
||||
if hasStoredPassword {
|
||||
fmt.Fprint(p.output, "Password [stored, leave blank to keep]: ")
|
||||
} else {
|
||||
fmt.Fprint(p.output, "Password: ")
|
||||
}
|
||||
|
||||
line, err := p.reader.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
|
||||
func newTerminalPasswordReader(input io.Reader, output io.Writer, fallback func(string) (string, error)) PasswordReader {
|
||||
return terminalPasswordReader{
|
||||
input: input,
|
||||
output: output,
|
||||
isTerminal: isTerminalReader,
|
||||
readHiddenPassword: readHiddenPassword,
|
||||
readFallbackPassword: fallback,
|
||||
}
|
||||
}
|
||||
|
||||
func (r terminalPasswordReader) ReadPassword(prompt string) (string, error) {
|
||||
if r.isTerminal != nil && r.isTerminal(r.input) {
|
||||
file, ok := r.input.(*os.File)
|
||||
if ok {
|
||||
if _, err := fmt.Fprint(r.output, prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
password, err := r.readHiddenPassword(file)
|
||||
if _, printErr := fmt.Fprintln(r.output); err == nil && printErr != nil {
|
||||
return "", printErr
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(password)), nil
|
||||
}
|
||||
password := strings.TrimSpace(line)
|
||||
if password == "" && hasStoredPassword {
|
||||
return storedPassword, nil
|
||||
}
|
||||
|
||||
return r.readFallbackPassword(prompt)
|
||||
}
|
||||
|
||||
func isTerminalReader(reader io.Reader) bool {
|
||||
file, ok := reader.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isTerminalFile(file)
|
||||
}
|
||||
|
||||
func isTerminalFile(file *os.File) bool {
|
||||
_, err := getTermios(file.Fd())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readHiddenPassword(file *os.File) ([]byte, error) {
|
||||
state, err := getTermios(file.Fd())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next := *state
|
||||
next.Lflag &^= syscall.ECHO
|
||||
if err := setTermios(file.Fd(), &next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = setTermios(file.Fd(), state)
|
||||
}()
|
||||
|
||||
reader := bufio.NewReader(file)
|
||||
value, err := reader.ReadBytes('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(strings.TrimRight(string(value), "\r\n")), nil
|
||||
}
|
||||
|
||||
func getTermios(fd uintptr) (*syscall.Termios, error) {
|
||||
state := &syscall.Termios{}
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(state)))
|
||||
if errno != 0 {
|
||||
return nil, errno
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func setTermios(fd uintptr, state *syscall.Termios) error {
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCSETS), uintptr(unsafe.Pointer(state)))
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
return password, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,68 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
)
|
||||
|
||||
type runtimeFactories struct {
|
||||
newPrompter func(io.Reader, io.Writer) SetupPrompter
|
||||
newWalletClient func() kwallet.Client
|
||||
newStore func(kwallet.Client) secretstore.Store
|
||||
newMailService func() mcpserver.MailService
|
||||
newRunner func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
||||
newPrompter func(io.Reader, io.Writer) ConfigPrompter
|
||||
newConfigStore func() profileConfigStore
|
||||
openSecretStore func() (secretStore, error)
|
||||
newMailService func() mcpserver.MailService
|
||||
newRunner func(secretstore.Credential, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
||||
loadManifest manifestLoader
|
||||
resolveExecutable executableResolver
|
||||
}
|
||||
|
||||
func BuildApp() *App {
|
||||
return buildApp(os.Stdin, os.Stdout, os.Stderr, runtimeFactories{})
|
||||
func BuildApp(version string) *App {
|
||||
return buildApp(os.Stdin, os.Stdout, os.Stderr, version, runtimeFactories{})
|
||||
}
|
||||
|
||||
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, factories runtimeFactories) *App {
|
||||
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, version string, factories runtimeFactories) *App {
|
||||
factories = factories.withDefaults()
|
||||
|
||||
prompter := factories.newPrompter(stdin, stderr)
|
||||
store := factories.newStore(factories.newWalletClient())
|
||||
mailService := factories.newMailService()
|
||||
runner := factories.newRunner(store, mailService, stdin, stdout, stderr)
|
||||
|
||||
return NewAppWithDependencies(prompter, store, runner, stderr)
|
||||
return NewAppWithDependencies(
|
||||
factories.newPrompter(stdin, stderr),
|
||||
factories.newConfigStore(),
|
||||
factories.openSecretStore,
|
||||
factories.newMailService,
|
||||
factories.newRunner,
|
||||
factories.loadManifest,
|
||||
factories.resolveExecutable,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
version,
|
||||
)
|
||||
}
|
||||
|
||||
func (f runtimeFactories) withDefaults() runtimeFactories {
|
||||
if f.newPrompter == nil {
|
||||
f.newPrompter = func(input io.Reader, output io.Writer) SetupPrompter {
|
||||
return NewInteractiveSetupPrompter(input, output)
|
||||
f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter {
|
||||
return NewInteractiveConfigPrompter(input, output)
|
||||
}
|
||||
}
|
||||
if f.newWalletClient == nil {
|
||||
f.newWalletClient = kwallet.NewDefaultWalletClient
|
||||
if f.newConfigStore == nil {
|
||||
f.newConfigStore = func() profileConfigStore {
|
||||
return frameworkconfig.NewStore[ProfileConfig]("email-mcp")
|
||||
}
|
||||
}
|
||||
if f.newStore == nil {
|
||||
f.newStore = func(client kwallet.Client) secretstore.Store {
|
||||
return kwallet.NewStore(client)
|
||||
if f.openSecretStore == nil {
|
||||
f.openSecretStore = func() (secretStore, error) {
|
||||
return frameworksecretstore.Open(frameworksecretstore.Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: frameworksecretstore.BackendAuto,
|
||||
})
|
||||
}
|
||||
}
|
||||
if f.newMailService == nil {
|
||||
|
|
@ -53,10 +71,28 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
|
|||
}
|
||||
}
|
||||
if f.newRunner == nil {
|
||||
f.newRunner = func(store secretstore.Store, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner {
|
||||
return mcpserver.NewRunner(mcpserver.New(store, mail), input, output, errOut)
|
||||
f.newRunner = func(cred secretstore.Credential, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner {
|
||||
return mcpserver.NewRunner(mcpserver.New(staticCredentialStore{credential: cred}, mail), input, output, errOut)
|
||||
}
|
||||
}
|
||||
if f.loadManifest == nil {
|
||||
f.loadManifest = frameworkmanifest.LoadDefault
|
||||
}
|
||||
if f.resolveExecutable == nil {
|
||||
f.resolveExecutable = os.Executable
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
type staticCredentialStore struct {
|
||||
credential secretstore.Credential
|
||||
}
|
||||
|
||||
func (s staticCredentialStore) Save(_ context.Context, _ string, _ secretstore.Credential) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s staticCredentialStore) Load(_ context.Context, _ string) (secretstore.Credential, error) {
|
||||
return s.credential, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
4
mcp.toml
Normal 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"
|
||||
Loading…
Reference in a new issue