Compare commits

..

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

19 changed files with 443 additions and 1823 deletions

View file

@ -12,7 +12,6 @@ jobs:
env: env:
BINARY_NAME: email-mcp BINARY_NAME: email-mcp
BUILD_PATH: build/email-mcp-linux-amd64 BUILD_PATH: build/email-mcp-linux-amd64
CHECKSUM_PATH: build/email-mcp-linux-amd64.sha256
MANIFEST_PATH: mcp.toml MANIFEST_PATH: mcp.toml
steps: steps:
@ -29,14 +28,6 @@ jobs:
- name: Build linux amd64 binary - name: Build linux amd64 binary
run: make build GOOS=linux GOARCH=amd64 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 - name: Generate release notes
id: release_notes id: release_notes
env: env:
@ -174,30 +165,3 @@ jobs:
cat asset.json >&2 cat asset.json >&2
exit 1 exit 1
fi 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) OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
.PHONY: build test generate generate-check .PHONY: build test
build: build:
@mkdir -p $(BUILD_DIR) $(GOCACHE) @mkdir -p $(BUILD_DIR) $(GOCACHE)
@ -23,11 +23,3 @@ build:
test: test:
@mkdir -p $(GOCACHE) @mkdir -p $(GOCACHE)
GOCACHE=$(GOCACHE) go test ./... 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

View file

