Compare commits

..

No commits in common. "main" and "v1.8.1" have entirely different histories.
main ... v1.8.1

38 changed files with 146 additions and 1254 deletions

View file

@ -6,13 +6,12 @@ name: Release
- "**"
permissions:
contents: write
contents: read
releases: write
jobs:
release:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout repository
@ -20,55 +19,38 @@ jobs:
with:
fetch-depth: 0
- name: Extract changelog and update CHANGELOG.md
- name: Build changelog
id: changelog
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
shell: bash
run: |
set -euo pipefail
current_tag="${GITHUB_REF_NAME}"
today=$(date +%Y-%m-%d)
previous_stable_tag=""
# Extract content of [Unreleased] section (non-empty lines)
release_notes=$(awk '/^## \[Unreleased\]/{found=1; next} found && /^## \[/{exit} found{print}' CHANGELOG.md | sed '/^[[:space:]]*$/d')
if [ -z "${release_notes}" ]; then
release_notes="Voir les commits pour le détail des changements."
if previous_stable_tag="$(
git describe --tags --abbrev=0 \
--exclude '*-rc*' \
--exclude '*-beta*' \
--exclude '*-alpha*' \
"${current_tag}^" 2>/dev/null
)"; then
range="${previous_stable_tag}..${current_tag}"
{
printf '## Changelog\n\n'
printf 'Changes since `%s`.\n\n' "${previous_stable_tag}"
git log --reverse --pretty=format:'- %h %s' "${range}"
printf '\n'
} >CHANGELOG.md
else
{
printf '## Changelog\n\n'
printf 'Initial release.\n\n'
git log --reverse --pretty=format:'- %h %s' "${current_tag}"
printf '\n'
} >CHANGELOG.md
fi
printf '%s\n' "${release_notes}" > release_notes.md
# For stable releases: rename [Unreleased] and insert a new empty section
case "${current_tag}" in
*-rc*|*-beta*|*-alpha*)
echo "Pre-release tag — CHANGELOG.md non modifié"
;;
*)
# Rename [Unreleased] → version header
sed -i "s/^## \[Unreleased\]$/## [${current_tag}] — ${today}/" CHANGELOG.md
# Insert new empty [Unreleased] section after "# Changelog"
sed -i "s/^# Changelog$/# Changelog\n\n## [Unreleased]/" CHANGELOG.md
# Insert reference link before the first existing [vX...] link
awk -v tag="${current_tag}" -v url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${current_tag}" '
!inserted && /^\[v/ { print "[" tag "]: " url; inserted=1 }
{ print }
' CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md
git config user.name "CI"
git config user.email "ci@forge.lclr.dev"
scheme="${GITHUB_SERVER_URL%%://*}"
host="${GITHUB_SERVER_URL#*://}"
git remote set-url origin "${scheme}://x-token:${GITEA_TOKEN}@${host}/${GITHUB_REPOSITORY}.git"
git add CHANGELOG.md
git commit -m "chore(changelog): release ${current_tag}"
git push origin HEAD:main
;;
esac
- name: Create or update release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@ -87,32 +69,11 @@ jobs:
;;
esac
# Build commit log since previous stable tag
previous_stable_tag=""
if previous_stable_tag="$(
git describe --tags --abbrev=0 \
--exclude '*-rc*' \
--exclude '*-beta*' \
--exclude '*-alpha*' \
"${current_tag}^" 2>/dev/null
)"; then
range="${previous_stable_tag}..${current_tag}"
else
range="${current_tag}"
fi
{
cat release_notes.md
printf '\n\n## Commits\n\n'
git log --reverse --pretty=format:'- %h %s' "${range}"
printf '\n'
} > release_body.md
json_escape() {
sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g'
}
body="$(json_escape < release_body.md)"
body="$(json_escape < CHANGELOG.md)"
payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
"${current_tag}" \
"${current_tag}" \

View file

@ -60,11 +60,3 @@ Avant d'ouvrir ou de mettre à jour une PR :
## Commits
Conserver des messages de commit au format conventional commits, conformément aux règles globales.
## Changelog
Après chaque développement fonctionnel, mettre à jour la section `## [Unreleased]` du `CHANGELOG.md` avec une description claire des changements apportés.
Ne pas logger les changements purement liés à la CI ou au changelog lui-même.
La CI se charge de versioner automatiquement la section `[Unreleased]` lors du push d'un tag stable.

View file

