Compare commits
24 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0de2a2b351 | |||
| 72fb48f59e | |||
| 23fff88ccb | |||
| 235727106d | |||
| 1e0bdfc42d | |||
| 51dda82cd0 | |||
| fbaea341c7 | |||
| ebd3a64522 | |||
| 801d7fbb95 | |||
| fe9e70b61a | |||
| be2b7e631b | |||
| be33b467a6 | |||
| a87db60345 | |||
| 9b90332b7c | |||
| 9c6f8b70d4 | |||
| b6276776fd | |||
| 678b966425 | |||
| 226d636ca2 | |||
| a19b35cbea | |||
| 21808ddce0 | |||
| ec3774138e | |||
| 647a03a10c | |||
| 7315f2658c | |||
| 46c94a1a12 |
19 changed files with 1155 additions and 393 deletions
10
Makefile
10
Makefile
|
|
@ -14,7 +14,7 @@ endif
|
|||
|
||||
OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
|
||||
|
||||
.PHONY: build test
|
||||
.PHONY: build test generate generate-check
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR) $(GOCACHE)
|
||||
|
|
@ -23,3 +23,11 @@ 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
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -6,8 +6,9 @@ Le binaire s’appuie 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 le wallet natif de l’OS
|
||||
- le stockage du mot de passe dans Bitwarden via `bw`
|
||||
- le manifeste `mcp.toml`
|
||||
- les helpers Go générés depuis `mcp.toml` (`mcpgen/`)
|
||||
- l’auto-update via `email-mcp update`
|
||||
|
||||
## Commandes
|
||||
|
|
@ -17,7 +18,7 @@ Le binaire s’appuie maintenant sur [`mcp-framework`](../mcp-framework) pour :
|
|||
- `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 doctor` : diagnostique la configuration locale, le wallet, le manifeste et l’accès IMAP
|
||||
- `email-mcp doctor` : diagnostique la configuration locale, Bitwarden, le manifeste et l’accès IMAP
|
||||
- `email-mcp update` : met à jour le binaire courant depuis la dernière release
|
||||
- `email-mcp version` : affiche la version du binaire
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ La commande `email-mcp help` (ou `-h` / `--help`) affiche l’aide globale.
|
|||
La configuration est séparée en deux parties :
|
||||
|
||||
- `host` et `username` sont stockés dans `config.json`
|
||||
- `password` est stocké dans le wallet système
|
||||
- `password` est stocké dans Bitwarden via le CLI `bw`
|
||||
|
||||
Le profil actif est résolu dans cet ordre :
|
||||
|
||||
|
|
@ -44,11 +45,11 @@ Le profil actif est résolu dans cet ordre :
|
|||
4. `[profiles].default` dans `mcp.toml`
|
||||
5. `default`
|
||||
|
||||
Les credentials IMAP sont résolus ensuite via le résolveur multi-sources du framework (RC3) :
|
||||
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 wallet `imap-password/<profile>`
|
||||
3. `password` : `EMAIL_MCP_PASSWORD` puis secret Bitwarden `imap-password/<profile>`
|
||||
|
||||
### Configurer un profil
|
||||
|
||||
|
|
@ -68,9 +69,9 @@ Le binaire demande ensuite :
|
|||
2. le nom d’utilisateur
|
||||
3. le mot de passe
|
||||
|
||||
Si un mot de passe existe déjà dans le wallet, laisser le champ vide le conserve.
|
||||
Si un mot de passe existe déjà dans Bitwarden, laisser le champ vide le conserve.
|
||||
|
||||
Si le backend de secrets est en lecture seule (`[secret_store].backend_policy = "env-only"`), `setup` ne peut pas persister le mot de passe dans un wallet. Dans ce cas, exporte `EMAIL_MCP_PASSWORD` avant `setup`. La commande sauvegarde alors `host`/`username` et utilise le mot de passe depuis l’environnement.
|
||||
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 l’environnement. `EMAIL_MCP_PASSWORD` reste accepté pour fournir le mot de passe sans lire Bitwarden.
|
||||
|
||||
### Lancer le serveur MCP
|
||||
|
||||
|
|
@ -134,7 +135,7 @@ token_env_names = ["GITEA_TOKEN"]
|
|||
|
||||
- la lisibilité du fichier de configuration
|
||||
- le profil IMAP résolu
|
||||
- la disponibilité du wallet système
|
||||
- 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
|
||||
|
|
@ -153,6 +154,20 @@ Pour l’update, la validation du manifeste accepte :
|
|||
|
||||
## Installation
|
||||
|
||||
### Installateur interactif
|
||||
|
||||
Le repo inclut un assistant interactif `install.sh` :
|
||||
|
||||
```sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Tu peux aussi l’exé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` :
|
||||
|
|
@ -207,3 +222,10 @@ 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
2
go.mod
|
|
@ -3,7 +3,7 @@ module email-mcp
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
gitea.lclr.dev/AI/mcp-framework v1.3.1
|
||||
forge.lclr.dev/AI/mcp-framework v1.13.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
10
go.sum
|
|
@ -1,5 +1,11 @@
|
|||
gitea.lclr.dev/AI/mcp-framework v1.3.1 h1:GxT5bV22+hbLLUz2IMCscb3qLdkqX5u9d1dbHnUs7RI=
|
||||
gitea.lclr.dev/AI/mcp-framework v1.3.1/go.mod h1:kUVMrL3/UBYgjOsW7sJCs3V0pO0qoJJMpIpueoTsoA4=
|
||||
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=
|
||||
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=
|
||||
|
|
|
|||
625
install.sh
Executable file
625
install.sh
Executable file
|
|
@ -0,0 +1,625 @@
|
|||
#!/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 "$@"
|
||||
|
|
@ -11,24 +11,24 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
frameworkbootstrap "gitea.lclr.dev/AI/mcp-framework/bootstrap"
|
||||
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/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"
|
||||
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
)
|
||||
|
||||
const (
|
||||
binaryName = "email-mcp"
|
||||
binaryName = mcpgen.BinaryName
|
||||
defaultProfileEnv = "EMAIL_MCP_PROFILE"
|
||||
hostEnv = "EMAIL_MCP_HOST"
|
||||
usernameEnv = "EMAIL_MCP_USERNAME"
|
||||
passwordEnv = "EMAIL_MCP_PASSWORD"
|
||||
binaryDescription = "Local MCP server to read an IMAP mailbox."
|
||||
fallbackProfile = "default"
|
||||
)
|
||||
|
||||
|
|
@ -115,7 +115,10 @@ func NewAppWithDependencies(
|
|||
}
|
||||
|
||||
func (a *App) Run(args []string) error {
|
||||
return a.runBootstrap(context.Background(), normalizeArgs(args))
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
}
|
||||
return a.runBootstrap(context.Background(), args)
|
||||
}
|
||||
|
||||
func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
||||
|
|
@ -125,6 +128,7 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
|||
BinaryName: metadata.BinaryName,
|
||||
Description: metadata.Description,
|
||||
Version: a.version,
|
||||
EnableDoctorAlias: true,
|
||||
Args: args,
|
||||
Stdin: a.stdin,
|
||||
Stdout: a.stdout,
|
||||
|
|
@ -133,6 +137,7 @@ func (a *App) runBootstrap(ctx context.Context, args []string) error {
|
|||
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)
|
||||
},
|
||||
|
|
@ -260,7 +265,7 @@ func (a *App) runConfigShow(ctx context.Context, args []string) error {
|
|||
return mapAppError(err)
|
||||
}
|
||||
|
||||
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
|
||||
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
|
||||
if err != nil {
|
||||
var missingErr *frameworkcli.MissingRequiredValuesError
|
||||
if !errors.As(err, &missingErr) {
|
||||
|
|
@ -389,18 +394,13 @@ func (a *App) runUpdate(ctx context.Context, args []string) error {
|
|||
return fmt.Errorf("resolve executable path: %w", err)
|
||||
}
|
||||
|
||||
manifestFile, err := a.loadManifestForExecutable(executablePath)
|
||||
options, err := mcpgen.UpdateOptionsFrom(filepath.Dir(executablePath), a.version, a.stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options.ExecutablePath = executablePath
|
||||
|
||||
return frameworkupdate.Run(ctx, frameworkupdate.Options{
|
||||
CurrentVersion: a.version,
|
||||
ExecutablePath: executablePath,
|
||||
BinaryName: a.runtimeMetadata().BinaryName,
|
||||
ReleaseSource: manifestFile.Update.ReleaseSource(),
|
||||
Stdout: a.stdout,
|
||||
})
|
||||
return frameworkupdate.Run(ctx, options)
|
||||
}
|
||||
|
||||
func (a *App) loadManifestForExecutable(executablePath string) (frameworkmanifest.File, error) {
|
||||
|
|
@ -441,7 +441,7 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
|
|||
return secretstore.Credential{}, err
|
||||
}
|
||||
|
||||
resolution, err := resolveCredentialFields(profile, secrets, credentialFieldSpecs(profileName))
|
||||
resolution, err := resolveCredentialFields(profile, secrets, mcpgen.ResolveFieldSpecs(profileName))
|
||||
if err != nil {
|
||||
var missingErr *frameworkcli.MissingRequiredValuesError
|
||||
if errors.As(err, &missingErr) {
|
||||
|
|
@ -466,68 +466,24 @@ func (a *App) loadCredential(profileFlag string) (secretstore.Credential, error)
|
|||
return cred, nil
|
||||
}
|
||||
|
||||
func credentialFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
||||
return []frameworkcli.FieldSpec{
|
||||
{
|
||||
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",
|
||||
},
|
||||
passwordFieldSpec(profileName),
|
||||
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 profileFieldSpecs() []frameworkcli.FieldSpec {
|
||||
return []frameworkcli.FieldSpec{
|
||||
{
|
||||
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 passwordOnlyFieldSpecs(profileName string) []frameworkcli.FieldSpec {
|
||||
for _, spec := range mcpgen.ResolveFieldSpecs(profileName) {
|
||||
if spec.Name == "password" {
|
||||
return []frameworkcli.FieldSpec{spec}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -582,6 +538,11 @@ 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)
|
||||
}
|
||||
|
||||
|
|
@ -629,8 +590,8 @@ type runtimeMetadata struct {
|
|||
|
||||
func (a *App) runtimeMetadata() runtimeMetadata {
|
||||
metadata := runtimeMetadata{
|
||||
BinaryName: binaryName,
|
||||
Description: binaryDescription,
|
||||
BinaryName: mcpgen.BinaryName,
|
||||
Description: mcpgen.DefaultDescription,
|
||||
DefaultProfile: fallbackProfile,
|
||||
}
|
||||
|
||||
|
|
@ -727,10 +688,7 @@ func mapAppError(err error) error {
|
|||
case errors.Is(err, mcpserver.ErrCredentialsNotConfigured):
|
||||
return newUserFacingError("credentials not configured; run `email-mcp setup`", err)
|
||||
case errors.Is(err, frameworksecretstore.ErrBackendUnavailable):
|
||||
return newUserFacingError(
|
||||
fmt.Sprintf("%s is not available; configure a supported OS wallet and retry", frameworksecretstore.BackendName()),
|
||||
err,
|
||||
)
|
||||
return newUserFacingError(strings.TrimSpace(err.Error()), err)
|
||||
case errors.Is(err, frameworksecretstore.ErrReadOnly):
|
||||
return newUserFacingError("secret backend is read-only", err)
|
||||
default:
|
||||
|
|
@ -778,40 +736,3 @@ func newUserFacingError(message string, err error) error {
|
|||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeArgs(args []string) []string {
|
||||
if len(args) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
normalized := append([]string(nil), args...)
|
||||
for i, arg := range normalized {
|
||||
normalized[i] = strings.TrimSpace(arg)
|
||||
}
|
||||
|
||||
switch normalized[0] {
|
||||
case "doctor":
|
||||
if len(normalized) == 1 {
|
||||
return []string{frameworkbootstrap.CommandConfig, frameworkbootstrap.ConfigSubcommandTest}
|
||||
}
|
||||
|
||||
lastArg := normalized[len(normalized)-1]
|
||||
if lastArg == "-h" || lastArg == "--help" {
|
||||
return []string{"help", frameworkbootstrap.CommandConfig, frameworkbootstrap.ConfigSubcommandTest}
|
||||
}
|
||||
|
||||
return append(
|
||||
[]string{frameworkbootstrap.CommandConfig, frameworkbootstrap.ConfigSubcommandTest},
|
||||
normalized[1:]...,
|
||||
)
|
||||
case "help":
|
||||
if len(normalized) > 1 && normalized[1] == "doctor" {
|
||||
return append(
|
||||
[]string{"help", frameworkbootstrap.CommandConfig, frameworkbootstrap.ConfigSubcommandTest},
|
||||
normalized[2:]...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
|
|
@ -158,7 +158,7 @@ func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
|
|||
}
|
||||
|
||||
text := output.String()
|
||||
for _, snippet := range []string{"Usage:", "config", "version"} {
|
||||
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)
|
||||
}
|
||||
|
|
@ -964,9 +964,8 @@ base_url = "https://gitea.lclr.dev"
|
|||
for _, needle := range []string{
|
||||
"[OK] config: config file is readable",
|
||||
"[OK] profile: required profile values are resolved",
|
||||
"[OK] password: stored password is present",
|
||||
"[OK] connectivity: IMAP server is reachable",
|
||||
"Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total",
|
||||
"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)
|
||||
|
|
@ -996,16 +995,6 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
|
||||
output := &bytes.Buffer{}
|
||||
app := NewAppWithDependencies(
|
||||
nil,
|
||||
|
|
@ -1014,7 +1003,7 @@ base_url = "https://gitea.lclr.dev"
|
|||
func() mcpserver.MailService { return &doctorMailServiceStub{} },
|
||||
nil,
|
||||
nil,
|
||||
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
|
||||
nil,
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
|
|
@ -1028,7 +1017,7 @@ base_url = "https://gitea.lclr.dev"
|
|||
if !strings.Contains(err.Error(), "doctor checks failed") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "[FAIL] password: stored password is missing") {
|
||||
if !strings.Contains(output.String(), "[FAIL] connectivity: cannot load IMAP credentials") {
|
||||
t.Fatalf("unexpected output: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
|
@ -1057,16 +1046,6 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
|
||||
output := &bytes.Buffer{}
|
||||
app := NewAppWithDependencies(
|
||||
nil,
|
||||
|
|
@ -1075,7 +1054,7 @@ base_url = "https://gitea.lclr.dev"
|
|||
func() mcpserver.MailService { return &doctorMailServiceStub{} },
|
||||
nil,
|
||||
nil,
|
||||
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
|
||||
nil,
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
|
|
@ -1085,78 +1064,11 @@ base_url = "https://gitea.lclr.dev"
|
|||
if err := app.Run([]string{"doctor"}); err != nil {
|
||||
t.Fatalf("doctor returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "[OK] password: password is provided via environment") {
|
||||
if !strings.Contains(output.String(), "[OK] connectivity: IMAP server is reachable") {
|
||||
t.Fatalf("unexpected output: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunDoctorFailsWhenManifestUpdateConfigIsInvalid(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)
|
||||
}
|
||||
|
||||
manifestDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(`
|
||||
[update]
|
||||
driver = "gitea"
|
||||
base_url = "https://gitea.lclr.dev"
|
||||
`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
mail := &doctorMailServiceStub{
|
||||
listMailboxes: []imapclient.Mailbox{{Name: "INBOX"}},
|
||||
}
|
||||
output := &bytes.Buffer{}
|
||||
app := NewAppWithDependencies(
|
||||
nil,
|
||||
store,
|
||||
func() (secretStore, error) { return &secretStoreStub{}, nil },
|
||||
func() mcpserver.MailService { return mail },
|
||||
nil,
|
||||
nil,
|
||||
func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil },
|
||||
nil,
|
||||
output,
|
||||
&bytes.Buffer{},
|
||||
"dev",
|
||||
)
|
||||
|
||||
err = app.Run([]string{"doctor"})
|
||||
if err == nil {
|
||||
t.Fatal("expected doctor to fail with invalid manifest update config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "doctor checks failed") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
text := output.String()
|
||||
if !strings.Contains(text, "[FAIL] manifest: manifest validation failed") {
|
||||
t.Fatalf("unexpected output: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "requires repository") {
|
||||
t.Fatalf("unexpected output: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
|
||||
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
|
||||
|
|
@ -1197,7 +1109,7 @@ func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
|
||||
func TestMapAppErrorMapsUnavailableSecretBackendError(t *testing.T) {
|
||||
err := mapAppError(&frameworksecretstore.BackendUnavailableError{
|
||||
Policy: frameworksecretstore.BackendAuto,
|
||||
Required: "any keyring backend",
|
||||
|
|
@ -1205,8 +1117,25 @@ func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected mapped error")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "wallet") {
|
||||
t.Fatalf("expected wallet guidance, got %v", err)
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "secret backend") {
|
||||
t.Fatalf("expected secret backend 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1234,7 +1163,7 @@ func TestExecuteSetupWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) {
|
|||
if code := Execute(app, []string{"setup"}, stderr); code != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", code)
|
||||
}
|
||||
if got := strings.ToLower(stderr.String()); !strings.Contains(got, "wallet") {
|
||||
if got := strings.ToLower(stderr.String()); !strings.Contains(got, "secret backend") {
|
||||
t.Fatalf("unexpected stderr: %q", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,13 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update"
|
||||
"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 {
|
||||
|
|
@ -31,18 +26,12 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
|
|||
return fmt.Errorf("mail service is not configured")
|
||||
}
|
||||
|
||||
metadata := a.runtimeMetadata()
|
||||
report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{
|
||||
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)),
|
||||
ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)),
|
||||
SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.openSecretStore),
|
||||
ManifestDir: a.doctorManifestDir(),
|
||||
ManifestValidator: func(file frameworkmanifest.File, _ string) []string {
|
||||
return validateManifestUpdate(file, metadata.BinaryName)
|
||||
},
|
||||
ConnectivityCheck: a.doctorConnectivityCheck(profileFlag),
|
||||
ExtraChecks: []frameworkcli.DoctorCheck{
|
||||
a.doctorRequiredProfileFieldsCheck(profileFlag),
|
||||
a.doctorPasswordCheck(profileFlag),
|
||||
},
|
||||
})
|
||||
if err := frameworkcli.RenderDoctorReport(a.stdout, report); err != nil {
|
||||
|
|
@ -54,18 +43,6 @@ func (a *App) runDoctor(ctx context.Context, args []string) error {
|
|||
return 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) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.DoctorCheck {
|
||||
var (
|
||||
profileValues map[string]string
|
||||
|
|
@ -73,7 +50,7 @@ func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.
|
|||
)
|
||||
|
||||
check := frameworkcli.RequiredResolvedFieldsCheck(frameworkcli.ResolveOptions{
|
||||
Fields: profileFieldSpecs(),
|
||||
Fields: profileFieldSpecs(a.resolveDoctorProfileName(profileFlag)),
|
||||
Lookup: frameworkcli.ResolveLookup(frameworkcli.ResolveLookupOptions{
|
||||
Env: frameworkcli.EnvLookup(os.LookupEnv),
|
||||
Config: func(key string) (string, bool, error) {
|
||||
|
|
@ -107,66 +84,6 @@ func (a *App) doctorRequiredProfileFieldsCheck(profileFlag string) frameworkcli.
|
|||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) doctorConnectivityCheck(profileFlag string) frameworkcli.DoctorCheck {
|
||||
return func(parent context.Context) frameworkcli.DoctorResult {
|
||||
ctx, cancel := context.WithTimeout(parent, 35*time.Second)
|
||||
|
|
@ -212,25 +129,3 @@ func (a *App) resolveDoctorProfileName(profileFlag string) string {
|
|||
return a.resolveProfileName(profileFlag, cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
func validateManifestUpdate(file frameworkmanifest.File, runtimeBinaryName string) []string {
|
||||
source := file.Update.ReleaseSource()
|
||||
issues := make([]string, 0, 2)
|
||||
|
||||
if _, err := frameworkupdate.ResolveLatestReleaseURL("", source); err != nil {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
|
||||
binary := strings.TrimSpace(runtimeBinaryName)
|
||||
if binary == "" {
|
||||
binary = strings.TrimSpace(file.BinaryName)
|
||||
}
|
||||
if binary == "" {
|
||||
binary = binaryName
|
||||
}
|
||||
|
||||
if _, err := frameworkupdate.AssetNameWithTemplate(binary, runtime.GOOS, runtime.GOARCH, source.AssetNameTemplate); err != nil {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
frameworkcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
||||
"email-mcp/mcpgen"
|
||||
frameworkcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
|
||||
"email-mcp/internal/secretstore"
|
||||
)
|
||||
|
|
@ -76,32 +77,20 @@ func (p *InteractiveConfigPrompter) promptCredentialWithSetupEngine(existing sec
|
|||
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: []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,
|
||||
},
|
||||
},
|
||||
Fields: fields,
|
||||
})
|
||||
if err != nil {
|
||||
return secretstore.Credential{}, err
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
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/mcpgen"
|
||||
frameworkconfig "forge.lclr.dev/AI/mcp-framework/config"
|
||||
frameworksecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
|
|
@ -48,6 +48,7 @@ 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)
|
||||
|
|
@ -55,28 +56,29 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
|
|||
}
|
||||
if f.newConfigStore == nil {
|
||||
f.newConfigStore = func() profileConfigStore {
|
||||
return frameworkconfig.NewStore[ProfileConfig]("email-mcp")
|
||||
return frameworkconfig.NewStore[ProfileConfig](mcpgen.BinaryName)
|
||||
}
|
||||
}
|
||||
if f.loadManifest == nil {
|
||||
f.loadManifest = frameworkmanifest.LoadDefault
|
||||
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: "email-mcp",
|
||||
ServiceName: mcpgen.BinaryName,
|
||||
ManifestLoader: frameworksecretstore.ManifestLoader(f.loadManifest),
|
||||
ExecutableResolver: frameworksecretstore.ExecutableResolver(f.resolveExecutable),
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if strings.HasPrefix(trimmedName, "imap-password/") {
|
||||
return os.LookupEnv(passwordEnv)
|
||||
}
|
||||
return os.LookupEnv(trimmedName)
|
||||
},
|
||||
LookupEnv: profilePasswordLookupEnv,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +96,14 @@ func (f runtimeFactories) withDefaults() runtimeFactories {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestBuildAppReturnsConfiguredApp(t *testing.T) {
|
||||
|
|
|
|||
43
mcp.toml
43
mcp.toml
|
|
@ -1,21 +1,29 @@
|
|||
binary_name = "email-mcp"
|
||||
docs_url = "https://gitea.lclr.dev/AI/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://gitea.lclr.dev"
|
||||
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"]
|
||||
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 = "auto"
|
||||
backend_policy = "bitwarden-cli"
|
||||
|
||||
[profiles]
|
||||
default = "default"
|
||||
|
|
@ -23,3 +31,30 @@ 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"]
|
||||
|
|
|
|||
63
mcpgen/config.go
Normal file
63
mcpgen/config.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// 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))
|
||||
}
|
||||
71
mcpgen/generated_test.go
Normal file
71
mcpgen/generated_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
11
mcpgen/manifest.go
Normal file
11
mcpgen/manifest.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// 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)
|
||||
}
|
||||
27
mcpgen/metadata.go
Normal file
27
mcpgen/metadata.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// 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
|
||||
}
|
||||
91
mcpgen/secretstore.go
Normal file
91
mcpgen/secretstore.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// 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
|
||||
}
|
||||
59
mcpgen/update.go
Normal file
59
mcpgen/update.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// 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)
|
||||
}
|
||||
Loading…
Reference in a new issue