@ -6,9 +6,8 @@ Le binaire sappuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
- la gestion de profils CLI - la gestion de profils CLI
- le stockage JSON de configuration dans `os.UserConfigDir()` - 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` - le manifeste `mcp.toml`
- les helpers Go générés depuis `mcp.toml` (`mcpgen/`)
- lauto-update via `email-mcp update` - lauto-update via `email-mcp update`
## Commandes ## Commandes
@ -16,9 +15,8 @@ Le binaire sappuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
- `email-mcp setup` : configure (ou met à jour) un profil IMAP - `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 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 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 mcp` : lance le serveur MCP sur `stdin/stdout` - `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 doctor` : diagnostique la configuration locale, le wallet, le manifeste et laccès IMAP
- `email-mcp update` : met à jour le binaire courant depuis la dernière release - `email-mcp update` : met à jour le binaire courant depuis la dernière release
- `email-mcp version` : affiche la version du binaire - `email-mcp version` : affiche la version du binaire
@ -35,21 +33,20 @@ La commande `email-mcp help` (ou `-h` / `--help`) affiche laide globale.
La configuration est séparée en deux parties : La configuration est séparée en deux parties :
- `host` et `username` sont stockés dans `config.json` - `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 : Le profil actif est résolu dans cet ordre :
1. `--profile` 1. `--profile`
2. `EMAIL_MCP_PROFILE` 2. `EMAIL_MCP_PROFILE`
3. `current_profile` dans `config.json` 3. `current_profile` dans `config.json`
4. `[profiles].default` dans `mcp.toml` 4. `default`
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 : Les credentials IMAP sont résolus ensuite via le résolveur multi-sources du framework (RC3) :
1. `host` : `EMAIL_MCP_HOST` puis `config.json` 1. `host` : `EMAIL_MCP_HOST` puis `config.json`
2. `username` : `EMAIL_MCP_USERNAME` puis `config.json` 2. `username` : `EMAIL_MCP_USERNAME` puis `config.json`
3. `password` : `EMAIL_MCP_PASSWORD` puis secret Bitwarden `imap-password/<profile>` 3. `password` : `EMAIL_MCP_PASSWORD` puis secret wallet `imap-password/<profile>`
### Configurer un profil ### Configurer un profil
@ -69,9 +66,7 @@ Le binaire demande ensuite :
2. le nom dutilisateur 2. le nom dutilisateur
3. le mot de passe 3. le mot de passe
Si un mot de passe existe déjà dans Bitwarden, laisser le champ vide le conserve. Si un mot de passe existe déjà dans le wallet, 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.
### Lancer le serveur MCP ### Lancer le serveur MCP
@ -113,20 +108,13 @@ credentials not configured; run `email-mcp setup`
./email-mcp update ./email-mcp update
``` ```
Le manifeste de ce repo utilise le driver Gitea du framework : Le manifeste de ce repo pointe vers lendpoint Gitea :
```toml ```toml
binary_name = "email-mcp"
[update] [update]
source_name = "email-mcp releases" source_name = "email-mcp releases"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev" base_url = "https://gitea.lclr.dev"
asset_name_template = "{binary}-{os}-{arch}{ext}" latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
token_env_names = ["GITEA_TOKEN"]
``` ```
## Diagnostic ## Diagnostic
@ -135,7 +123,7 @@ token_env_names = ["GITEA_TOKEN"]
- la lisibilité du fichier de configuration - la lisibilité du fichier de configuration
- le profil IMAP résolu - le profil IMAP résolu
- la disponibilité du backend Bitwarden - la disponibilité du wallet système
- la présence du mot de passe stocké - la présence du mot de passe stocké
- la validité du manifeste `mcp.toml` - la validité du manifeste `mcp.toml`
- la connectivité IMAP avec les credentials résolus - la connectivité IMAP avec les credentials résolus
@ -147,27 +135,8 @@ token_env_names = ["GITEA_TOKEN"]
La commande retourne un code de sortie non nul si au moins un check échoue. 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 ## 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 ### Claude Code CLI
Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` : Ajoute le serveur MCP en pointant vers le binaire et la sous-commande `mcp` :
@ -200,7 +169,6 @@ Une release est générée automatiquement quand tu pousses un tag `v*` sur le r
Les assets publiés sont : Les assets publiés sont :
- `build/email-mcp-linux-amd64` - `build/email-mcp-linux-amd64`
- `build/email-mcp-linux-amd64.sha256`
- `mcp.toml` - `mcp.toml`
## Compiler depuis les sources ## Compiler depuis les sources
@ -222,10 +190,3 @@ Pour lancer les tests :
```sh ```sh
make test 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 go 1.25.0
require ( require (
forge.lclr.dev/AI/mcp-framework v1.13.0 gitea.lclr.dev/AI/mcp-framework v1.2.1
github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2 github.com/emersion/go-message v0.18.2
github.com/godbus/dbus/v5 v5.2.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= gitea.lclr.dev/AI/mcp-framework v1.2.1 h1:4sM47gKeR6N4tqTr92ExCUZjxbCpnGPuZkfbigSVgPM=
forge.lclr.dev/AI/mcp-framework v1.9.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4= gitea.lclr.dev/AI/mcp-framework v1.2.1/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4=
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=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= 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/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 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,26 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"email-mcp/mcpgen" frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap"
frameworkbootstrap "forge.lclr.dev/AI/mcp-framework/bootstrap" frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli" frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest" frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update"
frameworkupdate "forge.lclr.dev/AI/mcp-framework/update"
"email-mcp/internal/mcpserver" "email-mcp/internal/mcpserver"
"email-mcp/internal/secretstore" "email-mcp/internal/secretstore"
) )
const ( const (
binaryName = mcpgen.BinaryName binaryName = "email-mcp"
defaultProfileEnv = "EMAIL_MCP_PROFILE" defaultProfileEnv = "EMAIL_MCP_PROFILE"
hostEnv = "EMAIL_MCP_HOST" hostEnv = "EMAIL_MCP_HOST"
usernameEnv = "EMAIL_MCP_USERNAME" usernameEnv = "EMAIL_MCP_USERNAME"
passwordEnv = "EMAIL_MCP_PASSWORD" passwordEnv = "EMAIL_MCP_PASSWORD"
fallbackProfile = "default" binaryDescription = "Local MCP server to read an IMAP mailbox."
) )
type MCPRunner interface { type MCPRunner interface {
@ -45,7 +43,11 @@ type profileConfigStore interface {
SaveDefault(frameworkconfig.FileConfig[ProfileConfig]) (string, error) 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 manifestLoader func(startDir string) (frameworkmanifest.File, string, error)
type executableResolver func() (string, error) type executableResolver func() (string, error)
@ -115,20 +117,26 @@ func NewAppWithDependencies(
} }
func (a *App) Run(args []string) error { func (a *App) Run(args []string) error {
if args == nil { if isDoctorHelpCommand(args) {
args = []string{} return a.printDoctorHelp()
} }
if len(args) > 0 && strings.TrimSpace(args[0]) == "doctor" {
return a.runDoctor(context.Background(), args[1:])
}
if isGlobalHelpCommand(args) {
return a.printGlobalHelp()
}
return a.runBootstrap(context.Background(), args) return a.runBootstrap(context.Background(), args)
} }
func (a *App) runBootstrap(ctx context.Context, args []string) error { func (a *App) runBootstrap(ctx context.Context, args []string) error {
metadata := a.runtimeMetadata()
return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{ return frameworkbootstrap.Run(ctx, frameworkbootstrap.Options{
BinaryName: metadata.BinaryName, BinaryName: binaryName,
Description: metadata.Description, Description: binaryDescription,
Version: a.version, Version: a.version,
EnableDoctorAlias: true,
Args: args, Args: args,
Stdin: a.stdin, Stdin: a.stdin,
Stdout: a.stdout, Stdout: a.stdout,
@ -137,7 +145,6 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { Setup: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args) return a.runConfig(ctx, frameworkbootstrap.CommandSetup, inv.Args)
}, },
Login: frameworkbootstrap.BitwardenLoginHandler(metadata.BinaryName),
MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { MCP: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runMCP(ctx, inv.Args) return a.runMCP(ctx, inv.Args)
}, },
@ -145,10 +152,7 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
return a.runConfigShow(ctx, inv.Args) return a.runConfigShow(ctx, inv.Args)
}, },
ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error { ConfigTest: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runDoctor(ctx, inv.Args) return a.runConfigTest(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 { Update: func(ctx context.Context, inv frameworkbootstrap.Invocation) error {
return a.runUpdate(ctx, inv.Args) return a.runUpdate(ctx, inv.Args)
@ -157,6 +161,80 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
}) })
} }
func isGlobalHelpCommand(args []string) bool {
if len(args) == 0 {
return true
}
if len(args) != 1 {
return false
}
switch strings.TrimSpace(args[0]) {
case "help", "-h", "--help":
return true
default:
return false
}
}
func isDoctorHelpCommand(args []string) bool {
if len(args) != 2 {
return false
}
first := strings.TrimSpace(args[0])
second := strings.TrimSpace(args[1])
if first == "help" && second == "doctor" {
return true
}
if first == "doctor" && (second == "-h" || second == "--help") {
return true
}
return false
}
func (a *App) printGlobalHelp() error {
if _, err := fmt.Fprintf(a.stdout, "%s\n\n", binaryDescription); err != nil {
return err
}
if _, err := fmt.Fprintf(a.stdout, "Usage:\n %s <command> [args]\n\n", binaryName); err != nil {
return err
}
if _, err := fmt.Fprintln(a.stdout, "Common commands:"); err != nil {
return err
}
commands := []struct {
name string
description string
}{
{name: "setup", description: "Initialize or update local configuration."},
{name: "mcp", description: "Run the MCP server over stdio."},
{name: "config", description: "Inspect or test resolved configuration."},
{name: "doctor", description: "Run local diagnostics."},
{name: "update", description: "Run the self-update flow."},
{name: "version", description: "Print the binary version."},
}
for _, command := range commands {
if _, err := fmt.Fprintf(a.stdout, " %-7s %s\n", command.name, command.description); err != nil {
return err
}
}
_, err := fmt.Fprintf(a.stdout, "\nDetailed help: %s help <command>\n", binaryName)
return err
}
func (a *App) printDoctorHelp() error {
_, err := fmt.Fprintf(
a.stdout,
"Usage:\n %s doctor [--profile NAME]\n\nRun local diagnostics for config, wallet, manifest, and IMAP connectivity.\n",
binaryName,
)
return err
}
func (a *App) runConfig(ctx context.Context, command string, args []string) error { func (a *App) runConfig(ctx context.Context, command string, args []string) error {
if a.prompter == nil { if a.prompter == nil {
return fmt.Errorf("config prompter is not configured") return fmt.Errorf("config prompter is not configured")
@ -178,7 +256,7 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
return err return err
} }
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
profile := cfg.Profiles[profileName] profile := cfg.Profiles[profileName]
secrets, err := a.openSecretStore() secrets, err := a.openSecretStore()
@ -203,24 +281,9 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro
return err return err
} }
if shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) {
if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil { 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) return mapAppError(err)
} }
}
}
if cfg.Profiles == nil { if cfg.Profiles == nil {
cfg.Profiles = map[string]ProfileConfig{} cfg.Profiles = map[string]ProfileConfig{}
@ -257,7 +320,7 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error {
return err return err
} }
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
profile := cfg.Profiles[profileName] profile := cfg.Profiles[profileName]
secrets, err := a.openSecretStore() secrets, err := a.openSecretStore()
@ -265,7 +328,7 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error {
return mapAppError(err) return mapAppError(err)
} }
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName)) resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
if err != nil { if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError var missingErr *frameworkcli.MissingRequiredValuesError
if !errors.As(err, &missingErr) { if !errors.As(err, &missingErr) {
@ -293,62 +356,8 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error {
return nil return nil
} }
func (a *App) runConfigDelete(_ context.Context, args []string) error { func (a *App) runConfigTest(ctx context.Context, args []string) error {
if a.configStore == nil { return a.runDoctor(ctx, args)
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 { func (a *App) runMCP(ctx context.Context, args []string) error {
@ -394,13 +403,18 @@ func (a *App) runUpdate(ctx context.Context, args []string) error {
return fmt.Errorf("resolve executable path: %w", err) 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 { if err != nil {
return err 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) { func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) {
@ -433,7 +447,7 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
return secretstore.Credential{}, err return secretstore.Credential{}, err
} }
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
profile := cfg.Profiles[profileName] profile := cfg.Profiles[profileName]
secrets, err := a.openSecretStore() secrets, err := a.openSecretStore()
@ -441,7 +455,7 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
return secretstore.Credential{}, err return secretstore.Credential{}, err
} }
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName)) resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
if err != nil { if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError var missingErr *frameworkcli.MissingRequiredValuesError
if errors.As(err, &missingErr) { if errors.As(err, &missingErr) {
@ -466,24 +480,68 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
return cred, nil return cred, nil
} }
func profileFieldSpecs(profileName string) []frameworkcli.FieldSpec { func credentialFieldSpecs(profileName string) []frameworkcli.FieldSpec {
specs := mcpgen.ResolveFieldSpecs(profileName) return []frameworkcli.FieldSpec{
profileSpecs := make([]frameworkcli.FieldSpec, 0, len(specs)) {
for _, spec := range specs { Name: "host",
if spec.Name == "host" || spec.Name == "username" { Required: true,
profileSpecs = append(profileSpecs, spec) Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceConfig,
},
EnvKey: hostEnv,
ConfigKey: "host",
},
{
Name: "username",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceConfig,
},
EnvKey: usernameEnv,
ConfigKey: "username",
},
passwordFieldSpec(profileName),
} }
}
return profileSpecs
} }
func passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec { func profileFieldSpecs() []frameworkcli.FieldSpec {
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) { return []frameworkcli.FieldSpec{
if spec.Name == "password" { {
return []frameworkcli.FieldSpec{spec} Name: "host",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceConfig,
},
EnvKey: hostEnv,
ConfigKey: "host",
},
{
Name: "username",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceConfig,
},
EnvKey: usernameEnv,
ConfigKey: "username",
},
} }
}
func passwordFieldSpec(profileName string) frameworkcli.FieldSpec {
return frameworkcli.FieldSpec{
Name: "password",
Required: true,
Sources: []frameworkcli.ValueSource{
frameworkcli.SourceEnv,
frameworkcli.SourceSecret,
},
EnvKey: passwordEnv,
SecretKey: passwordSecretName(profileName),
} }
return nil
} }
func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) { func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []frameworkcli.FieldSpec) (frameworkcli.Resolution, error) {
@ -494,11 +552,31 @@ func resolveCredentialFields(profile ProfileConfig, store secretStore, fields []
return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{ return frameworkcli.ResolveFields(frameworkcli.ResolveOptions{
Fields: fields, Fields: fields,
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{ Lookup: func(source frameworkcli.ValueSource, key string) (string, bool, error) {
Env: frameworkcli.EnvLookup(os.LookupEnv), switch source {
Config: frameworkcli.ConfigMap(configValues), case frameworkcli.SourceEnv:
Secret: frameworkcli.SecretStore(store), value, ok := os.LookupEnv(strings.TrimSpace(key))
}), return value, ok, nil
case frameworkcli.SourceConfig:
value, ok := configValues[strings.TrimSpace(key)]
return value, ok, nil
case frameworkcli.SourceSecret:
if store == nil {
return "", false, nil
}
value, err := store.GetSecret(strings.TrimSpace(key))
if err != nil {
if errors.Is(err, frameworksecretstore.ErrNotFound) {
return "", false, nil
}
return "", false, err
}
return value, true, nil
default:
return "", false, nil
}
},
}) })
} }
@ -538,21 +616,9 @@ func loadStoredPassword(store secretStore, profileName string) (string, bool, er
} }
func passwordSecretName(profileName string) string { 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) 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) { func parseProfileArgs(command string, args []string) (string, error) {
flagSet := flag.NewFlagSet(command, flag.ContinueOnError) flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
flagSet.SetOutput(io.Discard) flagSet.SetOutput(io.Discard)
@ -582,103 +648,6 @@ func parseUpdateArgs(args []string) error {
return nil 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 { func mapAppError(err error) error {
if err == nil { if err == nil {
return nil return nil
@ -688,7 +657,10 @@ func mapAppError(err error) error {
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured): case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
return newUserFacingError("credentials not configured; run `email-mcp setup`", err) return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable): 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): case errors.Is(err, frameworksecretstore.ErrReadOnly):
return newUserFacingError("secret backend is read-only", err) return newUserFacingError("secret backend is read-only", err)
default: default:

View file

@ -11,9 +11,9 @@ import (
"strings" "strings"
"testing" "testing"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
"email-mcp/internal/imapclient" "email-mcp/internal/imapclient"
"email-mcp/internal/mcpserver" "email-mcp/internal/mcpserver"
@ -87,12 +87,9 @@ type secretStoreStub struct {
values map[string]string values map[string]string
setErr error setErr error
getErr error getErr error
deleteErr error
setName string setName string
setValue string setValue string
setCalled bool setCalled bool
delName string
delCalled bool
} }
func (s *secretStoreStub) SetSecret(name, _ string, secret string) error { 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 { func (s *secretStoreStub) DeleteSecret(name string) error {
s.delCalled = true
s.delName = name
if s.deleteErr != nil {
return s.deleteErr
}
delete(s.values, name) delete(s.values, name)
return nil return nil
} }
@ -158,42 +150,7 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
} }
text := output.String() text := output.String()
for _, snippet := range []string{"Usage:", "config", "version", `doctor Diagnostiquer la configuration locale. (alias de "config test").`} { for _, snippet := range []string{"Usage:", "doctor", "version"} {
if !strings.Contains(text, snippet) {
t.Fatalf("help output missing %q: %q", snippet, text)
}
}
}
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) { if !strings.Contains(text, snippet) {
t.Fatalf("help output missing %q: %q", snippet, text) t.Fatalf("help output missing %q: %q", snippet, text)
} }
@ -219,19 +176,7 @@ func TestAppRunDoctorHelp(t *testing.T) {
if err := app.Run([]string{"doctor", "--help"}); err != nil { if err := app.Run([]string{"doctor", "--help"}); err != nil {
t.Fatalf("doctor help returned error: %v", err) t.Fatalf("doctor help returned error: %v", err)
} }
if got := output.String(); !strings.Contains(got, "email-mcp config test [args]") { if got := output.String(); !strings.Contains(got, "email-mcp doctor [--profile NAME]") {
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) t.Fatalf("unexpected doctor help output: %q", got)
} }
} }
@ -289,56 +234,6 @@ 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 TestAppRunConfigRequiresSubcommand(t *testing.T) {
prompter := &configPrompterStub{ prompter := &configPrompterStub{
credential: secretstore.Credential{ credential: secretstore.Credential{
@ -376,119 +271,6 @@ func TestAppRunConfigRequiresSubcommand(t *testing.T) {
} }
} }
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 TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) {
prompter := &capturingPrompterStub{ prompter := &capturingPrompterStub{
credential: secretstore.Credential{ credential: secretstore.Credential{
@ -540,85 +322,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) { func TestAppRunConfigShowPrintsResolvedConfiguration(t *testing.T) {
cfgStore := &configStoreStub{ cfgStore := &configStoreStub{
cfg: frameworkconfig.FileConfig[ProfileConfig]{ cfg: frameworkconfig.FileConfig[ProfileConfig]{
@ -846,9 +549,8 @@ func TestAppRunUpdateLoadsManifestNearExecutable(t *testing.T) {
if err := os.WriteFile(filepath.Join(tempDir, "mcp.toml"), []byte(` if err := os.WriteFile(filepath.Join(tempDir, "mcp.toml"), []byte(`
[update] [update]
source_name = "test" source_name = "test"
driver = "gitea"
repository = "AI/email-mcp"
base_url = "http://127.0.0.1:1" base_url = "http://127.0.0.1:1"
latest_release_url = "http://127.0.0.1:1/releases/latest"
`), 0o600); err != nil { `), 0o600); err != nil {
t.Fatalf("WriteFile manifest returned error: %v", err) t.Fatalf("WriteFile manifest returned error: %v", err)
} }
@ -922,9 +624,7 @@ func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) {
manifestDir := t.TempDir() manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update] [update]
driver = "gitea" latest_release_url = "https://example.com/releases/latest"
repository = "AI/email-mcp"
base_url = "https://gitea.lclr.dev"
`), 0o600); err != nil { `), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err) t.Fatalf("WriteFile returned error: %v", err)
} }
@ -963,9 +663,10 @@ base_url = "https://gitea.lclr.dev"
text := output.String() text := output.String()
for _, needle := range []string{ for _, needle := range []string{
"[OK] config: config file is readable", "[OK] config: config file is readable",
"[OK] profile: required profile values are resolved", "[OK] profile: resolved profile is complete",
"[OK] password: stored password is present",
"[OK] connectivity: IMAP server is reachable", "[OK] connectivity: IMAP server is reachable",
"Summary: 4 ok, 0 warning(s), 0 failure(s), 4 total", "Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total",
} { } {
if !strings.Contains(text, needle) { if !strings.Contains(text, needle) {
t.Fatalf("output = %q, want substring %q", text, needle) t.Fatalf("output = %q, want substring %q", text, needle)
@ -995,6 +696,14 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
t.Fatalf("Save returned error: %v", err) t.Fatalf("Save returned error: %v", err)
} }
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
latest_release_url = "https://example.com/releases/latest"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
output := &bytes.Buffer{} output := &bytes.Buffer{}
app := NewAppWithDependencies( app := NewAppWithDependencies(
nil, nil,
@ -1003,7 +712,7 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
func() mcpserver.MailService { return &doctorMailServiceStub{} }, func() mcpserver.MailService { return &doctorMailServiceStub{} },
nil, nil,
nil, nil,
nil, func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
nil, nil,
output, output,
&bytes.Buffer{}, &bytes.Buffer{},
@ -1017,7 +726,7 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
if !strings.Contains(err.Error(), "doctor checks failed") { if !strings.Contains(err.Error(), "doctor checks failed") {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if !strings.Contains(output.String(), "[FAIL] connectivity: cannot load IMAP credentials") { if !strings.Contains(output.String(), "[FAIL] password: stored password is missing") {
t.Fatalf("unexpected output: %q", output.String()) t.Fatalf("unexpected output: %q", output.String())
} }
} }
@ -1046,6 +755,14 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
t.Fatalf("Save returned error: %v", err) t.Fatalf("Save returned error: %v", err)
} }
manifestDir := t.TempDir()
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
[update]
latest_release_url = "https://example.com/releases/latest"
`), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
output := &bytes.Buffer{} output := &bytes.Buffer{}
app := NewAppWithDependencies( app := NewAppWithDependencies(
nil, nil,
@ -1054,7 +771,7 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
func() mcpserver.MailService { return &doctorMailServiceStub{} }, func() mcpserver.MailService { return &doctorMailServiceStub{} },
nil, nil,
nil, nil,
nil, func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
nil, nil,
output, output,
&bytes.Buffer{}, &bytes.Buffer{},
@ -1064,12 +781,11 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
if err := app.Run([]string{"doctor"}); err != nil { if err := app.Run([]string{"doctor"}); err != nil {
t.Fatalf("doctor returned error: %v", err) t.Fatalf("doctor returned error: %v", err)
} }
if !strings.Contains(output.String(), "[OK] connectivity: IMAP server is reachable") { if !strings.Contains(output.String(), "[OK] password: password is provided via environment") {
t.Fatalf("unexpected output: %q", output.String()) t.Fatalf("unexpected output: %q", output.String())
} }
} }
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
@ -1109,7 +825,7 @@ func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
} }
} }
func TestMapAppErrorMapsUnavailableSecretBackendError(t *testing.T) { func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
err := mapAppError(&frameworksecretstore.BackendUnavailableError{ err := mapAppError(&frameworksecretstore.BackendUnavailableError{
Policy: frameworksecretstore.BackendAuto, Policy: frameworksecretstore.BackendAuto,
Required: "any keyring backend", Required: "any keyring backend",
@ -1117,25 +833,8 @@ func TestMapAppErrorMapsUnavailableSecretBackendError(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected mapped error") t.Fatal("expected mapped error")
} }
if !strings.Contains(strings.ToLower(err.Error()), "secret backend") { if !strings.Contains(strings.ToLower(err.Error()), "wallet") {
t.Fatalf("expected secret backend guidance, got %v", err) 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)
} }
} }
@ -1163,7 +862,7 @@ func TestExecuteSetupWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) {
if code := Execute(app, []string{"setup"}, stderr); code != 1 { if code := Execute(app, []string{"setup"}, stderr); code != 1 {
t.Fatalf("expected exit code 1, got %d", code) 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) t.Fatalf("unexpected stderr: %q", got)
} }
} }

