Compare commits

..

No commits in common. "main" and "v2.0.0" have entirely different histories.
main ... v2.0.0

21 changed files with 113 additions and 2613 deletions

View file

@ -12,7 +12,6 @@ jobs:
env:
BINARY_NAME: email-mcp
BUILD_PATH: build/email-mcp-linux-amd64
CHECKSUM_PATH: build/email-mcp-linux-amd64.sha256
MANIFEST_PATH: mcp.toml
steps:
@ -29,14 +28,6 @@ jobs:
- name: Build linux amd64 binary
run: make build GOOS=linux GOARCH=amd64
- name: Generate binary checksum
run: |
set -euo pipefail
asset_name="$(basename "${BUILD_PATH}")"
checksum_value="$(sha256sum "${BUILD_PATH}" | cut -d' ' -f1)"
printf '%s %s\n' "${checksum_value}" "${asset_name}" > "${CHECKSUM_PATH}"
- name: Generate release notes
id: release_notes
env:
@ -174,30 +165,3 @@ jobs:
cat asset.json >&2
exit 1
fi
- name: Upload checksum 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 "${CHECKSUM_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 @"${CHECKSUM_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

@ -14,7 +14,7 @@ endif
OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
.PHONY: build test generate generate-check
.PHONY: build test
build:
@mkdir -p $(BUILD_DIR) $(GOCACHE)
@ -23,11 +23,3 @@ build:
test:
@mkdir -p $(GOCACHE)
GOCACHE=$(GOCACHE) go test ./...
generate:
@mkdir -p $(GOCACHE)
GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate
generate-check:
@mkdir -p $(GOCACHE)
GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate --check

106
README.md
View file

@ -6,23 +6,16 @@ Le binaire sappuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
- la gestion de profils CLI
- le stockage JSON de configuration dans `os.UserConfigDir()`
- le stockage du mot de passe dans Bitwarden via `bw`
- le stockage du mot de passe dans le wallet natif de lOS
- le manifeste `mcp.toml`
- les helpers Go générés depuis `mcp.toml` (`mcpgen/`)
- lauto-update via `email-mcp update`
## Commandes
- `email-mcp setup` : configure (ou met à jour) un profil IMAP
- `email-mcp config show` : affiche la configuration IMAP résolue et la provenance
- `email-mcp config test` : lance les checks de configuration/connectivité (équivalent de `doctor`)
- `email-mcp config delete` : supprime un profil local et son mot de passe stocké
- `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 doctor` : diagnostique la configuration locale, Bitwarden, le manifeste et laccès IMAP
- `email-mcp update` : met à jour le binaire courant depuis la dernière release
- `email-mcp version` : affiche la version du binaire
La commande `email-mcp help` (ou `-h` / `--help`) affiche laide globale.
## Outils MCP
@ -35,32 +28,25 @@ La commande `email-mcp help` (ou `-h` / `--help`) affiche laide globale.
La configuration est séparée en deux parties :
- `host` et `username` sont stockés dans `config.json`
- `password` est stocké dans Bitwarden via le CLI `bw`
- `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. `[profiles].default` dans `mcp.toml`
5. `default`
Les credentials IMAP sont résolus ensuite via les champs `[[config.fields]]` du manifeste et les helpers générés par le framework :
1. `host` : `EMAIL_MCP_HOST` puis `config.json`
2. `username` : `EMAIL_MCP_USERNAME` puis `config.json`
3. `password` : `EMAIL_MCP_PASSWORD` puis secret Bitwarden `imap-password/<profile>`
4. `default`
### Configurer un profil
```sh
./email-mcp setup
./email-mcp config
```
Pour un profil nommé :
```sh
./email-mcp setup --profile work
./email-mcp config --profile work
```
Le binaire demande ensuite :
@ -69,9 +55,7 @@ Le binaire demande ensuite :
2. le nom dutilisateur
3. le mot de passe
Si un mot de passe existe déjà dans Bitwarden, laisser le champ vide le conserve.
Le backend de secrets déclaré dans `mcp.toml` est `bitwarden-cli`. Le CLI `bw` doit être installé, connecté et déverrouillé avec `BW_SESSION` disponible dans lenvironnement. `EMAIL_MCP_PASSWORD` reste accepté pour fournir le mot de passe sans lire Bitwarden.
Si un mot de passe existe déjà dans le wallet, laisser le champ vide le conserve.
### Lancer le serveur MCP
@ -88,21 +72,7 @@ Pour un profil nommé :
Si aucun credential na été configuré pour le profil résolu, le serveur renvoie lerreur :
```text
credentials not configured; run `email-mcp setup`
```
### Inspecter la configuration résolue
```sh
./email-mcp config show
./email-mcp config show --profile work
```
### Tester la configuration résolue
```sh
./email-mcp config test
./email-mcp config test --profile work
credentials not configured; run `email-mcp config`
```
## Auto-update
@ -113,61 +83,17 @@ credentials not configured; run `email-mcp setup`
./email-mcp update
```
Le manifeste de ce repo utilise le driver Gitea du framework :
Le manifeste de ce repo pointe vers lendpoint Gitea :
```toml
binary_name = "email-mcp"
[update]
source_name = "email-mcp releases"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
token_env_names = ["GITEA_TOKEN"]
latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest"
```
## Diagnostic
`email-mcp doctor` vérifie :
- la lisibilité du fichier de configuration
- le profil IMAP résolu
- la disponibilité du backend Bitwarden
- la présence du mot de passe stocké
- la validité du manifeste `mcp.toml`
- la connectivité IMAP avec les credentials résolus
```sh
./email-mcp doctor
./email-mcp doctor --profile work
```
La commande retourne un code de sortie non nul si au moins un check échoue.
Pour lupdate, la validation du manifeste accepte :
- soit `update.latest_release_url`
- soit un couple driver/référentiel (`update.driver`, `update.repository`) avec les champs requis (ex. `update.base_url` pour Gitea)
## Installation
### Installateur interactif
Le repo inclut un assistant interactif `install.sh` :
```sh
./install.sh
```
Tu peux aussi lexécuter directement depuis la branche `main` :
```sh
curl -fsSL https://gitea.lclr.dev/AI/email-mcp/raw/branch/main/install.sh | bash
```
### Claude Code CLI
Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` :
@ -176,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
```
La configuration se fait une fois séparément via `email-mcp setup`.
La configuration se fait une fois séparément via `email-mcp config`.
### Configuration JSON manuelle
@ -200,7 +126,6 @@ Une release est générée automatiquement quand tu pousses un tag `v*` sur le r
Les assets publiés sont :
- `build/email-mcp-linux-amd64`
- `build/email-mcp-linux-amd64.sha256`
- `mcp.toml`
## Compiler depuis les sources
@ -222,10 +147,3 @@ Pour lancer les tests :
```sh
make test
```
Pour régénérer la glue framework après une modification de `mcp.toml` :
```sh
make generate
make generate-check
```

2
go.mod
View file

@ -3,7 +3,7 @@ module email-mcp
go 1.25.0
require (
forge.lclr.dev/AI/mcp-framework v1.13.0
gitea.lclr.dev/AI/mcp-framework v1.1.0
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

10
go.sum
View file

@ -1,11 +1,5 @@
forge.lclr.dev/AI/mcp-framework v1.9.0 h1:8i2CHQlQo/mRG1BE2UArHptAa/HC7AOhZBIqz8md8Vk=
forge.lclr.dev/AI/mcp-framework v1.9.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
forge.lclr.dev/AI/mcp-framework v1.10.0 h1:RrTy7K/hSruaVS9Z/oaRpkLs2U5WGs4H3tox7PiErak=
forge.lclr.dev/AI/mcp-framework v1.10.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
forge.lclr.dev/AI/mcp-framework v1.12.0 h1:pu1cfWcL62BF+f7DBe4IbkigHLcK6YOJ3vEBz1495AY=
forge.lclr.dev/AI/mcp-framework v1.12.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
forge.lclr.dev/AI/mcp-framework v1.13.0 h1:YfC/AqdzTHGRgtZxMl7CfDN+duFezyQ4nkX9uTD+HX0=
forge.lclr.dev/AI/mcp-framework v1.13.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
gitea.lclr.dev/AI/mcp-framework v1.1.0 h1:/7fqiXmhir2mDxg3s1ReKrsnFNtSaBN7eQl5zXyYcdc=
gitea.lclr.dev/AI/mcp-framework v1.1.0/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4=
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=

View file

@ -1,625 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BINARY_NAME="email-mcp"
DEFAULT_PROFILE="default"
PROFILE_ENV="EMAIL_MCP_PROFILE"
RELEASE_BASE_URL="https://forge.lclr.dev"
RELEASE_REPOSITORY="AI/email-mcp"
INSTALLED_BINARY_PATH=""
PREFILL_SERVER_NAME=""
PREFILL_PROFILE_VALUE=""
PREFILL_COMMAND_PATH=""
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
C_RESET="$(printf '\033[0m')"
C_BOLD="$(printf '\033[1m')"
C_DIM="$(printf '\033[2m')"
C_RED="$(printf '\033[31m')"
C_GREEN="$(printf '\033[32m')"
C_YELLOW="$(printf '\033[33m')"
C_BLUE="$(printf '\033[34m')"
C_MAGENTA="$(printf '\033[35m')"
C_CYAN="$(printf '\033[36m')"
else
C_RESET=""
C_BOLD=""
C_DIM=""
C_RED=""
C_GREEN=""
C_YELLOW=""
C_BLUE=""
C_MAGENTA=""
C_CYAN=""
fi
ui_line() {
printf "%b%s%b\n" "$C_DIM" "------------------------------------------------------------" "$C_RESET" >&2
}
ui_title() {
printf "\n%b%s%b\n" "$C_BOLD$C_CYAN" "$1" "$C_RESET" >&2
}
ui_info() {
printf "%b[info]%b %s\n" "$C_BLUE" "$C_RESET" "$1" >&2
}
ui_success() {
printf "%b[ok]%b %s\n" "$C_GREEN" "$C_RESET" "$1" >&2
}
ui_warn() {
printf "%b[warn]%b %s\n" "$C_YELLOW" "$C_RESET" "$1" >&2
}
ui_error() {
printf "%b[error]%b %s\n" "$C_RED" "$C_RESET" "$1" >&2
}
prompt() {
local label="$1"
local default_value="$2"
local answer=""
if [ -n "$default_value" ]; then
printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2
else
printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2
fi
if [ -t 2 ] && [ -r /dev/tty ]; then
if ! IFS= read -r answer < /dev/tty 2>/dev/null; then
IFS= read -r answer || answer=""
fi
else
IFS= read -r answer || answer=""
fi
if [ -z "$answer" ]; then
printf "%s" "$default_value"
return
fi
printf "%s" "$answer"
}
tty_prompt_available() {
[ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]
}
menu_select() {
local title="$1"
shift
local options=("$@")
local count="${#options[@]}"
local index=0
local key=""
local i=0
local rows=$((count + 3))
local rendered=0
if [ "$count" -eq 0 ]; then
return 1
fi
if ! tty_prompt_available; then
ui_title "$title"
i=0
while [ "$i" -lt "$count" ]; do
printf " %d) %s\n" "$((i + 1))" "${options[$i]}" >&2
i=$((i + 1))
done
while true; do
local raw_choice
raw_choice="$(prompt "Choix" "1")"
case "$raw_choice" in
''|*[!0-9]*)
ui_warn "Choix invalide: $raw_choice"
;;
*)
if [ "$raw_choice" -ge 1 ] && [ "$raw_choice" -le "$count" ]; then
printf "%s" "${options[$((raw_choice - 1))]}"
return 0
fi
ui_warn "Choix invalide: $raw_choice"
;;
esac
done
fi
while true; do
if [ "$rendered" -eq 1 ]; then
printf "\033[%dA\033[J" "$rows" >&2 2>/dev/null || true
fi
ui_title "$title"
i=0
while [ "$i" -lt "$count" ]; do
if [ "$i" -eq "$index" ]; then
printf " %b %s%b\n" "$C_BOLD$C_CYAN" "${options[$i]}" "$C_RESET" >&2
else
printf " %s\n" "${options[$i]}" >&2
fi
i=$((i + 1))
done
printf "%bUtilise ↑/↓ puis Entrée.%b\n" "$C_DIM" "$C_RESET" >&2
rendered=1
if ! IFS= read -rsn1 key < /dev/tty; then
continue
fi
case "$key" in
"")
printf "%s" "${options[$index]}"
return 0
;;
$'\x1b')
if IFS= read -rsn2 key < /dev/tty; then
case "$key" in
"[A")
if [ "$index" -eq 0 ]; then
index=$((count - 1))
else
index=$((index - 1))
fi
;;
"[B")
if [ "$index" -eq $((count - 1)) ]; then
index=0
else
index=$((index + 1))
fi
;;
esac
fi
;;
esac
done
}
sanitize_server_name() {
local raw="$1"
local sanitized
sanitized="$(printf "%s" "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//')"
if [ -z "$sanitized" ]; then
sanitized="$BINARY_NAME"
fi
printf "%s" "$sanitized"
}
toml_escape() {
printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
go_bin_dir() {
local gobin
gobin="$(go env GOBIN 2>/dev/null || true)"
if [ -n "$gobin" ]; then
printf "%s\n" "$gobin"
return
fi
go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}'
}
resolve_binary_path() {
if [ -n "$INSTALLED_BINARY_PATH" ] && [ -x "$INSTALLED_BINARY_PATH" ]; then
printf "%s\n" "$INSTALLED_BINARY_PATH"
return
fi
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
command -v "$BINARY_NAME"
return
fi
if command -v go >/dev/null 2>&1; then
local bin_dir
bin_dir="$(go_bin_dir)"
if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then
printf "%s\n" "$bin_dir/$BINARY_NAME"
return
fi
fi
printf "%s\n" "$HOME/.local/bin/$BINARY_NAME"
}
ensure_cli() {
local cli_name="$1"
if command -v "$cli_name" >/dev/null 2>&1; then
return
fi
ui_error "Commande introuvable: $cli_name"
exit 1
}
detect_os() {
case "$(uname -s | tr '[:upper:]' '[:lower:]')" in
linux) printf "linux" ;;
darwin) printf "darwin" ;;
*)
ui_error "OS non supporte: $(uname -s)"
exit 1
;;
esac
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) printf "amd64" ;;
aarch64|arm64) printf "arm64" ;;
*)
ui_error "Architecture non supportee: $(uname -m)"
exit 1
;;
esac
}
release_api_url() {
printf "%s/api/v1/repos/%s/releases/latest\n" "${RELEASE_BASE_URL%/}" "$RELEASE_REPOSITORY"
}
extract_asset_url() {
local json="$1"
local asset_name="$2"
printf "%s\n" "$json" \
| grep -o '"browser_download_url":"[^"]*"' \
| sed 's/"browser_download_url":"//;s/"$//' \
| sed 's#\\/#/#g' \
| grep "/${asset_name}$" \
| head -n 1
}
verify_checksum() {
local binary_path="$1"
local checksum_path="$2"
if command -v sha256sum >/dev/null 2>&1; then
( cd "$(dirname "$binary_path")" && sha256sum -c "$(basename "$checksum_path")" )
return
fi
if command -v shasum >/dev/null 2>&1; then
local expected
local actual
expected="$(awk '{print $1}' "$checksum_path")"
actual="$(shasum -a 256 "$binary_path" | awk '{print $1}')"
if [ "$expected" != "$actual" ]; then
ui_error "Checksum invalide pour $binary_path"
exit 1
fi
return
fi
ui_warn "Aucun utilitaire checksum trouve (sha256sum/shasum), verification ignoree."
}
download_latest_release_binary() {
if ! command -v curl >/dev/null 2>&1; then
ui_error "curl est requis pour telecharger la release."
exit 1
fi
local os_name
local arch_name
local ext=""
os_name="$(detect_os)"
arch_name="$(detect_arch)"
if [ "$os_name" = "windows" ]; then
ext=".exe"
fi
local asset_name
local checksum_name
asset_name="${BINARY_NAME}-${os_name}-${arch_name}${ext}"
checksum_name="${asset_name}.sha256"
ui_info "Recuperation de la derniere release..."
local release_json
release_json="$(curl -fsSL "$(release_api_url)")"
local asset_url
asset_url="$(extract_asset_url "$release_json" "$asset_name")"
if [ -z "$asset_url" ]; then
ui_error "Asset introuvable dans la derniere release: $asset_name"
exit 1
fi
local checksum_url
checksum_url="$(extract_asset_url "$release_json" "$checksum_name" || true)"
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'if [ -n "${tmp_dir:-}" ] && [ -d "${tmp_dir:-}" ]; then rm -rf "${tmp_dir}"; fi' EXIT INT TERM
curl -fsSL "$asset_url" -o "$tmp_dir/$asset_name"
chmod +x "$tmp_dir/$asset_name"
if [ -n "$checksum_url" ]; then
curl -fsSL "$checksum_url" -o "$tmp_dir/$checksum_name"
verify_checksum "$tmp_dir/$asset_name" "$tmp_dir/$checksum_name"
fi
local target_dir
target_dir="$(prompt "Repertoire d'installation" "$HOME/.local/bin")"
mkdir -p "$target_dir"
install -m 0755 "$tmp_dir/$asset_name" "$target_dir/$BINARY_NAME"
INSTALLED_BINARY_PATH="$target_dir/$BINARY_NAME"
ui_success "Binaire installe: $INSTALLED_BINARY_PATH"
ui_info "Ajoute ce dossier au PATH si necessaire."
}
install_binary() {
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
INSTALLED_BINARY_PATH="$(command -v "$BINARY_NAME")"
ui_success "Binaire detecte: $INSTALLED_BINARY_PATH"
local reinstall
reinstall="$(prompt "Forcer une reinstall depuis la derniere release ? (y/N)" "N")"
case "$reinstall" in
y|Y|yes|YES)
download_latest_release_binary
return
;;
*)
return
;;
esac
fi
download_latest_release_binary
}
run_setup_wizard() {
install_binary
local profile
profile="$(prompt "Profil a configurer (${PROFILE_ENV})" "$DEFAULT_PROFILE")"
local binary_path
binary_path="$(resolve_binary_path)"
if [ ! -x "$binary_path" ]; then
ui_error "Binaire introuvable/executable: $binary_path"
exit 1
fi
ui_info "Déverrouillage Bitwarden avant le setup..."
if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
env "${PROFILE_ENV}=${profile}" "$binary_path" login < /dev/tty > /dev/tty
else
env "${PROFILE_ENV}=${profile}" "$binary_path" login
fi
ui_info "Lancement de $BINARY_NAME setup"
if [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
env "${PROFILE_ENV}=${profile}" "$binary_path" setup < /dev/tty > /dev/tty
else
env "${PROFILE_ENV}=${profile}" "$binary_path" setup
fi
ui_success "Setup termine pour le profil \"$profile\"."
PREFILL_SERVER_NAME="$(sanitize_server_name "$BINARY_NAME")"
PREFILL_PROFILE_VALUE="$profile"
PREFILL_COMMAND_PATH="$binary_path"
}
collect_server_inputs() {
local default_name
default_name="$(sanitize_server_name "${PREFILL_SERVER_NAME:-$BINARY_NAME}")"
SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")"
SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")"
PROFILE_VALUE="$(prompt "Valeur de ${PROFILE_ENV}" "${PREFILL_PROFILE_VALUE:-$DEFAULT_PROFILE}")"
local default_command
if [ -n "${PREFILL_COMMAND_PATH:-}" ]; then
default_command="$PREFILL_COMMAND_PATH"
else
default_command="$(resolve_binary_path)"
fi
COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")"
}
choose_scope() {
local selected
selected="$(menu_select "Scope de configuration" "global (user)" "project (projet courant)")"
case "$selected" in
"global (user)")
printf "global"
;;
*)
printf "project"
;;
esac
}
apply_claude_mcp() {
ensure_cli "claude"
collect_server_inputs
local scope_choice
scope_choice="$(choose_scope)"
local claude_scope
if [ "$scope_choice" = "global" ]; then
claude_scope="user"
else
claude_scope="project"
fi
ui_info "Application de la configuration Claude ($claude_scope)..."
claude mcp remove --scope "$claude_scope" "$SERVER_NAME" >/dev/null 2>&1 || true
claude mcp add \
--transport stdio \
--scope "$claude_scope" \
"$SERVER_NAME" \
--env "${PROFILE_ENV}=${PROFILE_VALUE}" \
-- "$COMMAND_PATH" mcp
ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)."
}
rewrite_codex_project_config() {
local project_dir="$1"
local config_file="$project_dir/.codex/config.toml"
local section_prefix="[mcp_servers.${SERVER_NAME}"
mkdir -p "$project_dir/.codex"
touch "$config_file"
local tmp_file
tmp_file="$(mktemp)"
awk -v prefix="$section_prefix" '
function is_target(line, p, next_char) {
if (index(line, p) != 1) {
return 0
}
next_char = substr(line, length(p) + 1, 1)
return next_char == "]" || next_char == "."
}
/^\[.*\]$/ {
if (is_target($0, prefix)) {
skip = 1
next
}
if (skip == 1) {
skip = 0
}
}
{
if (skip != 1) {
print $0
}
}
' "$config_file" > "$tmp_file"
mv "$tmp_file" "$config_file"
{
printf "\n[mcp_servers.%s]\n" "$SERVER_NAME"
printf "command = \"%s\"\n" "$(toml_escape "$COMMAND_PATH")"
printf "args = [\"mcp\"]\n\n"
printf "[mcp_servers.%s.env]\n" "$SERVER_NAME"
printf "%s = \"%s\"\n" "$PROFILE_ENV" "$(toml_escape "$PROFILE_VALUE")"
} >> "$config_file"
}
apply_codex_mcp() {
ensure_cli "codex"
collect_server_inputs
local scope_choice
scope_choice="$(choose_scope)"
if [ "$scope_choice" = "global" ]; then
ui_info "Application via codex mcp add (scope global)..."
codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true
codex mcp add \
"$SERVER_NAME" \
--env "${PROFILE_ENV}=${PROFILE_VALUE}" \
-- "$COMMAND_PATH" mcp
ui_success "Serveur \"$SERVER_NAME\" configure dans le scope global Codex."
return
fi
local default_project_dir
default_project_dir="$(pwd)"
local project_dir
project_dir="$(prompt "Dossier projet cible pour .codex/config.toml" "$default_project_dir")"
rewrite_codex_project_config "$project_dir"
ui_success "Configuration projet ecrite dans $project_dir/.codex/config.toml"
}
print_mcp_json() {
collect_server_inputs
cat <<JSON
{
"mcpServers": {
"${SERVER_NAME}": {
"command": "${COMMAND_PATH}",
"args": ["mcp"],
"env": {
"${PROFILE_ENV}": "${PROFILE_VALUE}"
}
}
}
}
JSON
}
print_header() {
ui_line
printf "%bMCP Install Wizard%b for %b%s%b\n" "$C_BOLD$C_MAGENTA" "$C_RESET" "$C_BOLD" "$BINARY_NAME" "$C_RESET" >&2
printf "%bRelease repo:%b %s\n" "$C_DIM" "$C_RESET" "$RELEASE_REPOSITORY" >&2
ui_line
printf "%bSelectionne une action dans le menu interactif.%b\n" "$C_DIM" "$C_RESET" >&2
}
post_setup_configure_mcp() {
ui_title "Configuration MCP apres setup"
local next_action
next_action="$(menu_select \
"Configurer le MCP maintenant ?" \
"Configurer Claude Code (apply direct)" \
"Configurer Codex (apply direct)" \
"Generer JSON MCP manuel" \
"Terminer sans config MCP")"
printf "\n" >&2
case "$next_action" in
"Configurer Claude Code (apply direct)")
apply_claude_mcp
;;
"Configurer Codex (apply direct)")
apply_codex_mcp
;;
"Generer JSON MCP manuel")
ui_info "JSON MCP genere sur stdout."
print_mcp_json
;;
*)
ui_info "Setup termine sans configuration MCP additionnelle."
;;
esac
}
main() {
print_header
local action
action="$(menu_select \
"Choisis une action" \
"Installer/mettre a jour le binaire + setup" \
"Configurer Claude Code (apply direct)" \
"Configurer Codex (apply direct)" \
"Generer JSON MCP manuel" \
"Quitter")"
printf "\n" >&2
case "$action" in
"Installer/mettre a jour le binaire + setup")
run_setup_wizard
post_setup_configure_mcp
;;
"Configurer Claude Code (apply direct)")
apply_claude_mcp
;;
"Configurer Codex (apply direct)")
apply_codex_mcp
;;
"Generer JSON MCP manuel")
ui_info "JSON MCP genere sur stdout."
print_mcp_json
;;
*)
ui_warn "Annule."
;;
esac
}
main "$@"