@ -1,240 +0,0 @@
# Changelog
## [Unreleased]
## [v1.13.0] — 2026-05-13
### Corrections
- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage.
## [v1.12.0] — 2026-05-13
### Nouvelles fonctionnalités
- **Bootstrap — `DefaultLoginHandler`** : handler de login Bitwarden prêt à l'emploi avec confirmation, évitant de réimplémenter le même code dans chaque MCP.
- **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`.
- **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile.
### Changements cassants
- **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`.
## [v1.11.0] — 2026-05-12
### Nouvelles fonctionnalités
- **Bootstrap — masquage automatique des commandes non configurées** : les commandes dont aucun hook n'est défini dans le manifeste sont désormais automatiquement masquées de l'aide CLI, ce qui rend la sortie `--help` plus propre et adaptée à chaque projet.
- **Bootstrap — option `DisabledCommands`** : il est maintenant possible de désactiver explicitement des commandes via l'option `DisabledCommands`, indépendamment de la configuration des hooks.
---
## [v1.10.0] — 2026-05-11
### Nouvelles fonctionnalités
- **Bootstrap — `DefaultLoginHandler`** : un handler de login générique est désormais disponible dans le package bootstrap, utilisable par défaut pour les projets qui n'ont pas besoin d'un flux de connexion personnalisé.
### Corrections
- Renommage du dossier `.forgejo` corrigé suite à une erreur de casse.
---
## [v1.9.0] — 2026-05-05
### Changements internes
- **Migration vers Forgejo** : les workflows CI ont été migrés de GitHub Actions vers Forgejo.
- **Mise à jour du module path** : le chemin du module Go a été mis à jour pour pointer vers la forge interne.
---
## [v1.8.2] — 2026-05-02
### Performances
- Suppression d'un appel de sonde Bitwarden inutile lors de la génération de la description runtime, ce qui réduit les appels CLI superflus au démarrage.
---
## [v1.8.1] — 2026-05-02
### Performances
- La vérification de disponibilité de Bitwarden est désormais chargée de manière paresseuse (lazy), évitant une initialisation coûteuse si le secret store n'est pas utilisé.
---
## [v1.8.0] — 2026-05-02
### Nouvelles fonctionnalités
- **Cache Bitwarden chiffré** : les lectures de secrets Bitwarden sont maintenant mises en cache localement sous forme chiffrée, ce qui réduit considérablement le nombre d'appels au CLI `bw` durant une session.
- **Configuration du cache via `mcp.toml`** : les options de cache (durée, activation) sont configurables directement dans le manifeste du projet.
- **Helpers générés** : les helpers de code générés exposent les contrôles de cache Bitwarden, permettant aux projets scaffoldés de bénéficier automatiquement du cache.
---
## [v1.7.0] — 2026-05-02
### Nouvelles fonctionnalités
- **Génération de code depuis le manifeste** : le framework peut désormais générer automatiquement du code Go à partir du `mcp.toml`, incluant les helpers de champs de configuration et le code de glue pour les helpers de manifeste. Cela réduit le boilerplate dans les projets utilisant le framework.
---
## [v1.6.0] — 2026-04-20
### Corrections
- L'invite de connexion Bitwarden s'affiche désormais en rouge lorsque la session est absente, rendant l'état d'erreur plus visible pour l'utilisateur.
---
## [v1.5.1] — 2026-04-16
### Améliorations
- Le script d'installation généré par le scaffold récupère désormais les binaires depuis la dernière release disponible, plutôt qu'une version fixée en dur.
---
## [v1.5.0] — 2026-04-16
### Nouvelles fonctionnalités
- **Fallback runtime embarqué pour les apps scaffoldées** : si le binaire runtime n'est pas trouvé dans l'environnement, les applications générées par le scaffold peuvent désormais utiliser un runtime de fallback embarqué directement dans le manifeste.
---
## [v1.4.2] — 2026-04-15
### Nouvelles fonctionnalités
- **Vérification Ed25519 des artefacts de release** : les artefacts téléchargés lors des mises à jour automatiques sont désormais vérifiés par signature Ed25519, garantissant leur intégrité.
### Corrections
- Le mécanisme de mise à jour (`self-update`) rejette maintenant les artefacts HTML (erreurs de redirection ou pages d'erreur) pour éviter d'installer un binaire corrompu.
- Durcissement du runtime scaffold et de la sécurité du processus de mise à jour.
### Documentation
- Le README a été réorganisé et la documentation détaillée déplacée dans des fichiers séparés.
---
## [v1.4.1] — 2026-04-15
### Améliorations
- L'assistant d'installation généré par le scaffold est aligné avec le dernier flux TUI, garantissant la cohérence entre le code généré et le comportement attendu.
---
## [v1.4.0] — 2026-04-15
### Nouvelles fonctionnalités
- **Assistant d'installation TUI pour Claude et Codex** : le scaffold injecte désormais un wizard interactif (TUI) dans le script `install.sh` des projets générés, guidant l'utilisateur lors de la première installation avec des étapes de configuration pour Claude et Codex.
---
## [v1.3.2] — 2026-04-15
### Corrections
- Revert des fonctionnalités `build` unifiée et matrice CI introduites en cours de cycle, jugées non stables pour cette release.
---
## [v1.3.1] — 2026-04-14
### Nouvelles fonctionnalités
- **CLI — vérification doctor sur les champs de profil** : un helper réutilisable permet de valider les champs de configuration résolus depuis plusieurs sources (env, fichier, défaut) lors du diagnostic `doctor`.
- **CLI — lookup multi-sources** : ajout d'un helper de résolution de valeur avec traçabilité de la source (d'où vient la valeur résolue).
- **Secretstore — helper de manifeste runtime** : ajout d'un helper facilitant l'ouverture du backend secret store depuis le manifeste runtime.
- **Bootstrap — expansion d'alias de commandes** : les commandes bootstrap peuvent maintenant définir des alias qui sont développés automatiquement.
---
## [v1.3.0] — 2026-04-14
### Nouvelles fonctionnalités
- **Commande `scaffold init`** : nouvelle commande CLI pour initialiser un projet MCP depuis zéro via le scaffold.
- **Générateur de scaffold MCP** : ajout d'un générateur de projet binaire MCP complet, produisant la structure de fichiers, le manifeste `mcp.toml`, et le code de démarrage.
---
## [v1.2.1] — 2026-04-14
### Améliorations
- Les commandes `config show` et `config test` générées par le bootstrap suivent désormais une structure standardisée cohérente entre les projets.
### Corrections
- La CI construit le changelog depuis le dernier tag de release stable (et non depuis un tag RC).
---
## [v1.2.0] — 2026-04-14
### Documentation
- Mise en place de la convention de nommage des branches d'amélioration dans les instructions agents du dépôt.
---
## [v1.1.0] — 2026-04-13
### Nouvelles fonctionnalités
- **Migrations de configuration versionnées** : le framework gère désormais les migrations de configuration entre versions, permettant aux projets d'évoluer leur schéma de config sans casser les installations existantes.
- **Secrets structurés et politiques de backend** : support des secrets structurés (objets, non plus uniquement des chaînes) et des politiques de sélection de backend secret store par champ.
### Documentation
- Ajout des instructions de workflow du dépôt.
---
## [v1.0.0] — 2026-04-13
Première release stable du framework.
### Fonctionnalités initiales
- **Framework MCP réutilisable** : socle commun pour construire des serveurs MCP en Go, avec gestion du cycle de vie, configuration, et intégration des outils.
- **Loader de manifeste TOML** : chargement de la configuration projet depuis un fichier `mcp.toml`.
- **Package de mise à jour** : mécanisme de self-update découplé, pilotable par des drivers de forge (GitLab, Forgejo, etc.) avec validation de checksum.
- **Bootstrap CLI optionnel** : package permettant de bootstrapper rapidement une CLI pour un projet MCP, avec commandes `config`, `login`, `doctor`, et `update` préconfigurées.
- **Workflow de release CI** : pipeline de release automatisée avec génération de changelog et publication des artefacts.
---
[v1.13.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.13.0
[v1.12.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.12.0
[v1.11.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.11.0
[v1.10.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.10.0
[v1.9.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.9.0
[v1.8.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.2
[v1.8.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.1
[v1.8.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.0
[v1.7.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.7.0
[v1.6.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.6.0
[v1.5.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.1
[v1.5.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.0
[v1.4.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.2
[v1.4.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.1
[v1.4.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.0
[v1.3.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.2
[v1.3.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.1
[v1.3.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.0
[v1.2.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.1
[v1.2.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.0
[v1.1.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.1.0
[v1.0.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.0.0

View file

@ -1 +0,0 @@
AGENTS.md

View file

@ -9,13 +9,13 @@ manifeste `mcp.toml`, diagnostic et auto-update.
Dans un projet Go :
```bash
go get forge.lclr.dev/AI/mcp-framework
go get gitea.lclr.dev/AI/mcp-framework
```
Pour utiliser le CLI :
```bash
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
```
## Créer un projet MCP

View file

@ -56,7 +56,6 @@ type Options struct {
Aliases map[string][]string
AliasDescriptions map[string]string
EnableDoctorAlias bool
DisabledCommands []string
Args []string
Stdin io.Reader
Stdout io.Writer
@ -145,10 +144,6 @@ func Run(ctx context.Context, opts Options) error {
return printHelp(normalized, "")
}
if isCommandDisabled(command, normalized.DisabledCommands) {
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
}
if command == CommandConfig {
return runConfigCommand(ctx, normalized, commandArgs)
}
@ -159,16 +154,14 @@ func Run(ctx context.Context, opts Options) error {
}
if handler == nil {
switch command {
case CommandVersion:
if command == CommandVersion {
if strings.TrimSpace(normalized.Version) == "" {
return ErrVersionRequired
}
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
return err
default:
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
}
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
}
return handler(ctx, Invocation{
@ -195,11 +188,6 @@ func normalize(opts Options) Options {
}
opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias)
opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias)
for _, cmd := range autoDisabledCommands(opts) {
if !isCommandDisabled(cmd, opts.DisabledCommands) {
opts.DisabledCommands = append(opts.DisabledCommands, cmd)
}
}
return opts
}
@ -456,10 +444,6 @@ func printHelp(opts Options, command string, args ...[]string) error {
return printGlobalHelp(opts)
}
if isCommandDisabled(command, opts.DisabledCommands) {
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
}
if command == CommandConfig {
return printConfigHelp(opts, commandArgs)
}
@ -539,9 +523,6 @@ func printGlobalHelp(opts Options) error {
}
for _, def := range commands {
if isCommandDisabled(def.Name, opts.DisabledCommands) {
continue
}
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil {
return err
}
@ -578,39 +559,6 @@ func printGlobalHelp(opts Options) error {
return err
}
func autoDisabledCommands(opts Options) []string {
h := opts.Hooks
var disabled []string
if h.Setup == nil {
disabled = append(disabled, CommandSetup)
}
if h.Login == nil {
disabled = append(disabled, CommandLogin)
}
if h.MCP == nil {
disabled = append(disabled, CommandMCP)
}
if h.Config == nil && h.ConfigShow == nil && h.ConfigTest == nil && h.ConfigDelete == nil {
disabled = append(disabled, CommandConfig)
}
if h.Update == nil {
disabled = append(disabled, CommandUpdate)
}
if h.Version == nil && strings.TrimSpace(opts.Version) == "" {
disabled = append(disabled, CommandVersion)
}
return disabled
}
func isCommandDisabled(command string, disabled []string) bool {
for _, d := range disabled {
if d == command {
return true
}
}
return false
}
func aliasDescription(descriptions map[string]string, name, target string) string {
description := strings.TrimSpace(descriptions[name])
if description == "" {

View file

@ -91,13 +91,11 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
})
if !errors.Is(err, ErrSubcommandRequired) {
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err)
@ -150,7 +148,7 @@ func TestRunVersionHookOverridesDefault(t *testing.T) {
}
}
func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) {
func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@ -160,8 +158,8 @@ func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) {
Stdout: &stdout,
Stderr: &stderr,
})
if !errors.Is(err, ErrUnknownCommand) {
t.Fatalf("Run error = %v, want ErrUnknownCommand", err)
if !errors.Is(err, ErrVersionRequired) {
t.Fatalf("Run error = %v, want ErrVersionRequired", err)
}
}
@ -169,15 +167,12 @@ func TestRunPrintsGlobalHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Description: "Binaire MCP de test.",
Version: "v1.2.3",
Args: []string{"help"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -202,15 +197,12 @@ func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Description: "Binaire MCP de test.",
Version: "v1.2.3",
Args: []string{},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -235,13 +227,11 @@ func TestRunPrintsCommandHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "update"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{Update: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -260,13 +250,11 @@ func TestRunPrintsLoginCommandHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "login"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{Login: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -285,13 +273,11 @@ func TestRunPrintsConfigHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "config"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -313,13 +299,11 @@ func TestRunPrintsConfigSubcommandHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "config", "show"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -365,13 +349,11 @@ func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config", "show"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
})
if !errors.Is(err, ErrCommandNotConfigured) {
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
@ -382,13 +364,11 @@ func TestRunConfigReturnsUnknownSubcommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config", "sync"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
})
if !errors.Is(err, ErrUnknownSubcommand) {
t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err)
@ -432,7 +412,6 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Aliases: map[string][]string{
@ -441,7 +420,6 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
Args: []string{"help", "doctor"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -457,7 +435,6 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Aliases: map[string][]string{
@ -466,7 +443,6 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
Args: []string{"doctor", "--help"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -631,58 +607,3 @@ func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) {
t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1")
}
}
func TestDisabledCommandReturnsUnknown(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"login"},
DisabledCommands: []string{"login"},
Stdout: &stdout,
Stderr: &stderr,
})
if !errors.Is(err, ErrUnknownCommand) {
t.Fatalf("err = %v, want ErrUnknownCommand", err)
}
}
func TestDisabledCommandHiddenFromHelp(t *testing.T) {
var stdout bytes.Buffer
err := printGlobalHelp(Options{
BinaryName: "my-mcp",
DisabledCommands: []string{"login", "setup"},
Stdout: &stdout,
})
if err != nil {
t.Fatalf("printGlobalHelp error = %v", err)
}
out := stdout.String()
if strings.Contains(out, "login") {
t.Fatalf("help should not contain disabled command %q, got:\n%s", "login", out)
}
if strings.Contains(out, "setup") {
t.Fatalf("help should not contain disabled command %q, got:\n%s", "setup", out)
}
if !strings.Contains(out, "mcp") {
t.Fatalf("help should contain enabled command %q, got:\n%s", "mcp", out)
}
}
func TestDisabledCommandHelpReturnsUnknown(t *testing.T) {
var stdout bytes.Buffer
err := printHelp(Options{
BinaryName: "my-mcp",
DisabledCommands: []string{"login"},
Stdout: &stdout,
}, "login")
if !errors.Is(err, ErrUnknownCommand) {
t.Fatalf("err = %v, want ErrUnknownCommand", err)
}
}

View file

@ -1,56 +0,0 @@
package bootstrap
import (
"context"
"fmt"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
// StandardConfigTestOptions configure le handler de config test standard.
// Aucun champ n'est obligatoire — omettez ceux qui ne s'appliquent pas à l'application.
type StandardConfigTestOptions struct {
// ConfigCheck vérifie que le fichier de configuration est lisible.
// Construire avec cli.NewConfigCheck(store).
ConfigCheck fwcli.DoctorCheck
// OpenStore ouvre le secret store pour vérifier sa disponibilité.
// Si fourni, un SecretStoreAvailabilityCheck est automatiquement inclus.
OpenStore func() (secretstore.Store, error)
// ConnectivityCheck vérifie la connectivité applicative (IMAP, HTTP, etc.).
ConnectivityCheck fwcli.DoctorCheck
// ExtraChecks contient des vérifications supplémentaires spécifiques à l'application.
ExtraChecks []fwcli.DoctorCheck
}
// StandardConfigTestHandler retourne un Handler pour la commande config test.
// Il inclut : config (si fourni), secret store (si fourni), connectivité (si fournie),
// checks supplémentaires. Le ManifestCheck est intentionnellement absent : le manifest
// est un artefact de build, pas une contrainte runtime.
func StandardConfigTestHandler(opts StandardConfigTestOptions) Handler {
return func(ctx context.Context, inv Invocation) error {
doctorOpts := fwcli.DoctorOptions{
ConfigCheck: opts.ConfigCheck,
ConnectivityCheck: opts.ConnectivityCheck,
ExtraChecks: opts.ExtraChecks,
}
if opts.OpenStore != nil {
doctorOpts.SecretStoreCheck = fwcli.SecretStoreAvailabilityCheck(opts.OpenStore)
}
report := fwcli.RunDoctor(ctx, doctorOpts)
if err := fwcli.RenderDoctorReport(inv.Stdout, report); err != nil {
return err
}
if report.HasFailures() {
return fmt.Errorf("config checks failed")
}
return nil
}
}

View file

@ -1,155 +0,0 @@
package bootstrap
import (
"bytes"
"context"
"errors"
"strings"
"testing"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
func TestStandardConfigTestHandlerRendersChecks(t *testing.T) {
var stdout bytes.Buffer
handler := StandardConfigTestHandler(StandardConfigTestOptions{
ConfigCheck: func(context.Context) fwcli.DoctorResult {
return fwcli.DoctorResult{Name: "config", Status: fwcli.DoctorStatusOK, Summary: "config ok"}
},
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusOK, Summary: "reachable"}
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "[OK] config") {
t.Fatalf("stdout = %q, want [OK] config", out)
}
if !strings.Contains(out, "[OK] connectivity") {
t.Fatalf("stdout = %q, want [OK] connectivity", out)
}
if strings.Contains(out, "manifest") {
t.Fatalf("stdout should not contain manifest check, got:\n%s", out)
}
}
func TestStandardConfigTestHandlerIncludesSecretStoreCheck(t *testing.T) {
var stdout bytes.Buffer
handler := StandardConfigTestHandler(StandardConfigTestOptions{
OpenStore: func() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
BackendPolicy: secretstore.BackendEnvOnly,
LookupEnv: func(string) (string, bool) { return "", false },
})
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
if !strings.Contains(stdout.String(), "secret-store") {
t.Fatalf("stdout = %q, want secret-store check", stdout.String())
}
}
func TestStandardConfigTestHandlerReturnsErrorOnFailure(t *testing.T) {
var stdout bytes.Buffer
handler := StandardConfigTestHandler(StandardConfigTestOptions{
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusFail, Summary: "unreachable"}
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err == nil {
t.Fatal("handler should return error when checks fail")
}
if !strings.Contains(err.Error(), "config checks failed") {
t.Fatalf("err = %q, want 'config checks failed'", err.Error())
}
if !strings.Contains(stdout.String(), "[FAIL] connectivity") {
t.Fatalf("stdout = %q, want [FAIL] connectivity", stdout.String())
}
}
func TestStandardConfigTestHandlerOmitsManifestCheck(t *testing.T) {
var stdout bytes.Buffer
handler := StandardConfigTestHandler(StandardConfigTestOptions{
ExtraChecks: []fwcli.DoctorCheck{
func(context.Context) fwcli.DoctorResult {
return fwcli.DoctorResult{Name: "custom", Status: fwcli.DoctorStatusOK, Summary: "ok"}
},
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
if strings.Contains(stdout.String(), "manifest") {
t.Fatalf("manifest check should not appear, got:\n%s", stdout.String())
}
}
func TestStandardConfigTestHandlerRunsViaBootstrap(t *testing.T) {
var stdout bytes.Buffer
openCalled := false
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config", "test"},
Stdout: &stdout,
Hooks: Hooks{
ConfigTest: StandardConfigTestHandler(StandardConfigTestOptions{
OpenStore: func() (secretstore.Store, error) {
openCalled = true
return secretstore.Open(secretstore.Options{
BackendPolicy: secretstore.BackendEnvOnly,
LookupEnv: func(string) (string, bool) { return "", false },
})
},
}),
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if !openCalled {
t.Fatal("OpenStore should have been called")
}
if !strings.Contains(stdout.String(), "secret-store") {
t.Fatalf("stdout = %q, want secret-store in output", stdout.String())
}
}
func TestStandardConfigTestHandlerSecretStoreFailurePropagates(t *testing.T) {
var stdout bytes.Buffer
storeErr := errors.New("bitwarden unavailable")
handler := StandardConfigTestHandler(StandardConfigTestOptions{
OpenStore: func() (secretstore.Store, error) {
return nil, storeErr
},
})
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
if err == nil {
t.Fatal("handler should return error when store fails")
}
if !strings.Contains(stdout.String(), "[FAIL] secret-store") {
t.Fatalf("stdout = %q, want [FAIL] secret-store", stdout.String())
}
}

View file

@ -1,34 +0,0 @@
package bootstrap
import (
"context"
"fmt"
"strings"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
var loginBitwarden = secretstore.LoginBitwarden
// BitwardenLoginHandler retourne un Handler pour la commande login des MCPs
// qui utilisent le backend Bitwarden. Il authentifie et déverrouille le vault,
// persiste BW_SESSION, et confirme le résultat.
//
// N'utiliser que si le MCP déclare secret_store.backend_policy = "bitwarden-cli"
// dans son manifest. Pour les backends env-only ou keyring, ne pas définir de
// hook Login : la commande sera automatiquement masquée.
func BitwardenLoginHandler(binaryName string) Handler {
name := strings.TrimSpace(binaryName)
return func(_ context.Context, inv Invocation) error {
if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{
ServiceName: name,
Stdin: inv.Stdin,
Stdout: inv.Stdout,
Stderr: inv.Stderr,
}); err != nil {
return err
}
_, err := fmt.Fprintf(inv.Stdout, "Session Bitwarden persistée pour %q.\n", name)
return err
}
}

View file

@ -1,103 +0,0 @@
package bootstrap
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) (string, error)) {
t.Helper()
previous := loginBitwarden
loginBitwarden = fn
t.Cleanup(func() { loginBitwarden = previous })
}
func TestBitwardenLoginHandlerPrintsConfirmation(t *testing.T) {
var stdout bytes.Buffer
withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) {
if opts.ServiceName != "my-mcp" {
t.Fatalf("ServiceName = %q, want %q", opts.ServiceName, "my-mcp")
}
return "session-token", nil
})
handler := BitwardenLoginHandler("my-mcp")
err := handler(context.Background(), Invocation{
Command: CommandLogin,
Stdout: &stdout,
})
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"my-mcp"`) {
t.Fatalf("stdout = %q, want mention of binary name", out)
}
if !strings.Contains(out, "persistée") {
t.Fatalf("stdout = %q, want confirmation message", out)
}
}
func TestBitwardenLoginHandlerPropagatesError(t *testing.T) {
var stdout bytes.Buffer
loginErr := errors.New("vault locked")
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
return "", loginErr
})
handler := BitwardenLoginHandler("my-mcp")
err := handler(context.Background(), Invocation{
Command: CommandLogin,
Stdout: &stdout,
})
if !errors.Is(err, loginErr) {
t.Fatalf("err = %v, want %v", err, loginErr)
}
if stdout.Len() > 0 {
t.Fatalf("stdout should be empty on error, got %q", stdout.String())
}
}
func TestRunUsesBitwardenLoginHandlerWhenHookSet(t *testing.T) {
var stdout bytes.Buffer
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
return "tok", nil
})
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"login"},
Stdout: &stdout,
Hooks: Hooks{
Login: BitwardenLoginHandler("my-mcp"),
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if !strings.Contains(stdout.String(), "persistée") {
t.Fatalf("stdout = %q, want confirmation", stdout.String())
}
}
func TestRunLoginAutoHiddenWithoutHook(t *testing.T) {
var stdout bytes.Buffer
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"login"},
Stdout: &stdout,
})
if !errors.Is(err, ErrUnknownCommand) {
t.Fatalf("err = %v, want ErrUnknownCommand", err)
}
}

View file

@ -8,9 +8,9 @@ import (
"os"
"strings"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/secretstore"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
)
type DoctorStatus string
@ -86,7 +86,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
}
if options.ManifestCheck != nil {
checks = append(checks, options.ManifestCheck)
} else if strings.TrimSpace(options.ManifestDir) != "" {
} else {
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
}
if options.ConnectivityCheck != nil {

View file

@ -10,9 +10,9 @@ import (
"strings"
"testing"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/secretstore"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
)
type doctorProfile struct {
@ -198,24 +198,6 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) {
}
}
func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) {
report := RunDoctor(context.Background(), DoctorOptions{
ConnectivityCheck: func(context.Context) DoctorResult {
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"}
},
// ManifestDir intentionally empty, ManifestCheck intentionally nil.
})
for _, r := range report.Results {
if r.Name == "manifest" {
t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r)
}
}
if len(report.Results) != 1 {
t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results))
}
}
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
prev := checkBitwardenReady
t.Cleanup(func() {

View file

@ -4,7 +4,7 @@ import (
"errors"
"os"
"forge.lclr.dev/AI/mcp-framework/secretstore"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
)
type KeyLookupFunc func(key string) (string, bool, error)

View file

@ -4,7 +4,7 @@ import (
"errors"
"testing"
"forge.lclr.dev/AI/mcp-framework/secretstore"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
)
type testSecretStore struct {

View file

@ -5,7 +5,7 @@ import (
"fmt"
"strings"
"forge.lclr.dev/AI/mcp-framework/secretstore"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
)
type SetupSecretWriteOptions struct {

View file

@ -5,7 +5,7 @@ import (
"strings"
"testing"
"forge.lclr.dev/AI/mcp-framework/secretstore"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
)
func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) {

View file

@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
generatepkg "forge.lclr.dev/AI/mcp-framework/generate"
scaffoldpkg "forge.lclr.dev/AI/mcp-framework/scaffold"
generatepkg "gitea.lclr.dev/AI/mcp-framework/generate"
scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold"
)
const toolName = "mcp-framework"

View file

@ -1,27 +1,8 @@
# Bootstrap CLI
Le package `bootstrap` fournit un point d'entrée CLI uniforme pour les binaires
MCP. Il gère le parsing des arguments, l'aide, les alias et le routage vers les
hooks fournis par l'application.
Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique.
## Commandes disponibles
| Commande | Description |
|---|---|
| `setup` | Initialiser ou mettre à jour la configuration locale |
| `login` | Authentifier et déverrouiller Bitwarden pour persister `BW_SESSION` |
| `mcp` | Démarrer le serveur MCP |
| `config show` | Afficher la configuration résolue et la provenance des valeurs |
| `config test` | Vérifier la configuration et la connectivité |
| `config delete` | Supprimer un profil local |
| `update` | Auto-update du binaire |
| `version` | Afficher la version |
Les commandes sans hook correspondant sont automatiquement masquées de l'aide et
retournent une erreur `ErrUnknownCommand`. Exception : `version` affiche
`Options.Version` si fourni, sans hook.
## Utilisation
Exemple minimal :
```go
func main() {
@ -29,22 +10,26 @@ func main() {
BinaryName: "my-mcp",
Description: "Client MCP",
Version: version,
EnableDoctorAlias: true,
EnableDoctorAlias: true, // expose `doctor` comme alias de `config test`
AliasDescriptions: map[string]string{
"doctor": "Diagnostiquer la configuration locale.",
},
Hooks: bootstrap.Hooks{
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
return runSetup(ctx, inv.Args)
},
Login: bootstrap.BitwardenLoginHandler("my-mcp"),
Login: func(ctx context.Context, inv bootstrap.Invocation) error {
return runLogin(ctx, inv.Args)
},
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
return runMCP(ctx, inv.Args)
},
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
return runConfigShow(ctx, inv.Args)
},
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
OpenStore: openStore,
ConnectivityCheck: connectivityCheck,
}),
ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error {
return runConfigTest(ctx, inv.Args)
},
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
return runUpdate(ctx, inv.Args)
},
@ -56,97 +41,11 @@ func main() {
}
```
## Handlers fournis
Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`.
### `BitwardenLoginHandler`
```go
bootstrap.BitwardenLoginHandler(binaryName string) bootstrap.Handler
```
Handler prêt à l'emploi pour la commande `login` des MCPs qui utilisent le
backend Bitwarden CLI. Il lance le flux interactif `bw unlock --raw`, persiste
`BW_SESSION` dans un fichier `0600` sous le répertoire de config utilisateur, et
confirme le résultat.
À n'utiliser que si le MCP déclare `secret_store.backend_policy = "bitwarden-cli"`
dans son manifest. Pour les autres backends (`env-only`, `keyring-any`), ne pas
définir de hook `Login` : la commande est automatiquement masquée.
```go
Hooks: bootstrap.Hooks{
Login: bootstrap.BitwardenLoginHandler(mcpgen.BinaryName),
}
```
### `StandardConfigTestHandler`
```go
bootstrap.StandardConfigTestHandler(opts bootstrap.StandardConfigTestOptions) bootstrap.Handler
```
Handler pour `config test` qui exécute un ensemble de checks standards et affiche
un rapport formaté. Aucun champ n'est obligatoire.
```go
type StandardConfigTestOptions struct {
ConfigCheck cli.DoctorCheck // cli.NewConfigCheck(store)
OpenStore func() (secretstore.Store, error) // check disponibilité secret store
ConnectivityCheck cli.DoctorCheck // check applicatif (HTTP, IMAP…)
ExtraChecks []cli.DoctorCheck
}
```
Checks inclus automatiquement selon les champs fournis :
| Champ | Check résultant |
|---|---|
| `ConfigCheck` | Fichier de configuration lisible |
| `OpenStore` | Secret store disponible |
| `ConnectivityCheck` | Connectivité applicative |
| `ExtraChecks` | Checks supplémentaires |
Le `ManifestCheck` n'est pas inclus : le manifest est un artefact de build, pas
une contrainte runtime.
```go
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
ConfigCheck: cli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig]("my-mcp")),
OpenStore: openSecretStore,
ConnectivityCheck: func(ctx context.Context) cli.DoctorResult {
if err := pingBackend(ctx); err != nil {
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusFail,
Summary: "backend inaccessible",
Detail: err.Error(),
}
}
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusOK,
Summary: "backend accessible",
}
},
}),
```
Pour un config test applicatif spécifique (appels API, messages ✓/✗), implémenter
un hook `ConfigTest` custom.
## Options
| Champ | Description |
|---|---|
| `BinaryName` | Nom du binaire, utilisé dans l'aide et les messages |
| `Description` | Description affichée dans l'aide globale |
| `Version` | Version affichée par `version` sans hook |
| `Args` | Arguments CLI (défaut : `os.Args[1:]`) |
| `Stdin/Stdout/Stderr` | I/O (défaut : `os.Stdin/Stdout/Stderr`) |
| `Aliases` | Alias de commandes |
| `AliasDescriptions` | Descriptions des alias dans l'aide |
| `EnableDoctorAlias` | Active `doctor` comme alias de `config test` |
| `DisabledCommands` | Commandes à masquer explicitement |
Le flag global `--debug` active le debug des appels Bitwarden
Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`).
La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif.
La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`).
Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale.
Le flag global `--debug` est supporté et active le debug des appels Bitwarden
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).

View file

@ -126,11 +126,8 @@ if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil {
`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ.
Le package fournit un socle réutilisable pour une commande `doctor`.
Pour les cas standards (config, secret store, connectivité), préférer
`bootstrap.StandardConfigTestHandler` qui câble `RunDoctor` sans boilerplate.
Pour un contrôle fin ou un config test impératif, utiliser `RunDoctor` directement :
Le package fournit aussi un socle réutilisable pour une commande `doctor`.
L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks :
```go
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
@ -140,19 +137,32 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
ServiceName: "my-mcp",
})
}),
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
BitwardenOptions: cli.BitwardenDoctorOptions{
LookupEnv: os.LookupEnv,
},
RequiredSecrets: []cli.DoctorSecret{
{Name: "api-token", Label: "API token"},
},
SecretStoreFactory: func() (secretstore.Store, error) {
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
})
},
ManifestDir: ".",
ConnectivityCheck: func(context.Context) cli.DoctorResult {
if err := pingBackend(); err != nil {
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusFail,
Summary: "backend inaccessible",
Summary: "backend is unreachable",
Detail: err.Error(),
}
}
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusOK,
Summary: "backend accessible",
Summary: "backend is reachable",
}
},
})
@ -166,10 +176,6 @@ if report.HasFailures() {
}
```
`ManifestDir` est optionnel. Quand il est fourni, `RunDoctor` inclut un
`ManifestCheck` qui vérifie la présence et la validité de `mcp.toml` dans ce
répertoire. Ne l'inclure que si ce check est pertinent pour l'application.
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
Pour le désactiver explicitement :

View file

@ -3,7 +3,7 @@
## Installation
```bash
go get forge.lclr.dev/AI/mcp-framework
go get gitea.lclr.dev/AI/mcp-framework
```
## CLI de scaffold
@ -11,7 +11,7 @@ go get forge.lclr.dev/AI/mcp-framework
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
```bash
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
mcp-framework scaffold init \
--target ./my-mcp \
--module example.com/my-mcp \

View file

@ -18,8 +18,8 @@ import (
"os"
"example.com/my-mcp/mcpgen"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/update"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/update"
)
var version = "dev"

View file

@ -12,7 +12,7 @@ Exemple :
```go
result, err := scaffold.Generate(scaffold.Options{
TargetDir: "./my-mcp",
ModulePath: "forge.lclr.dev/AI/my-mcp",
ModulePath: "gitea.lclr.dev/AI/my-mcp",
BinaryName: "my-mcp",
Description: "Client MCP interne",
DefaultProfile: "prod",

View file

@ -187,8 +187,7 @@ effective := secretstore.EffectiveBackendPolicy(store)
fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any...
```
Pour obtenir en un seul appel une description runtime légère (source manifeste,
policy déclarée/effective, backend affiché) :
Pour obtenir en un seul appel une description runtime (source manifeste, policy déclarée/effective, disponibilité) :
```go
desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{
@ -203,8 +202,7 @@ fmt.Println(secretstore.FormatBackendStatus(desc))
// declared=... effective=... display=... ready=... source=...
```
`DescribeRuntime` ne contacte pas Bitwarden par défaut. Pour vérifier réellement
la disponibilité du backend, utiliser le préflight :
Pour un préflight réutilisable dans `setup`, `config show` et `config test` :
```go
report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{

View file

@ -12,7 +12,7 @@ import (
"strconv"
"strings"
"forge.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date")
@ -211,7 +211,7 @@ func renderManifestLoader(packageName, manifestContent string) (string, error) {
package %s
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
const embeddedManifest = %s
@ -235,7 +235,7 @@ func renderMetadata(packageName string, manifestFile manifest.File) (string, err
package %s
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
const BinaryName = %s
const DefaultDescription = %s
@ -275,7 +275,7 @@ import (
"io"
"strings"
fwupdate "forge.lclr.dev/AI/mcp-framework/update"
fwupdate "gitea.lclr.dev/AI/mcp-framework/update"
)
func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {
@ -338,7 +338,7 @@ import (
"path/filepath"
"strings"
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
)
type SecretStoreOptions struct {
@ -483,7 +483,7 @@ import (
"flag"
"strings"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
)
type ConfigFlags struct {

View file

@ -37,7 +37,7 @@ description = "Demo MCP"
for _, snippet := range []string{
"// Code generated by mcp-framework generate. DO NOT EDIT.",
"package mcpgen",
"import fwmanifest \"forge.lclr.dev/AI/mcp-framework/manifest\"",
"import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"",
"const embeddedManifest = ",
"func LoadManifest(startDir string) (fwmanifest.File, string, error) {",
"return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)",
@ -386,7 +386,7 @@ func writeModule(t *testing.T, projectDir string) {
t.Fatalf("Abs repo root: %v", err)
}
goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tforge.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace forge.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n"
goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n"
if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil {
t.Fatalf("WriteFile go.mod: %v", err)
}
@ -406,10 +406,10 @@ import (
"io"
"testing"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
"example.com/generated-demo/mcpgen"
fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
)
func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {

2
go.mod
View file

@ -1,4 +1,4 @@
module forge.lclr.dev/AI/mcp-framework
module gitea.lclr.dev/AI/mcp-framework
go 1.25.0

View file

@ -9,7 +9,7 @@ import (
"github.com/BurntSushi/toml"
"forge.lclr.dev/AI/mcp-framework/update"
"gitea.lclr.dev/AI/mcp-framework/update"
)
const DefaultFile = "mcp.toml"

View file

@ -1539,12 +1539,12 @@ import (
"strings"
"sync"
"forge.lclr.dev/AI/mcp-framework/bootstrap"
"forge.lclr.dev/AI/mcp-framework/cli"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/secretstore"
"forge.lclr.dev/AI/mcp-framework/update"
"gitea.lclr.dev/AI/mcp-framework/bootstrap"
"gitea.lclr.dev/AI/mcp-framework/cli"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
"gitea.lclr.dev/AI/mcp-framework/update"
)
var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}"

View file

@ -384,7 +384,6 @@ func (s *bitwardenStore) scopedName(name string) string {
}
func (s *bitwardenStore) ensureReady() error {
s.refreshSessionEnv()
return verifyBitwardenCLIReady(Options{
BitwardenCommand: s.command,
BitwardenDebug: s.debug,
@ -393,14 +392,6 @@ func (s *bitwardenStore) ensureReady() error {
})
}
func (s *bitwardenStore) refreshSessionEnv() {
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil || strings.TrimSpace(session) == "" {
return
}
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
}
type bitwardenResolvedItem struct {
item bitwardenListItem
payload map[string]any

View file

@ -9,10 +9,7 @@ import (
"strings"
)
const (
bitwardenSessionFileName = "bw-session"
bitwardenSharedSessionName = "mcp-framework"
)
const bitwardenSessionFileName = "bw-session"
var bitwardenUserConfigDir = os.UserConfigDir
@ -111,14 +108,10 @@ func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
session, err := LoadBitwardenSession(options)
if err != nil {
if !errors.Is(err, ErrNotFound) {
return false, err
}
// Service-specific file not found; try the shared file written by any MCP login.
session, err = LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
if errors.Is(err, ErrNotFound) {
return false, nil
}
return false, err
}
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
@ -163,20 +156,7 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil {
return "", fmt.Errorf("login to bitwarden CLI: %w", err)
}
case "unlocked":
// Vault is already unlocked. Reuse an existing session to avoid calling
// bw unlock again, which would generate a new token and invalidate the
// tokens held by other running MCP processes.
if existing := loadAnyBitwardenSession(); existing != "" {
if err := os.Setenv(bitwardenSessionEnvName, existing); err != nil {
return "", fmt.Errorf("set %s from existing session: %w", bitwardenSessionEnvName, err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, existing); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err)
}
return existing, nil
}
case "locked":
case "locked", "unlocked":
default:
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
}
@ -195,25 +175,13 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil {
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err)
}
return session, nil
}
// loadAnyBitwardenSession looks for an existing valid session in: the process
// environment, then the shared file written by any MCP login.
func loadAnyBitwardenSession() string {
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
return strings.TrimSpace(session)
}
if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}); err == nil {
return session
}
return ""
}
func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) {
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName == "" {

View file

@ -60,12 +60,12 @@ func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) {
t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1])
}
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
if err != nil {
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
t.Fatalf("LoadBitwardenSession returned error: %v", err)
}
if persisted != "persisted-session" {
t.Fatalf("shared persisted session = %q, want persisted-session", persisted)
t.Fatalf("persisted session = %q, want persisted-session", persisted)
}
}
@ -207,112 +207,6 @@ func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) {
}
}
func TestLoginBitwardenPersistsToSharedFileAfterUnlock(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"locked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" {
return []byte("shared-session\n"), nil
}
return nil, fmt.Errorf("unexpected interactive args: %v", args)
})
if _, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "graylog-mcp",
BitwardenCommand: "bw",
}); err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
shared, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
}
if shared != "shared-session" {
t.Fatalf("shared session = %q, want shared-session", shared)
}
}
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithEnvSession(t *testing.T) {
t.Setenv("BW_SESSION", "existing-session")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"unlocked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
return nil, fmt.Errorf("bw unlock must not be called when vault is already unlocked with a session: %v", args)
})
session, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "email-mcp",
BitwardenCommand: "bw",
})
if err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
if session != "existing-session" {
t.Fatalf("session = %q, want existing-session", session)
}
}
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithSharedSession(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
// graylog-mcp logged in earlier and wrote to the shared file
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "graylog-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"unlocked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
return nil, fmt.Errorf("bw unlock must not be called when shared session exists: %v", args)
})
session, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "email-mcp",
BitwardenCommand: "bw",
})
if err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
if session != "graylog-session" {
t.Fatalf("session = %q, want graylog-session (from shared file)", session)
}
// The shared session must also be persisted to the service-specific file
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
if err != nil {
t.Fatalf("LoadBitwardenSession returned error: %v", err)
}
if persisted != "graylog-session" {
t.Fatalf("email-mcp persisted session = %q, want graylog-session", persisted)
}
}
func withBitwardenInteractiveRunner(
t *testing.T,
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),

View file

@ -15,7 +15,7 @@ import (
"testing"
"unicode/utf8"
"forge.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
@ -66,9 +66,6 @@ func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T)
}
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw")
withBitwardenRunner(t, fakeCLI.run)
@ -575,9 +572,6 @@ func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) {
}
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
store := &bitwardenStore{command: "bw", serviceName: "email-mcp"}
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" {
@ -734,84 +728,6 @@ func stripANSIControlSequences(value string) string {
return strings.ReplaceAll(noANSI, "\r", "")
}
func TestBitwardenStorePicksUpSessionRotatedByAnotherProcess(t *testing.T) {
t.Setenv("BW_SESSION", "old-session")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw")
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
ID: "item-1",
Name: "email-mcp/api-token",
Secret: "secret-v1",
MarkerService: "email-mcp",
MarkerSecretName: "api-token",
}
withBitwardenRunner(t, fakeCLI.run)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "new-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if got := os.Getenv("BW_SESSION"); got != "new-session" {
t.Fatalf("BW_SESSION = %q, want new-session after session rotation", got)
}
}
func TestBitwardenStorePicksUpSessionFromFileWhenEnvIsEmpty(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw")
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
ID: "item-1",
Name: "email-mcp/api-token",
Secret: "secret-v1",
MarkerService: "email-mcp",
MarkerSecretName: "api-token",
}
withBitwardenRunner(t, fakeCLI.run)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "file-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if got := os.Getenv("BW_SESSION"); got != "file-session" {
t.Fatalf("BW_SESSION = %q, want file-session loaded from file after login by another process", got)
}
}
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw")
@ -852,9 +768,6 @@ func withBitwardenSession(t *testing.T) {
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
}
func withBitwardenRunner(

View file

@ -7,7 +7,7 @@ import (
"path/filepath"
"strings"
"forge.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
type ManifestLoader func(startDir string) (manifest.File, string, error)

View file

@ -9,7 +9,7 @@ import (
"github.com/99designs/keyring"
"forge.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {

View file

@ -16,7 +16,6 @@ type DescribeRuntimeOptions struct {
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
CheckReady bool
Shell string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
@ -92,7 +91,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
desc.EffectivePolicy = effective
desc.DisplayName = BackendDisplayName(effective)
}
if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI {
if desc.EffectivePolicy == BackendBitwardenCLI {
if err := verifyBitwardenCLIReady(Options{
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
@ -108,7 +107,6 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
}
func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) {
options.CheckReady = true
desc, err := DescribeRuntime(options)
if err != nil {
return PreflightReport{}, err

View file

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"forge.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
@ -46,9 +46,16 @@ func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
}
}
func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) {
func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) {
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
switch {
case len(args) == 1 && args[0] == "--version":
return []byte("2026.1.0\n"), nil
case len(args) == 1 && args[0] == "status":
return []byte(`{"status":"locked"}`), nil
default:
return nil, errors.New("unexpected bitwarden invocation")
}
})
desc, err := DescribeRuntime(DescribeRuntimeOptions{
@ -73,11 +80,14 @@ func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) {
if desc.EffectivePolicy != BackendBitwardenCLI {
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI)
}
if !desc.Ready {
t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready)
if desc.Ready {
t.Fatalf("Ready = %v, want false", desc.Ready)
}
if desc.ReadyError != nil {
t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError)
if !errors.Is(desc.ReadyError, ErrBWLocked) {
t.Fatalf("ReadyError = %v, want ErrBWLocked", desc.ReadyError)
}
if !strings.Contains(desc.ReadyError.Error(), "set -x BW_SESSION (bw unlock --raw)") {
t.Fatalf("ReadyError = %v, want fish remediation", desc.ReadyError)
}
}