View file

@ -2,13 +2,17 @@ package cli
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"time" "time"
"email-mcp/mcpgen" frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli" frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
) )
func (a *App) runDoctor(ctx context.Context, args []string) error { func (a *App) runDoctor(ctx context.Context, args []string) error {
@ -27,11 +31,19 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
} }
report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{ report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)), ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)),
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore), SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.frameworkSecretStoreFactory()),
ManifestDir: a.doctorManifestDir(),
ManifestValidator: func(file frameworkmanifest.File, _ string) []string {
if strings.TrimSpace(file.Update.LatestReleaseURL) == "" {
return []string{"update.latest_release_url must not be empty"}
}
return nil
},
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag), ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
ExtraChecks: []frameworkcli.DoctorCheck{ ExtraChecks: []frameworkcli.DoctorCheck{
a.doctorRequiredProfileFieldsCheck(profileFlag), a.doctorProfileCheck(profileFlag),
a.doctorPasswordCheck(profileFlag),
}, },
}) })
if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil { if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil {
@ -43,44 +55,135 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
return nil return nil
} }
func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.DoctorCheck { func (a *App) frameworkSecretStoreFactory() func() (frameworksecretstore.Store, error) {
var ( return func() (frameworksecretstore.Store, error) {
profileValues map[string]string store, err := a.openSecretStore()
loadErr error if err != nil {
) return nil, err
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 { return store, nil
}
}
func (a *App) doctorManifestDir() string {
if a.resolveExecutable == nil {
return "."
}
executablePath, err := a.resolveExecutable()
if err != nil {
return "."
}
return filepath.Dir(executablePath)
}
func (a *App) doctorProfileCheck(profileFlag string) frameworkcli.DoctorCheck {
return func(context.Context) frameworkcli.DoctorResult {
cfg, _, err := a.configStore.LoadDefault() cfg, _, err := a.configStore.LoadDefault()
if err != nil { if err != nil {
loadErr = err return frameworkcli.DoctorResult{
return "", false, loadErr Name: "profile",
} Status: frameworkcli.DoctorStatusFail,
Summary: "cannot load profile configuration",
profileName := a.resolveProfileName(profileFlag, cfg.CurrentProfile) Detail: err.Error(),
profile := cfg.Profiles[profileName]
profileValues = map[string]string{
"host": profile.Host,
"username": profile.Username,
} }
} }
return frameworkcli.MapLookup(profileValues)(key) profileName := frameworkcli.ResolveProfileName(profileFlag, os.Getenv(defaultProfileEnv), cfg.CurrentProfile)
}, resolution, err := resolveCredentialFields(cfg.Profiles[profileName], nil, profileFieldSpecs())
}), if err != nil {
}) var missingErr *frameworkcli.MissingRequiredValuesError
if errors.As(err, &missingErr) {
return frameworkcli.DoctorResult{
Name: "profile",
Status: frameworkcli.DoctorStatusFail,
Summary: "resolved profile is incomplete",
Detail: fmt.Sprintf("profile %q: missing %s", profileName, strings.Join(missingErr.Fields, ", ")),
}
}
return func(ctx context.Context) frameworkcli.DoctorResult { return frameworkcli.DoctorResult{
result := check(ctx) Name: "profile",
result.Name = "profile" Status: frameworkcli.DoctorStatusFail,
return result Summary: "cannot resolve profile values",
Detail: err.Error(),
}
}
host, _ := resolution.Get("host")
username, _ := resolution.Get("username")
return frameworkcli.DoctorResult{
Name: "profile",
Status: frameworkcli.DoctorStatusOK,
Summary: "resolved profile is complete",
Detail: fmt.Sprintf(
"profile %q (host: %s, username: %s)",
profileName,
host.Source,
username.Source,
),
}
}
}
func (a *App) doctorPasswordCheck(profileFlag string) frameworkcli.DoctorCheck {
return func(context.Context) frameworkcli.DoctorResult {
profileName := a.resolveDoctorProfileName(profileFlag)
store, err := a.openSecretStore()
if err != nil {
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusFail,
Summary: "cannot inspect stored password",
Detail: err.Error(),
}
}
resolution, err := resolveCredentialFields(
ProfileConfig{},
store,
[]frameworkcli.FieldSpec{passwordFieldSpec(profileName)},
)
if err != nil {
var missingErr *frameworkcli.MissingRequiredValuesError
if errors.As(err, &missingErr) {
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusFail,
Summary: "stored password is missing",
Detail: fmt.Sprintf(
"set %q or secret %q",
passwordEnv,
passwordSecretName(profileName),
),
}
}
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusFail,
Summary: "cannot read stored password",
Detail: err.Error(),
}
}
password, _ := resolution.Get("password")
if password.Source == frameworkcli.SourceEnv {
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusOK,
Summary: "password is provided via environment",
Detail: fmt.Sprintf("variable %q", passwordEnv),
}
}
return frameworkcli.DoctorResult{
Name: "password",
Status: frameworkcli.DoctorStatusOK,
Summary: "stored password is present",
Detail: fmt.Sprintf("secret %q", passwordSecretName(profileName)),
}
} }
} }
@ -117,15 +220,15 @@ func (a *App) doctorConnectivityCheck(profileFlag string) frameworkcli.DoctorChe
} }
func (a *App) resolveDoctorProfileName(profileFlag string) string { func (a *App) resolveDoctorProfileName(profileFlag string) string {
envProfile := os.Getenv(defaultProfileEnv)
if a.configStore == nil { if a.configStore == nil {
return a.resolveProfileName(profileFlag, "") return frameworkcli.ResolveProfileName(profileFlag, envProfile, "")
} }
cfg, _, err := a.configStore.LoadDefault() cfg, _, err := a.configStore.LoadDefault()
if err != nil { if err != nil {
return a.resolveProfileName(profileFlag, "") return frameworkcli.ResolveProfileName(profileFlag, envProfile, "")
} }
return a.resolveProfileName(profileFlag, cfg.CurrentProfile) return frameworkcli.ResolveProfileName(profileFlag, envProfile, cfg.CurrentProfile)
} }