View file

@ -8,28 +8,21 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strings"
"email-mcp/mcpgen"
frameworkbootstrap "forge.lclr.dev/AI/mcp-framework/bootstrap"
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
frameworkupdate "forge.lclr.dev/AI/mcp-framework/update"
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"
)
const (
binaryName = mcpgen.BinaryName
binaryName = "email-mcp"
defaultProfileEnv = "EMAIL_MCP_PROFILE"
hostEnv = "EMAIL_MCP_HOST"
usernameEnv = "EMAIL_MCP_USERNAME"
passwordEnv = "EMAIL_MCP_PASSWORD"
fallbackProfile = "default"
)
type MCPRunner interface {
@ -45,7 +38,11 @@ type profileConfigStore interface {
SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error)
}
type secretStore = frameworksecretstore.Store
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)
@ -115,46 +112,20 @@ func NewAppWithDependencies(
}
func (a *App) Run(args []string) error {
if args == nil {
args = []string{}
if len(args) == 0 {
return fmt.Errorf("usage: email-mcp <config|setup|mcp|update>")
}
return a.runBootstrap(context.Background(), args)
}
func (a *App) runBootstrap(ctx context.Context, args []string) error {
metadata := a.runtimeMetadata()
return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{
BinaryName: metadata.BinaryName,
Description: metadata.Description,
Version: a.version,
EnableDoctorAlias: true,
Args: args,
Stdin: a.stdin,
Stdout: a.stdout,
Stderr: a.stderr,
Hooks: frameworkbootstrap.Hooks{
Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args)
},
Login: frameworkbootstrap.BitwardenLoginHandler(metadata.BinaryName),
MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runMCP(ctx, inv.Args)
},
ConfigShow: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runConfigShow(ctx, inv.Args)
},
ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runDoctor(ctx, inv.Args)
},
ConfigDelete: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runConfigDelete(ctx, inv.Args)
},
Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runUpdate(ctx, inv.Args)
},
},
})
switch args[0] {
case "config", "setup":
return a.runConfig(context.Background(), args[0], args[1:])
case "mcp":
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) runConfig(ctx context.Context, command string, args []string) error {
@ -178,7 +149,7 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
return err
}
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
secrets, err := a.openSecretStore()
@ -203,23 +174,8 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
return err
}
if shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) {
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
switch {
case errors.Is(err, frameworksecretstore.ErrReadOnly):
if strings.TrimSpace(os.Getenv(passwordEnv)) == "" {
return newUserFacingError(
fmt.Sprintf("secret backend is read-only; set %s and rerun `email-mcp setup`", passwordEnv),
err,
)
}
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; password is provided via %s\n", passwordEnv); writeErr != nil {
return writeErr
}
default:
return mapAppError(err)
}
}
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil {
return mapAppError(err)
}
if cfg.Profiles == nil {
@ -239,118 +195,6 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
return nil
}
func (a *App) runConfigShow(ctx context.Context, args []string) error {
if a.configStore == nil {
return fmt.Errorf("config store is not configured")
}
if a.openSecretStore == nil {
return fmt.Errorf("secret store is not configured")
}
profileFlag, err := parseProfileArgs("config show", args)
if err != nil {
return err
}
cfg, _, err := a.configStore.LoadDefault()
if err != nil {
return err
}
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
secrets, err := a.openSecretStore()
if err != nil {
return mapAppError(err)
}
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError
if !errors.As(err, &missingErr) {
return mapAppError(err)
}
}
host, _ := resolution.Get("host")
username, _ := resolution.Get("username")
password, _ := resolution.Get("password")
if _, err := fmt.Fprintf(a.stdout, "profile: %s\n", profileName); err != nil {
return err
}
if _, err := fmt.Fprintf(a.stdout, "host: %s (%s)\n", renderVisibleField(host), renderSource(host)); err != nil {
return err
}
if _, err := fmt.Fprintf(a.stdout, "username: %s (%s)\n", renderVisibleField(username), renderSource(username)); err != nil {
return err
}
if _, err := fmt.Fprintf(a.stdout, "password: %s (%s)\n", renderSecretField(password), renderSource(password)); err != nil {
return err
}
return nil
}
func (a *App) runConfigDelete(_ context.Context, args []string) error {
if a.configStore == nil {
return fmt.Errorf("config store is not configured")
}
if a.openSecretStore == nil {
return fmt.Errorf("secret store is not configured")
}
profileFlag, err := parseProfileArgs("config delete", args)
if err != nil {
return err
}
cfg, _, err := a.configStore.LoadDefault()
if err != nil {
return err
}
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
secrets, err := a.openSecretStore()
if err != nil {
return mapAppError(err)
}
if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil {
switch {
case errors.Is(err, frameworksecretstore.ErrNotFound):
case errors.Is(err, frameworksecretstore.ErrReadOnly):
if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; %s cannot be deleted automatically\n", passwordEnv); writeErr != nil {
return writeErr
}
default:
return mapAppError(err)
}
}
if cfg.Profiles != nil {
delete(cfg.Profiles, profileName)
}
if strings.TrimSpace(cfg.CurrentProfile) == profileName {
cfg.CurrentProfile = nextCurrentProfile(cfg.Profiles, a.runtimeMetadata().DefaultProfile)
}
configPath, err := a.configStore.SaveDefault(cfg)
if err != nil {
return err
}
if _, err := fmt.Fprintf(a.stdout, "profile %q deleted from %s\n", profileName, configPath); err != nil {
return err
}
if cfg.CurrentProfile != "" {
if _, err := fmt.Fprintf(a.stdout, "current profile: %s\n", cfg.CurrentProfile); err != nil {
return err
}
}
return nil
}
func (a *App) runMCP(ctx context.Context, args []string) error {
if a.newRunner == nil {
return fmt.Errorf("mcp runner is not configured")
@ -394,13 +238,18 @@ func (a *App) runUpdate(ctx context.Context, args []string) error {
return fmt.Errorf("resolve executable path: %w", err)
}
options, err := mcpgen.UpdateOptionsFrom(filepath.Dir(executablePath), a.version, a.stdout)
manifestFile, err := a.loadManifestForExecutable(executablePath)
if err != nil {
return err
}
options.ExecutablePath = executablePath
return frameworkupdate.Run(ctx, options)
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) {
@ -433,31 +282,29 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
return secretstore.Credential{}, err
}
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
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
}
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
password, _, err := loadStoredPassword(secrets, profileName)
if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError
if errors.As(err, &missingErr) {
return secretstore.Credential{}, fmt.Errorf(
"%w: profile %q is incomplete (missing: %s)",
mcpserver.ErrCredentialsNotConfigured,
profileName,
strings.Join(missingErr.Fields, ", "),
)
if errors.Is(err, frameworksecretstore.ErrNotFound) {
return secretstore.Credential{}, fmt.Errorf("%w: profile %q", mcpserver.ErrCredentialsNotConfigured, profileName)
}
return secretstore.Credential{}, err
}
cred, err := credentialFromResolution(resolution)
if err != nil {
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)
@ -466,65 +313,6 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
return cred, nil
}
func profileFieldSpecs(profileName string) []frameworkcli.FieldSpec {
specs := mcpgen.ResolveFieldSpecs(profileName)
profileSpecs := make([]frameworkcli.FieldSpec, 0, len(specs))
for _, spec := range specs {
if spec.Name == "host" || spec.Name == "username" {
profileSpecs = append(profileSpecs, spec)
}
}
return profileSpecs
}
func passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec {
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
if spec.Name == "password" {
return []frameworkcli.FieldSpec{spec}
}
}
return nil
}
func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) {
configValues := map[string]string{
"host": profile.Host,
"username": profile.Username,
}
return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{
Fields: fields,
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
Env: frameworkcli.EnvLookup(os.LookupEnv),
Config: frameworkcli.ConfigMap(configValues),
Secret: frameworkcli.SecretStore(store),
}),
})
}
func credentialFromResolution(resolution frameworkcli.Resolution) (secretstore.Credential, error) {
host, ok := resolution.Get("host")
if !ok {
return secretstore.Credential{}, fmt.Errorf("resolve credential: host field is missing from resolution")
}
username, ok := resolution.Get("username")
if !ok {
return secretstore.Credential{}, fmt.Errorf("resolve credential: username field is missing from resolution")
}
password, ok := resolution.Get("password")
if !ok {
return secretstore.Credential{}, fmt.Errorf("resolve credential: password field is missing from resolution")
}
return secretstore.Credential{
Host: host.Value,
Username: username.Value,
Password: password.Value,
}, nil
}
func loadStoredPassword(store secretStore, profileName string) (string, bool, error) {
password, err := store.GetSecret(passwordSecretName(profileName))
if err != nil {
@ -538,21 +326,9 @@ func loadStoredPassword(store secretStore, profileName string) (string, bool, er
}
func passwordSecretName(profileName string) string {
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
if spec.Name == "password" && strings.TrimSpace(spec.SecretKey) != "" {
return spec.SecretKey
}
}
return "imap-password/" + strings.TrimSpace(profileName)
}
func shouldPersistPassword(hasStoredPassword bool, storedPassword, newPassword string) bool {
if !hasStoredPassword {
return true
}
return storedPassword != newPassword
}
func parseProfileArgs(command string, args []string) (string, error) {
flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
flagSet.SetOutput(io.Discard)
@ -582,103 +358,6 @@ func parseUpdateArgs(args []string) error {
return nil
}
type runtimeMetadata struct {
BinaryName string
Description string
DefaultProfile string
}
func (a *App) runtimeMetadata() runtimeMetadata {
metadata := runtimeMetadata{
BinaryName: mcpgen.BinaryName,
Description: mcpgen.DefaultDescription,
DefaultProfile: fallbackProfile,
}
if a.loadManifest == nil {
return metadata
}
file, err := a.loadRuntimeManifest()
if err != nil {
return metadata
}
bootstrap := file.BootstrapInfo()
if bootstrap.BinaryName != "" {
metadata.BinaryName = bootstrap.BinaryName
}
if bootstrap.Description != "" {
metadata.Description = bootstrap.Description
}
if bootstrap.DefaultProfile != "" {
metadata.DefaultProfile = bootstrap.DefaultProfile
}
return metadata
}
func (a *App) loadRuntimeManifest() (frameworkmanifest.File, error) {
if a.loadManifest == nil {
return frameworkmanifest.File{}, fmt.Errorf("manifest loader is not configured")
}
if a.resolveExecutable != nil {
executablePath, err := a.resolveExecutable()
if err == nil {
file, loadErr := a.loadManifestForExecutable(executablePath)
if loadErr == nil {
return file, nil
}
}
}
file, _, err := a.loadManifest(".")
if err != nil {
return frameworkmanifest.File{}, err
}
return file, nil
}
func (a *App) resolveProfileName(profileFlag, currentProfile string) string {
resolvedCurrent := strings.TrimSpace(currentProfile)
if resolvedCurrent == "" {
resolvedCurrent = strings.TrimSpace(a.runtimeMetadata().DefaultProfile)
}
return frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), resolvedCurrent)
}
func nextCurrentProfile(profiles map[string]ProfileConfig, preferred string) string {
if len(profiles) == 0 {
return ""
}
normalizedPreferred := strings.TrimSpace(preferred)
if normalizedPreferred != "" {
if _, ok := profiles[normalizedPreferred]; ok {
return normalizedPreferred
}
}
if _, ok := profiles[fallbackProfile]; ok {
return fallbackProfile
}
names := make([]string, 0, len(profiles))
for name := range profiles {
if trimmed := strings.TrimSpace(name); trimmed != "" {
names = append(names, trimmed)
}
}
if len(names) == 0 {
return ""
}
sort.Strings(names)
return names[0]
}
func mapAppError(err error) error {
if err == nil {
return nil
@ -686,9 +365,12 @@ func mapAppError(err error) error {
switch {
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
return newUserFacingError("credentials not configured; run `email-mcp config`", err)
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
return newUserFacingError(strings.TrimSpace(err.Error()), err)
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:
@ -696,27 +378,6 @@ func mapAppError(err error) error {
}
}
func renderSource(field frameworkcli.ResolvedField) string {
if !field.Found {
return "missing"
}
return string(field.Source)
}
func renderVisibleField(field frameworkcli.ResolvedField) string {
if !field.Found || strings.TrimSpace(field.Value) == "" {
return "<missing>"
}
return field.Value
}
func renderSecretField(field frameworkcli.ResolvedField) string {
if !field.Found || strings.TrimSpace(field.Value) == "" {
return "<missing>"
}
return "<set>"
}
type userFacingError struct {
message string
err error

View file

@ -11,9 +11,9 @@ import (
"strings"
"testing"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
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"
@ -87,12 +87,9 @@ type secretStoreStub struct {
values map[string]string
setErr error
getErr error
deleteErr error
setName string
setValue string
setCalled bool
delName string
delCalled bool
}
func (s *secretStoreStub) SetSecret(name, _ string, secret string) error {
@ -121,11 +118,6 @@ func (s *secretStoreStub) GetSecret(name string) (string, error) {
}
func (s *secretStoreStub) DeleteSecret(name string) error {
s.delCalled = true
s.delName = name
if s.deleteErr != nil {
return s.deleteErr
}
delete(s.values, name)
return nil
}
@ -150,93 +142,18 @@ func TestAppRunRejectsUnknownCommand(t *testing.T) {
}
func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
output := &bytes.Buffer{}
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev")
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
if err := app.Run(nil); err != nil {
t.Fatalf("expected help to be rendered, got error %v", err)
err := app.Run(nil)
if err == nil {
t.Fatal("expected error for missing command")
}
text := output.String()
for _, snippet := range []string{"Usage:", "config", "version", `doctor Diagnostiquer la configuration locale. (alias de "config test").`} {
if !strings.Contains(text, snippet) {
t.Fatalf("help output missing %q: %q", snippet, text)
}
if !strings.Contains(err.Error(), "usage:") {
t.Fatalf("expected usage text in error, got %q", err.Error())
}
}
func TestAppRunShowsManifestBootstrapMetadataInHelp(t *testing.T) {
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
nil,
nil,
nil,
nil,
func(string) (frameworkmanifest.File, string, error) {
return frameworkmanifest.File{
BinaryName: "email-mcp-custom",
Bootstrap: frameworkmanifest.Bootstrap{
Description: "Custom manifest description",
},
}, "/tmp/mcp.toml", nil
},
func() (string, error) { return "/tmp/bin/email-mcp-custom", nil },
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run(nil); err != nil {
t.Fatalf("expected help to be rendered, got error %v", err)
}
text := output.String()
for _, snippet := range []string{"Custom manifest description", "email-mcp-custom <command>", "email-mcp-custom help <command>"} {
if !strings.Contains(text, snippet) {
t.Fatalf("help output missing %q: %q", snippet, text)
}
}
}
func TestAppRunVersionPrintsBuildVersion(t *testing.T) {
output := &bytes.Buffer{}
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "v1.2.3")
if err := app.Run([]string{"version"}); err != nil {
t.Fatalf("version returned error: %v", err)
}
if got := output.String(); got != "v1.2.3\n" {
t.Fatalf("version output = %q, want %q", got, "v1.2.3\n")
}
}
func TestAppRunDoctorHelp(t *testing.T) {
output := &bytes.Buffer{}
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev")
if err := app.Run([]string{"doctor", "--help"}); err != nil {
t.Fatalf("doctor help returned error: %v", err)
}
if got := output.String(); !strings.Contains(got, "email-mcp config test [args]") {
t.Fatalf("unexpected doctor help output: %q", got)
}
}
func TestAppRunHelpDoctorUsesConfigTestHelp(t *testing.T) {
output := &bytes.Buffer{}
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev")
if err := app.Run([]string{"help", "doctor"}); err != nil {
t.Fatalf("help doctor returned error: %v", err)
}
if got := output.String(); !strings.Contains(got, "email-mcp config test [args]") {
t.Fatalf("unexpected doctor help output: %q", got)
}
}
func TestAppRunSetupPromptsAndSavesProfile(t *testing.T) {
func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) {
prompter := &configPrompterStub{
credential: secretstore.Credential{
Host: "imap.example.com",
@ -262,7 +179,7 @@ func TestAppRunSetupPromptsAndSavesProfile(t *testing.T) {
"dev",
)
if err := app.Run([]string{"setup"}); err != nil {
if err := app.Run([]string{"config"}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@ -289,57 +206,7 @@ func TestAppRunSetupPromptsAndSavesProfile(t *testing.T) {
}
}
func TestAppRunSetupUsesManifestDefaultProfile(t *testing.T) {
prompter := &configPrompterStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
},
}
cfgStore := &configStoreStub{}
secrets := &secretStoreStub{}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
prompter,
cfgStore,
func() (secretStore, error) { return secrets, nil },
nil,
nil,
func(string) (frameworkmanifest.File, string, error) {
return frameworkmanifest.File{
Profiles: frameworkmanifest.Profiles{
Default: "work",
},
}, "/tmp/mcp.toml", nil
},
func() (string, error) { return "/tmp/bin/email-mcp", nil },
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"setup"}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if !secrets.setCalled {
t.Fatal("expected password to be stored")
}
if secrets.setName != "imap-password/work" {
t.Fatalf("unexpected secret name %q", secrets.setName)
}
if cfgStore.saved.CurrentProfile != "work" {
t.Fatalf("current profile = %q, want work", cfgStore.saved.CurrentProfile)
}
if got := output.String(); !strings.Contains(got, `profile "work" saved`) {
t.Fatalf("unexpected output %q", got)
}
}
func TestAppRunConfigRequiresSubcommand(t *testing.T) {
func TestAppRunSetupAliasesConfig(t *testing.T) {
prompter := &configPrompterStub{
credential: secretstore.Credential{
Host: "imap.example.com",
@ -364,132 +231,15 @@ func TestAppRunConfigRequiresSubcommand(t *testing.T) {
"dev",
)
err := app.Run([]string{"config"})
if err == nil {
t.Fatal("expected config without subcommand to fail")
if err := app.Run([]string{"setup"}); err != nil {
t.Fatalf("setup returned error: %v", err)
}
if !strings.Contains(err.Error(), "subcommand is required") {
t.Fatalf("unexpected error: %v", err)
}
if cfgStore.saveCalled {
t.Fatal("config without subcommand must not save configuration")
if !cfgStore.saveCalled {
t.Fatal("expected setup to save config via config command")
}
}
func TestAppRunConfigDeleteRemovesProfileAndSecret(t *testing.T) {
cfgStore := &configStoreStub{
cfg: frameworkconfig.FileConfig[ProfileConfig]{
Version: frameworkconfig.CurrentVersion,
CurrentProfile: "work",
Profiles: map[string]ProfileConfig{
"work": {
Host: "imap.work.example.com",
Username: "alice",
},
"default": {
Host: "imap.default.example.com",
Username: "alice",
},
},
},
}
secrets := &secretStoreStub{
values: map[string]string{
"imap-password/work": "secret-work",
},
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
cfgStore,
func() (secretStore, error) { return secrets, nil },
nil,
nil,
nil,
nil,
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"config", "delete", "--profile", "work"}); err != nil {
t.Fatalf("config delete returned error: %v", err)
}
if !secrets.delCalled {
t.Fatal("expected password secret to be deleted")
}
if secrets.delName != "imap-password/work" {
t.Fatalf("deleted secret = %q, want %q", secrets.delName, "imap-password/work")
}
if _, ok := cfgStore.saved.Profiles["work"]; ok {
t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles)
}
if cfgStore.saved.CurrentProfile != "default" {
t.Fatalf("current profile = %q, want default", cfgStore.saved.CurrentProfile)
}
text := output.String()
for _, needle := range []string{`profile "work" deleted`, "current profile: default"} {
if !strings.Contains(text, needle) {
t.Fatalf("output = %q, want substring %q", text, needle)
}
}
}
func TestAppRunConfigDeleteIgnoresReadOnlySecretBackend(t *testing.T) {
cfgStore := &configStoreStub{
cfg: frameworkconfig.FileConfig[ProfileConfig]{
Version: frameworkconfig.CurrentVersion,
CurrentProfile: "work",
Profiles: map[string]ProfileConfig{
"work": {
Host: "imap.work.example.com",
Username: "alice",
},
},
},
}
secrets := &secretStoreStub{
deleteErr: frameworksecretstore.ErrReadOnly,
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
cfgStore,
func() (secretStore, error) { return secrets, nil },
nil,
nil,
nil,
nil,
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"config", "delete", "--profile", "work"}); err != nil {
t.Fatalf("config delete returned error: %v", err)
}
if _, ok := cfgStore.saved.Profiles["work"]; ok {
t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles)
}
text := output.String()
for _, needle := range []string{
"secret backend is read-only; EMAIL_MCP_PASSWORD cannot be deleted automatically",
`profile "work" deleted`,
} {
if !strings.Contains(text, needle) {
t.Fatalf("output = %q, want substring %q", text, needle)
}
}
}
func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
func TestAppRunConfigUsesStoredValuesAsDefaults(t *testing.T) {
prompter := &capturingPrompterStub{
credential: secretstore.Credential{
Host: "imap.example.com",
@ -529,8 +279,8 @@ func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
"dev",
)
if err := app.Run([]string{"setup"}); err != nil {
t.Fatalf("setup returned error: %v", err)
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")
@ -540,148 +290,6 @@ func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
}
}
func TestAppRunSetupAllowsReadOnlySecretBackendWhenPasswordEnvIsSet(t *testing.T) {
t.Setenv(passwordEnv, "env-secret")
prompter := &configPrompterStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "new-secret",
},
}
cfgStore := &configStoreStub{}
secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
prompter,
cfgStore,
func() (secretStore, error) { return secrets, nil },
nil,
nil,
nil,
nil,
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"setup"}); err != nil {
t.Fatalf("setup returned error: %v", err)
}
if !secrets.setCalled {
t.Fatal("expected password write attempt")
}
if !cfgStore.saveCalled {
t.Fatal("expected config to be saved")
}
if !strings.Contains(output.String(), "secret backend is read-only; password is provided via EMAIL_MCP_PASSWORD") {
t.Fatalf("unexpected output: %q", output.String())
}
}
func TestAppRunSetupFailsOnReadOnlySecretBackendWithoutPasswordEnv(t *testing.T) {
prompter := &configPrompterStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "new-secret",
},
}
cfgStore := &configStoreStub{}
secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly}
app := NewAppWithDependencies(
prompter,
cfgStore,
func() (secretStore, error) { return secrets, nil },
nil,
nil,
nil,
nil,
nil,
io.Discard,
&bytes.Buffer{},
"dev",
)
err := app.Run([]string{"setup"})
if err == nil {
t.Fatal("expected setup to fail")
}
if !strings.Contains(err.Error(), "secret backend is read-only; set EMAIL_MCP_PASSWORD and rerun `email-mcp setup`") {
t.Fatalf("unexpected error: %v", err)
}
if cfgStore.saveCalled {
t.Fatal("config must not be saved when password cannot be persisted")
}
}
func TestAppRunConfigShowPrintsResolvedConfiguration(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",
},
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
cfgStore,
func() (secretStore, error) { return secrets, nil },
nil,
nil,
nil,
nil,
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"config", "show"}); err != nil {
t.Fatalf("config show returned error: %v", err)
}
text := output.String()
for _, needle := range []string{
"profile: work",
"host: imap.example.com (config)",
"username: alice (config)",
"password: <set> (secret)",
} {
if !strings.Contains(text, needle) {
t.Fatalf("output = %q, want substring %q", text, needle)
}
}
}
func TestAppRunConfigTestDelegatesToDoctor(t *testing.T) {
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
err := app.Run([]string{"config", "test"})
if err == nil {
t.Fatal("expected config test to fail without dependencies")
}
if !strings.Contains(err.Error(), "config store is not configured") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAppRunMCPDelegatesResolvedCredentialToRunner(t *testing.T) {
cfgStore := &configStoreStub{
cfg: frameworkconfig.FileConfig[ProfileConfig]{
@ -736,107 +344,6 @@ func TestAppRunMCPDelegatesResolvedCredentialToRunner(t *testing.T) {
}
}
func TestAppRunMCPPrefersEnvironmentCredentialValues(t *testing.T) {
t.Setenv(hostEnv, "imap.env.example.com")
t.Setenv(usernameEnv, "alice-env")
t.Setenv(passwordEnv, "secret-env")
cfgStore := &configStoreStub{
cfg: frameworkconfig.FileConfig[ProfileConfig]{
Version: frameworkconfig.CurrentVersion,
CurrentProfile: "work",
Profiles: map[string]ProfileConfig{
"work": {
Host: "imap.config.example.com",
Username: "alice-config",
},
},
},
}
secrets := &secretStoreStub{
values: map[string]string{
"imap-password/work": "secret-wallet",
},
}
runner := &runnerStub{}
var gotCredential secretstore.Credential
app := NewAppWithDependencies(
nil,
cfgStore,
func() (secretStore, error) { return secrets, nil },
func() mcpserver.MailService { return wireMailServiceStub{} },
func(cred secretstore.Credential, _ mcpserver.MailService, _ io.Reader, _ io.Writer, _ io.Writer) MCPRunner {
gotCredential = cred
return runner
},
nil,
nil,
nil,
nil,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"mcp"}); err != nil {
t.Fatalf("mcp returned error: %v", err)
}
if !runner.called {
t.Fatal("expected runner to be called")
}
want := secretstore.Credential{
Host: "imap.env.example.com",
Username: "alice-env",
Password: "secret-env",
}
if gotCredential != want {
t.Fatalf("credential = %#v, want %#v", gotCredential, want)
}
}
func TestAppRunMCPUsesEnvironmentCredentialWithoutSavedProfile(t *testing.T) {
t.Setenv(hostEnv, "imap.env.example.com")
t.Setenv(usernameEnv, "alice-env")
t.Setenv(passwordEnv, "secret-env")
cfgStore := &configStoreStub{
cfg: frameworkconfig.FileConfig[ProfileConfig]{
Version: frameworkconfig.CurrentVersion,
},
}
secrets := &secretStoreStub{}
runner := &runnerStub{}
var gotCredential secretstore.Credential
app := NewAppWithDependencies(
nil,
cfgStore,
func() (secretStore, error) { return secrets, nil },
func() mcpserver.MailService { return wireMailServiceStub{} },
func(cred secretstore.Credential, _ mcpserver.MailService, _ io.Reader, _ io.Writer, _ io.Writer) MCPRunner {
gotCredential = cred
return runner
},
nil,
nil,
nil,
nil,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"mcp"}); err != nil {
t.Fatalf("mcp returned error: %v", err)
}
if !runner.called {
t.Fatal("expected runner to be called")
}
if gotCredential.Host != "imap.env.example.com" || gotCredential.Username != "alice-env" || gotCredential.Password != "secret-env" {
t.Fatalf("unexpected credential %#v", gotCredential)
}
}
func TestAppRunUpdateLoadsManifestNearExecutable(t *testing.T) {
tempDir := t.TempDir()
executablePath := filepath.Join(tempDir, "email-mcp")
@ -846,9 +353,8 @@ func TestAppRunUpdateLoadsManifestNearExecutable(t *testing.T) {
if err := os.WriteFile(filepath.Join(tempDir, "mcp.toml"), []byte(`
[update]
source_name = "test"
driver = "gitea"
repository = "AI/email-mcp"
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)
}
@ -877,199 +383,6 @@ base_url = "http://127.0.0.1:1"
}
}
type doctorMailServiceStub struct {
listMailboxes []imapclient.Mailbox
listErr error
called bool
}
func (s *doctorMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
s.called = true
return s.listMailboxes, s.listErr
}
func (s *doctorMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
return nil, nil
}
func (s *doctorMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
return imapclient.Message{}, nil
}
func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempHome)
t.Setenv("HOME", tempHome)
store := frameworkconfig.NewStore[ProfileConfig](binaryName)
configPath, err := store.ConfigPath()
if err != nil {
t.Fatalf("ConfigPath returned error: %v", err)
}
if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{
Version: frameworkconfig.CurrentVersion,
CurrentProfile: "work",
Profiles: map[string]ProfileConfig{
"work": {
Host: "imap.example.com",
Username: "alice",
},
},
}); err != nil {
t.Fatalf("Save returned error: %v", err)
}
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
secrets := &secretStoreStub{
values: map[string]string{
"imap-password/work": "secret",
},
}
mail := &doctorMailServiceStub{
listMailboxes: []imapclient.Mailbox{{Name: "INBOX"}},
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
store,
func() (secretStore, error) { return secrets, nil },
func() mcpserver.MailService { return mail },
nil,
nil,
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"doctor"}); err != nil {
t.Fatalf("doctor returned error: %v", err)
}
if !mail.called {
t.Fatal("expected connectivity check to call mail service")
}
text := output.String()
for _, needle := range []string{
"[OK] config: config file is readable",
"[OK] profile: required profile values are resolved",
"[OK] connectivity: IMAP server is reachable",
"Summary: 4 ok, 0 warning(s), 0 failure(s), 4 total",
} {
if !strings.Contains(text, needle) {
t.Fatalf("output = %q, want substring %q", text, needle)
}
}
}
func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempHome)
t.Setenv("HOME", tempHome)
store := frameworkconfig.NewStore[ProfileConfig](binaryName)
configPath, err := store.ConfigPath()
if err != nil {
t.Fatalf("ConfigPath returned error: %v", err)
}
if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{
Version: frameworkconfig.CurrentVersion,
Profiles: map[string]ProfileConfig{
"default": {
Host: "imap.example.com",
Username: "alice",
},
},
}); err != nil {
t.Fatalf("Save returned error: %v", err)
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
store,
func() (secretStore, error) { return &secretStoreStub{}, nil },
func() mcpserver.MailService { return &doctorMailServiceStub{} },
nil,
nil,
nil,
nil,
output,
&bytes.Buffer{},
"dev",
)
err = app.Run([]string{"doctor"})
if err == nil {
t.Fatal("expected doctor to fail when password is missing")
}
if !strings.Contains(err.Error(), "doctor checks failed") {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(output.String(), "[FAIL] connectivity: cannot load IMAP credentials") {
t.Fatalf("unexpected output: %q", output.String())
}
}
func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tempHome)
t.Setenv("HOME", tempHome)
t.Setenv(passwordEnv, "env-secret")
store := frameworkconfig.NewStore[ProfileConfig](binaryName)
configPath, err := store.ConfigPath()
if err != nil {
t.Fatalf("ConfigPath returned error: %v", err)
}
if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{
Version: frameworkconfig.CurrentVersion,
CurrentProfile: "work",
Profiles: map[string]ProfileConfig{
"work": {
Host: "imap.example.com",
Username: "alice",
},
},
}); err != nil {
t.Fatalf("Save returned error: %v", err)
}
output := &bytes.Buffer{}
app := NewAppWithDependencies(
nil,
store,
func() (secretStore, error) { return &secretStoreStub{}, nil },
func() mcpserver.MailService { return &doctorMailServiceStub{} },
nil,
nil,
nil,
nil,
output,
&bytes.Buffer{},
"dev",
)
if err := app.Run([]string{"doctor"}); err != nil {
t.Fatalf("doctor returned error: %v", err)
}
if !strings.Contains(output.String(), "[OK] connectivity: IMAP server is reachable") {
t.Fatalf("unexpected output: %q", output.String())
}
}
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
@ -1077,9 +390,8 @@ func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
command string
want string
}{
{command: "setup", want: "config prompter is not configured"},
{command: "config", want: "config prompter is not configured"},
{command: "mcp", want: "mcp runner is not configured"},
{command: "doctor", want: "config store is not configured"},
{command: "update", want: "manifest loader is not configured"},
}
@ -1101,7 +413,7 @@ func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
if err == nil {
t.Fatal("expected mapped error")
}
if !strings.Contains(err.Error(), "run `email-mcp setup`") {
if !strings.Contains(err.Error(), "run `email-mcp config`") {
t.Fatalf("expected config guidance, got %v", err)
}
if !errors.Is(err, mcpserver.ErrCredentialsNotConfigured) {
@ -1109,7 +421,7 @@ func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
}
}
func TestMapAppErrorMapsUnavailableSecretBackendError(t *testing.T) {
func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
err := mapAppError(&frameworksecretstore.BackendUnavailableError{
Policy: frameworksecretstore.BackendAuto,
Required: "any keyring backend",
@ -1117,29 +429,12 @@ func TestMapAppErrorMapsUnavailableSecretBackendError(t *testing.T) {
if err == nil {
t.Fatal("expected mapped error")
}
if !strings.Contains(strings.ToLower(err.Error()), "secret backend") {
t.Fatalf("expected secret backend guidance, got %v", err)
if !strings.Contains(strings.ToLower(err.Error()), "wallet") {
t.Fatalf("expected wallet guidance, got %v", err)
}
}
func TestMapAppErrorPreservesBitwardenBackendDetails(t *testing.T) {
err := mapAppError(fmt.Errorf(
"cannot use bitwarden CLI command %q right now: %w",
"bw",
errors.Join(frameworksecretstore.ErrBackendUnavailable, frameworksecretstore.ErrBWLocked),
))
if err == nil {
t.Fatal("expected mapped error")
}
if !strings.Contains(strings.ToLower(err.Error()), "bitwarden") {
t.Fatalf("expected bitwarden guidance, got %v", err)
}
if strings.Contains(strings.ToLower(err.Error()), "secret service") || strings.Contains(strings.ToLower(err.Error()), "kwallet") {
t.Fatalf("unexpected keyring guidance: %v", err)
}
}
func TestExecuteSetupWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) {
func TestExecuteConfigWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) {
app := NewAppWithDependencies(
&configPrompterStub{},
&configStoreStub{},
@ -1160,10 +455,10 @@ func TestExecuteSetupWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) {
)
stderr := &bytes.Buffer{}
if code := Execute(app, []string{"setup"}, stderr); code != 1 {
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, "secret backend") {
if got := strings.ToLower(stderr.String()); !strings.Contains(got, "wallet") {
t.Fatalf("unexpected stderr: %q", got)
}
}

View file

@ -1,131 +0,0 @@
package cli
import (
"context"
"fmt"
"os"
"time"
"email-mcp/mcpgen"
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
)
func (a *App) runDoctor(ctx context.Context, args []string) error {
profileFlag, err := parseProfileArgs("doctor", args)
if err != nil {
return err
}
if a.configStore == nil {
return fmt.Errorf("config store is not configured")
}
if a.openSecretStore == nil {
return fmt.Errorf("secret store is not configured")
}
if a.newMailService == nil {
return fmt.Errorf("mail service is not configured")
}
report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)),
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
ExtraChecks: []frameworkcli.DoctorCheck{
a.doctorRequiredProfileFieldsCheck(profileFlag),
},
})
if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil {
return err
}
if report.HasFailures() {
return fmt.Errorf("doctor checks failed")
}
return nil
}
func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.DoctorCheck {
var (
profileValues map[string]string
loadErr error
)
check := frameworkcli.RequiredResolvedFieldsCheck(frameworkcli.ResolveOptions{
Fields: profileFieldSpecs(a.resolveDoctorProfileName(profileFlag)),
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
Env: frameworkcli.EnvLookup(os.LookupEnv),
Config: func(key string) (string, bool, error) {
if loadErr != nil {
return "", false, loadErr
}
if profileValues == nil {
cfg, _, err := a.configStore.LoadDefault()
if err != nil {
loadErr = err
return "", false, loadErr
}
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
profileValues = map[string]string{
"host": profile.Host,
"username": profile.Username,
}
}
return frameworkcli.MapLookup(profileValues)(key)
},
}),
})
return func(ctx context.Context) frameworkcli.DoctorResult {
result := check(ctx)
result.Name = "profile"
return result
}
}
func (a *App) doctorConnectivityCheck(profileFlag string) frameworkcli.DoctorCheck {
return func(parent context.Context) frameworkcli.DoctorResult {
ctx, cancel := context.WithTimeout(parent, 35*time.Second)
defer cancel()
cred, err := a.loadCredential(profileFlag)
if err != nil {
return frameworkcli.DoctorResult{
Name: "connectivity",
Status: frameworkcli.DoctorStatusFail,
Summary: "cannot load IMAP credentials",
Detail: err.Error(),
}
}
if _, err := a.newMailService().ListMailboxes(ctx, cred); err != nil {
return frameworkcli.DoctorResult{
Name: "connectivity",
Status: frameworkcli.DoctorStatusFail,
Summary: "IMAP server is unreachable or rejected authentication",
Detail: err.Error(),
}
}
return frameworkcli.DoctorResult{
Name: "connectivity",
Status: frameworkcli.DoctorStatusOK,
Summary: "IMAP server is reachable",
}
}
}
func (a *App) resolveDoctorProfileName(profileFlag string) string {
if a.configStore == nil {
return a.resolveProfileName(profileFlag, "")
}
cfg, _, err := a.configStore.LoadDefault()
if err != nil {
return a.resolveProfileName(profileFlag, "")
}
return a.resolveProfileName(profileFlag, cfg.CurrentProfile)
}

View file

@ -8,8 +8,7 @@ import (
"os"
"strings"
"email-mcp/mcpgen"
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
"email-mcp/internal/secretstore"
)
@ -40,10 +39,6 @@ func NewInteractiveConfigPrompter(input io.Reader, output io.Writer) *Interactiv
}
func (p *InteractiveConfigPrompter) PromptCredential(_ context.Context, existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) {
if p.stdinFile != nil {
return p.promptCredentialWithSetupEngine(existing, hasStoredPassword)
}
host, err := frameworkcli.PromptLine(p.reader, p.output, "IMAP host", existing.Host)
if err != nil {
return secretstore.Credential{}, err
@ -71,56 +66,6 @@ func (p *InteractiveConfigPrompter) PromptCredential(_ context.Context, existing
return cred, nil
}
func (p *InteractiveConfigPrompter) promptCredentialWithSetupEngine(existing secretstore.Credential, hasStoredPassword bool) (secretstore.Credential, error) {
password := ""
if hasStoredPassword {
password = existing.Password
}
fields := mcpgen.SetupFields(map[string]string{"password": password})
for i := range fields {
switch fields[i].Name {
case "host":
fields[i].Default = existing.Host
case "username":
fields[i].Default = existing.Username
}
}
result, err := frameworkcli.RunSetup(frameworkcli.SetupOptions{
Stdin: p.stdinFile,
Stdout: p.output,
Fields: fields,
})
if err != nil {
return secretstore.Credential{}, err
}
host, ok := result.Get("host")
if !ok {
return secretstore.Credential{}, fmt.Errorf("setup result is missing host")
}
username, ok := result.Get("username")
if !ok {
return secretstore.Credential{}, fmt.Errorf("setup result is missing username")
}
secret, ok := result.Get("password")
if !ok {
return secretstore.Credential{}, fmt.Errorf("setup result is missing password")
}
cred := secretstore.Credential{
Host: host.String,
Username: username.String,
Password: secret.String,
}
if err := cred.Validate(); err != nil {
return secretstore.Credential{}, err
}
return cred, nil
}
func (p *InteractiveConfigPrompter) promptPassword(storedPassword string, hasStoredPassword bool) (string, error) {
if p.stdinFile != nil {
return frameworkcli.PromptSecret(p.stdinFile, p.output, "Password", hasStoredPassword, storedPassword)

View file

@ -3,7 +3,6 @@ package cli
import (
"bytes"
"context"
"os"
"strings"
"testing"
@ -49,64 +48,3 @@ func TestInteractiveConfigPrompterPromptCredentialKeepsStoredPassword(t *testing
t.Fatalf("unexpected prompts: %q", got)
}
}
func TestInteractiveConfigPrompterPromptCredentialUsesSetupEngineWithFileInput(t *testing.T) {
input := setupInputFile(t, "imap.example.com\nalice\nsecret\n")
output := &bytes.Buffer{}
prompter := NewInteractiveConfigPrompter(input, output)
cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{}, false)
if err != nil {
t.Fatalf("PromptCredential returned error: %v", err)
}
if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" {
t.Fatalf("unexpected credential: %#v", cred)
}
if got := output.String(); got != "IMAP host: Username: Password: " {
t.Fatalf("unexpected prompts: %q", got)
}
}
func TestInteractiveConfigPrompterPromptCredentialKeepsStoredPasswordWithSetupEngine(t *testing.T) {
input := setupInputFile(t, "imap.example.com\nalice\n\n")
output := &bytes.Buffer{}
prompter := NewInteractiveConfigPrompter(input, output)
cred, err := prompter.PromptCredential(context.Background(), secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "stored-secret",
}, true)
if err != nil {
t.Fatalf("PromptCredential returned error: %v", err)
}
if cred.Password != "stored-secret" {
t.Fatalf("expected stored password to be preserved, got %q", cred.Password)
}
if got := output.String(); !strings.Contains(got, "Password [stored, leave blank to keep]: ") {
t.Fatalf("unexpected prompts: %q", got)
}
}
func setupInputFile(t *testing.T, content string) *os.File {
t.Helper()
input, err := os.CreateTemp(t.TempDir(), "setup-input-*.txt")
if err != nil {
t.Fatalf("CreateTemp returned error: %v", err)
}
t.Cleanup(func() {
_ = input.Close()
})
if _, err := input.WriteString(content); err != nil {
t.Fatalf("WriteString returned error: %v", err)
}
if _, err := input.Seek(0, 0); err != nil {
t.Fatalf("Seek returned error: %v", err)
}
return input
}

View file

@ -4,11 +4,10 @@ import (
"context"
"io"
"os"
"strings"
"email-mcp/mcpgen"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
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"
@ -48,7 +47,6 @@ func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, version strin
}
func (f runtimeFactories) withDefaults() runtimeFactories {
useGeneratedManifest := f.loadManifest == nil
if f.newPrompter == nil {
f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter {
return NewInteractiveConfigPrompter(input, output)
@ -56,29 +54,14 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
}
if f.newConfigStore == nil {
f.newConfigStore = func() profileConfigStore {
return frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)
return frameworkconfig.NewStore[ProfileConfig]("email-mcp")
}
}
if f.loadManifest == nil {
f.loadManifest = mcpgen.LoadManifest
}
if f.resolveExecutable == nil {
f.resolveExecutable = os.Executable
}
if f.openSecretStore == nil {
f.openSecretStore = func() (secretStore, error) {
if useGeneratedManifest {
return mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
LookupEnv: profilePasswordLookupEnv,
})
}
return frameworksecretstore.OpenFromManifest(frameworksecretstore.OpenFromManifestOptions{
ServiceName: mcpgen.BinaryName,
ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest),
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
LookupEnv: profilePasswordLookupEnv,
return frameworksecretstore.Open(frameworksecretstore.Options{
ServiceName: "email-mcp",
BackendPolicy: frameworksecretstore.BackendAuto,
})
}
}
@ -92,18 +75,16 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
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
}
func profilePasswordLookupEnv(name string) (string, bool) {
trimmedName := strings.TrimSpace(name)
if strings.HasPrefix(trimmedName, "imap-password/") {
return os.LookupEnv(passwordEnv)
}
return os.LookupEnv(trimmedName)
}
type staticCredentialStore struct {
credential secretstore.Credential
}

View file

@ -1,11 +1,6 @@
package cli
import (
"strings"
"testing"
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
)
import "testing"
func TestBuildAppReturnsConfiguredApp(t *testing.T) {
app := BuildApp("dev")
@ -31,52 +26,3 @@ func TestBuildAppReturnsConfiguredApp(t *testing.T) {
t.Fatal("expected manifest loader to be configured")
}
}
func TestBuildAppOpenSecretStoreMapsProfilePasswordToEnvironment(t *testing.T) {
t.Setenv(passwordEnv, "env-secret")
app := buildApp(nil, nil, nil, "dev", runtimeFactories{
loadManifest: func(string) (frameworkmanifest.File, string, error) {
return frameworkmanifest.File{
SecretStore: frameworkmanifest.SecretStore{
BackendPolicy: "env-only",
},
}, "/tmp/mcp.toml", nil
},
resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil },
})
store, err := app.openSecretStore()
if err != nil {
t.Fatalf("openSecretStore returned error: %v", err)
}
value, err := store.GetSecret("imap-password/work")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "env-secret" {
t.Fatalf("GetSecret = %q, want %q", value, "env-secret")
}
}
func TestBuildAppOpenSecretStoreReturnsErrorOnInvalidManifestPolicy(t *testing.T) {
app := buildApp(nil, nil, nil, "dev", runtimeFactories{
loadManifest: func(string) (frameworkmanifest.File, string, error) {
return frameworkmanifest.File{
SecretStore: frameworkmanifest.SecretStore{
BackendPolicy: "invalid-policy",
},
}, "/tmp/mcp.toml", nil
},
resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil },
})
_, err := app.openSecretStore()
if err == nil {
t.Fatal("expected invalid secret store policy error")
}
if !strings.Contains(err.Error(), "invalid secret_store.backend_policy") {
t.Fatalf("unexpected error: %v", err)
}
}

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"

View file

@ -1,60 +1,4 @@
binary_name = "email-mcp"
docs_url = "https://forge.lclr.dev/AI/email-mcp"
[update]
source_name = "email-mcp releases"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://forge.lclr.dev"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
token_env_names = ["GITEA_TOKEN"]
[environment]
known = [
"EMAIL_MCP_PROFILE",
"EMAIL_MCP_HOST",
"EMAIL_MCP_USERNAME",
"EMAIL_MCP_PASSWORD",
"BW_SESSION",
"MCP_FRAMEWORK_BITWARDEN_CACHE",
"MCP_FRAMEWORK_BITWARDEN_DEBUG",
]
[secret_store]
backend_policy = "bitwarden-cli"
[profiles]
default = "default"
known = ["default"]
[bootstrap]
description = "Local MCP server to read an IMAP mailbox."
[[config.fields]]
name = "host"
env = "EMAIL_MCP_HOST"
config_key = "host"
type = "string"
label = "IMAP host"
required = true
sources = ["env", "config"]
[[config.fields]]
name = "username"
env = "EMAIL_MCP_USERNAME"
config_key = "username"
type = "string"
label = "Username"
required = true
sources = ["env", "config"]
[[config.fields]]
name = "password"
env = "EMAIL_MCP_PASSWORD"
secret_key_template = "imap-password/{profile}"
type = "secret"
label = "Password"
required = true
sources = ["env", "secret"]
base_url = "https://gitea.lclr.dev"
latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest"

View file

@ -1,63 +0,0 @@
// Code generated by mcp-framework generate. DO NOT EDIT.
package mcpgen
import (
"flag"
"strings"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
)
type ConfigFlags struct {
values map[string]*string
}
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {
if fs == nil {
fs = flag.CommandLine
}
flags := ConfigFlags{
values: make(map[string]*string),
}
return flags
}
func ConfigFlagValues(flags ConfigFlags) map[string]string {
values := make(map[string]string)
for name, value := range flags.values {
if value == nil {
continue
}
if trimmed := strings.TrimSpace(*value); trimmed != "" {
values[name] = trimmed
}
}
return values
}
func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {
return []fwcli.FieldSpec{
{Name: "host", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceConfig}, FlagKey: "", EnvKey: "EMAIL_MCP_HOST", ConfigKey: "host", SecretKey: replaceProfile("", profile)},
{Name: "username", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceConfig}, FlagKey: "", EnvKey: "EMAIL_MCP_USERNAME", ConfigKey: "username", SecretKey: replaceProfile("", profile)},
{Name: "password", Required: true, DefaultValue: "", Sources: []fwcli.ValueSource{fwcli.SourceEnv, fwcli.SourceSecret}, FlagKey: "", EnvKey: "EMAIL_MCP_PASSWORD", ConfigKey: "", SecretKey: replaceProfile("imap-password/{profile}", profile)},
}
}
func SetupFields(existing map[string]string) []fwcli.SetupField {
if existing == nil {
existing = map[string]string{}
}
return []fwcli.SetupField{
{Name: "host", Label: "IMAP host", Type: fwcli.SetupFieldString, Required: true, Default: "", ExistingSecret: existing["host"]},
{Name: "username", Label: "Username", Type: fwcli.SetupFieldString, Required: true, Default: "", ExistingSecret: existing["username"]},
{Name: "password", Label: "Password", Type: fwcli.SetupFieldSecret, Required: true, Default: "", ExistingSecret: existing["password"]},
}
}
func replaceProfile(value, profile string) string {
return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile))
}

View file

@ -1,71 +0,0 @@
package mcpgen
import (
"os"
"testing"
fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestGeneratedManifestFallsBackToEmbeddedRootManifest(t *testing.T) {
previousDir, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd returned error: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("Chdir temp dir returned error: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(previousDir); err != nil {
t.Fatalf("restore working directory: %v", err)
}
})
manifestFile, source, err := LoadManifest(".")
if err != nil {
t.Fatalf("LoadManifest returned error: %v", err)
}
if source != fwmanifest.EmbeddedSource {
t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource)
}
if manifestFile.BinaryName != "email-mcp" {
t.Fatalf("BinaryName = %q, want email-mcp", manifestFile.BinaryName)
}
if len(manifestFile.Config.Fields) != 3 {
t.Fatalf("config fields = %d, want 3", len(manifestFile.Config.Fields))
}
if manifestFile.SecretStore.BackendPolicy != "bitwarden-cli" {
t.Fatalf("secret store backend policy = %q, want bitwarden-cli", manifestFile.SecretStore.BackendPolicy)
}
}
func TestGeneratedConfigHelpersExposeIMAPResolutionFields(t *testing.T) {
specs := ResolveFieldSpecs("work")
if len(specs) != 3 {
t.Fatalf("ResolveFieldSpecs returned %d fields, want 3", len(specs))
}
if specs[0].Name != "host" || specs[0].EnvKey != "EMAIL_MCP_HOST" || specs[0].ConfigKey != "host" {
t.Fatalf("host spec = %+v", specs[0])
}
if specs[1].Name != "username" || specs[1].EnvKey != "EMAIL_MCP_USERNAME" || specs[1].ConfigKey != "username" {
t.Fatalf("username spec = %+v", specs[1])
}
if specs[2].Name != "password" || specs[2].EnvKey != "EMAIL_MCP_PASSWORD" || specs[2].SecretKey != "imap-password/work" {
t.Fatalf("password spec = %+v", specs[2])
}
}
func TestGeneratedManifestPrefersRootFileWhenPresent(t *testing.T) {
manifestFile, source, err := LoadManifest(".")
if err != nil {
t.Fatalf("LoadManifest returned error: %v", err)
}
if source == fwmanifest.EmbeddedSource {
t.Fatalf("source = %q, want root manifest path", source)
}
if manifestFile.BinaryName != "email-mcp" {
t.Fatalf("BinaryName = %q, want email-mcp", manifestFile.BinaryName)
}
}

View file

@ -1,11 +0,0 @@
// Code generated by mcp-framework generate. DO NOT EDIT.
package mcpgen
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
const embeddedManifest = "binary_name = \"email-mcp\"\ndocs_url = \"https://gitea.lclr.dev/AI/email-mcp\"\n\n[update]\nsource_name = \"email-mcp releases\"\ndriver = \"gitea\"\nrepository = \"AI/email-mcp\"\nbase_url = \"https://gitea.lclr.dev\"\nasset_name_template = \"{binary}-{os}-{arch}{ext}\"\nchecksum_asset_name = \"{asset}.sha256\"\nchecksum_required = true\ntoken_env_names = [\"GITEA_TOKEN\"]\n\n[environment]\nknown = [\n \"EMAIL_MCP_PROFILE\",\n \"EMAIL_MCP_HOST\",\n \"EMAIL_MCP_USERNAME\",\n \"EMAIL_MCP_PASSWORD\",\n \"BW_SESSION\",\n \"MCP_FRAMEWORK_BITWARDEN_CACHE\",\n \"MCP_FRAMEWORK_BITWARDEN_DEBUG\",\n]\n\n[secret_store]\nbackend_policy = \"bitwarden-cli\"\n\n[profiles]\ndefault = \"default\"\nknown = [\"default\"]\n\n[bootstrap]\ndescription = \"Local MCP server to read an IMAP mailbox.\"\n\n[[config.fields]]\nname = \"host\"\nenv = \"EMAIL_MCP_HOST\"\nconfig_key = \"host\"\ntype = \"string\"\nlabel = \"IMAP host\"\nrequired = true\nsources = [\"env\", \"config\"]\n\n[[config.fields]]\nname = \"username\"\nenv = \"EMAIL_MCP_USERNAME\"\nconfig_key = \"username\"\ntype = \"string\"\nlabel = \"Username\"\nrequired = true\nsources = [\"env\", \"config\"]\n\n[[config.fields]]\nname = \"password\"\nenv = \"EMAIL_MCP_PASSWORD\"\nsecret_key_template = \"imap-password/{profile}\"\ntype = \"secret\"\nlabel = \"Password\"\nrequired = true\nsources = [\"env\", \"secret\"]\n"
func LoadManifest(startDir string) (fwmanifest.File, string, error) {
return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)
}

View file

@ -1,27 +0,0 @@
// Code generated by mcp-framework generate. DO NOT EDIT.
package mcpgen
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
const BinaryName = "email-mcp"
const DefaultDescription = "Local MCP server to read an IMAP mailbox."
const DocsURL = "https://gitea.lclr.dev/AI/email-mcp"
func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {
manifestFile, source, err := LoadManifest(startDir)
if err != nil {
return fwmanifest.BootstrapMetadata{}, "", err
}
return manifestFile.BootstrapInfo(), source, nil
}
func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {
manifestFile, source, err := LoadManifest(startDir)
if err != nil {
return fwmanifest.ScaffoldMetadata{}, "", err
}
return manifestFile.ScaffoldInfo(), source, nil
}

View file

@ -1,91 +0,0 @@
// Code generated by mcp-framework generate. DO NOT EDIT.
package mcpgen
import (
"os"
"path/filepath"
"strings"
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
)
type SecretStoreOptions struct {
ServiceName string
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
Shell string
ExecutableResolver fwsecretstore.ExecutableResolver
}
func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {
return fwsecretstore.OpenFromManifest(secretStoreOpenOptions(options))
}
func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {
return fwsecretstore.DescribeRuntime(secretStoreDescribeOptions(options))
}
func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {
return fwsecretstore.PreflightFromManifest(secretStoreDescribeOptions(options))
}
func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromManifestOptions {
return fwsecretstore.OpenFromManifestOptions{
ServiceName: secretStoreServiceName(options),
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
DisableBitwardenCache: options.DisableBitwardenCache,
Shell: options.Shell,
ManifestLoader: LoadManifest,
ExecutableResolver: options.ExecutableResolver,
}
}
func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.DescribeRuntimeOptions {
return fwsecretstore.DescribeRuntimeOptions{
ServiceName: secretStoreServiceName(options),
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
DisableBitwardenCache: options.DisableBitwardenCache,
Shell: options.Shell,
ManifestLoader: LoadManifest,
ExecutableResolver: options.ExecutableResolver,
}
}
func secretStoreServiceName(options SecretStoreOptions) string {
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName != "" {
return serviceName
}
startDir := "."
executableResolver := options.ExecutableResolver
if executableResolver == nil {
executableResolver = os.Executable
}
if executablePath, err := executableResolver(); err == nil {
if dir := strings.TrimSpace(filepath.Dir(strings.TrimSpace(executablePath))); dir != "" {
startDir = dir
}
}
if manifestFile, _, err := LoadManifest(startDir); err == nil {
if binaryName := strings.TrimSpace(manifestFile.BinaryName); binaryName != "" {
return binaryName
}
}
return BinaryName
}

View file

@ -1,59 +0,0 @@
// Code generated by mcp-framework generate. DO NOT EDIT.
package mcpgen
import (
"context"
"flag"
"fmt"
"io"
"strings"
fwupdate "forge.lclr.dev/AI/mcp-framework/update"
)
func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {
return UpdateOptionsFrom(".", version, stdout)
}
func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {
manifestFile, _, err := LoadManifest(startDir)
if err != nil {
return fwupdate.Options{}, err
}
binaryName := strings.TrimSpace(manifestFile.BinaryName)
if binaryName == "" {
binaryName = BinaryName
}
return fwupdate.Options{
CurrentVersion: version,
Stdout: stdout,
BinaryName: binaryName,
ReleaseSource: manifestFile.Update.ReleaseSource(),
}, nil
}
func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {
return RunUpdateFrom(ctx, args, ".", version, stdout)
}
func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error {
fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return fmt.Errorf("update does not accept positional arguments: %s", strings.Join(fs.Args(), ", "))
}
options, err := UpdateOptionsFrom(startDir, version, stdout)
if err != nil {
return err
}
return fwupdate.Run(ctx, options)
}