View file

@ -8,8 +8,7 @@ import (
"os" "os"
"strings" "strings"
"email-mcp/mcpgen" frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
"email-mcp/internal/secretstore" "email-mcp/internal/secretstore"
) )
@ -77,20 +76,32 @@ func (p *InteractiveConfigPrompter) promptCredentialWithSetupEngine(existing sec
password = existing.Password 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{ result, err := frameworkcli.RunSetup(frameworkcli.SetupOptions{
Stdin: p.stdinFile, Stdin: p.stdinFile,
Stdout: p.output, Stdout: p.output,
Fields: fields, Fields: []frameworkcli.SetupField{
{
Name: "host",
Label: "IMAP host",
Type: frameworkcli.SetupFieldString,
Required: true,
Default: existing.Host,
},
{
Name: "username",
Label: "Username",
Type: frameworkcli.SetupFieldString,
Required: true,
Default: existing.Username,
},
{
Name: "password",
Label: "Password",
Type: frameworkcli.SetupFieldSecret,
Required: true,
ExistingSecret: password,
},
},
}) })
if err != nil { if err != nil {
return secretstore.Credential{}, err return secretstore.Credential{}, err

View file

@ -4,11 +4,10 @@ import (
"context" "context"
"io" "io"
"os" "os"
"strings"
"email-mcp/mcpgen" frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore" frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
"email-mcp/internal/imapclient" "email-mcp/internal/imapclient"
"email-mcp/internal/mcpserver" "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 { func (f runtimeFactories) withDefaults() runtimeFactories {
useGeneratedManifest := f.loadManifest == nil
if f.newPrompter == nil { if f.newPrompter == nil {
f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter { f.newPrompter = func(input io.Reader, output io.Writer) ConfigPrompter {
return NewInteractiveConfigPrompter(input, output) return NewInteractiveConfigPrompter(input, output)
@ -56,29 +54,14 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
} }
if f.newConfigStore == nil { if f.newConfigStore == nil {
f.newConfigStore = func() profileConfigStore { 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 { if f.openSecretStore == nil {
f.openSecretStore = func() (secretStore, error) { f.openSecretStore = func() (secretStore, error) {
if useGeneratedManifest { return frameworksecretstore.Open(frameworksecretstore.Options{
return mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{ ServiceName: "email-mcp",
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable), BackendPolicy: frameworksecretstore.BackendAuto,
LookupEnv: profilePasswordLookupEnv,
})
}
return frameworksecretstore.OpenFromManifest(frameworksecretstore.OpenFromManifestOptions{
ServiceName: mcpgen.BinaryName,
ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest),
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
LookupEnv: profilePasswordLookupEnv,
}) })
} }
} }
@ -92,18 +75,16 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
return mcpserver.NewRunner(mcpserver.New(staticCredentialStore{credential: cred}, mail), input, output, errOut) return mcpserver.NewRunner(mcpserver.New(staticCredentialStore{credential: cred}, mail), input, output, errOut)
} }
} }
if f.loadManifest == nil {
f.loadManifest = frameworkmanifest.LoadDefault
}
if f.resolveExecutable == nil {
f.resolveExecutable = os.Executable
}
return f return f
} }
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 { type staticCredentialStore struct {
credential secretstore.Credential credential secretstore.Credential
} }

View file

@ -1,11 +1,6 @@
package cli package cli
import ( import "testing"
"strings"
"testing"
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestBuildAppReturnsConfiguredApp(t *testing.T) { func TestBuildAppReturnsConfiguredApp(t *testing.T) {
app := BuildApp("dev") app := BuildApp("dev")
@ -31,52 +26,3 @@ func TestBuildAppReturnsConfiguredApp(t *testing.T) {
t.Fatal("expected manifest loader to be configured") 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

@ -1,60 +1,4 @@
binary_name = "email-mcp"
docs_url = "https://forge.lclr.dev/AI/email-mcp"
[update] [update]
source_name = "email-mcp releases" source_name = "email-mcp releases"
driver = "gitea" base_url = "https://gitea.lclr.dev"
repository = "AI/email-mcp" latest_release_url = "https://gitea.lclr.dev/api/v1/repos/AI/email-mcp/releases/latest"
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"]

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