Compare commits

..

27 commits
v1.8.0 ... main

Author SHA1 Message Date
CI
0e0e1f6de6 chore(changelog): release v1.13.0 2026-05-13 12:01:26 +00:00
7c999d2aba docs(changelog): déplacer le fix Bitwarden dans Unreleased, restaurer v1.12.0
All checks were successful
CI / test (push) Successful in 12s
Release / release (push) Successful in 7s
2026-05-13 14:00:05 +02:00
846894c1a7 docs(changelog): mettre à jour la description du fix Bitwarden 2026-05-13 13:57:58 +02:00
7c016e8c5e refactor(secretstore): supprimer le fichier de session service-spécifique
Le fichier ~/.config/<service>/bw-session est redondant depuis l'introduction
du fichier partagé mcp-framework. On n'écrit plus que dans le partagé et on lit
uniquement depuis lui dans refreshSessionEnv et loadAnyBitwardenSession.
EnsureBitwardenSessionEnv tente le fichier service-spécifique en premier
(rétrocompat) puis bascule sur le partagé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:57:58 +02:00
90dbed4d37 fix(secretstore): éviter l'invalidation croisée des sessions Bitwarden entre MCPs
Quand deux MCPs appellaient login, le second appelait bw unlock et générait
un nouveau token, invalidant celui du premier. Deux mécanismes corrigent ça :

1. LoginBitwarden ne relance plus bw unlock si le vault est déjà unlocked
   et qu'une session existe (env, fichier service, ou fichier partagé).
2. Le login écrit le token dans ~/.config/mcp-framework/bw-session (partagé)
   en plus du fichier service-spécifique. Les autres MCPs lisent ce fichier
   en priorité via refreshSessionEnv avant chaque opération Bitwarden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:57:58 +02:00
078aa17285 fix(secretstore): relire la session Bitwarden depuis le fichier avant chaque opération
Quand un MCP appelle login/unlock, le token est écrit dans le fichier de session
mais les autres MCPs conservent leur token obsolète dans l'environnement du processus.
Désormais, bitwardenStore.ensureReady() appelle refreshSessionEnv() qui relit le
fichier avant chaque vérification, ce qui permet à tous les MCPs de rester
opérationnels après une rotation de session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:57:58 +02:00
CI
200674778b chore(changelog): release v1.12.0 2026-05-13 09:32:24 +00:00
267b83bd0c ci(release): corriger la construction de l'URL git pour le push
All checks were successful
CI / test (push) Successful in 12s
Release / release (push) Successful in 6s
GITHUB_SERVER_URL vaut http://forgejo:3000 (réseau interne Docker).
Extraire scheme et host séparément pour reconstruire l'URL correctement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:31:42 +02:00
92b63fe83d ci(release): ignorer les pushes de branches avec un guard sur refs/tags/
Le trigger 'on: push: tags: "**"' dans Forgejo déclenche aussi sur les
pushes de branches. Le guard 'if: startsWith(github.ref, refs/tags/)'
assure que le job ne tourne que sur de vrais tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:30:02 +02:00
64671fc8b2 ci(release): ajouter la liste des commits après les notes CHANGELOG dans la release
Some checks failed
CI / test (push) Successful in 12s
Release / release (push) Failing after 13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:24:08 +02:00
9ac814fda4 docs(agents): ajouter instruction de mise à jour du CHANGELOG après chaque dev
All checks were successful
CI / test (push) Successful in 12s
Ajoute CLAUDE.md comme symlink vers AGENTS.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:22:15 +02:00
39b2bfbcf9 docs(changelog): compléter la section Unreleased
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:21:38 +02:00
ea3a37559a ci(release): remplacer le script Python par sed/awk
All checks were successful
CI / test (push) Successful in 13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:18:27 +02:00
4e2bfbee02 ci(release): alimenter les notes de release depuis [Unreleased] dans CHANGELOG.md
À chaque tag stable, la CI extrait la section [Unreleased], l'utilise
comme notes de release Forgejo, renomme la section avec la version et
la date, puis commite le CHANGELOG.md mis à jour sur main.

Les tags RC utilisent le contenu [Unreleased] pour les notes mais ne
modifient pas le fichier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:15:21 +02:00
3a61387215 docs(changelog): supprimer le texte d'introduction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:03:15 +02:00
b9b729e439 docs: ajouter CHANGELOG.md avec l'historique des versions stables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:00:50 +02:00
f8eb0d3449 docs: mettre à jour bootstrap-cli et cli-helpers
All checks were successful
CI / test (push) Successful in 12s
Documenter BitwardenLoginHandler, StandardConfigTestHandler et le
comportement opt-in de ManifestCheck dans RunDoctor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:55:52 +02:00
e6c372bffc refactor(bootstrap): renommer DefaultLoginHandler en BitwardenLoginHandler
Le handler est spécifique au backend Bitwarden CLI. Le nom "Default"
suggérait à tort qu'il s'applique à tous les MCPs.

Les MCPs sans backend Bitwarden ne définissent pas de hook Login :
autoDisabledCommands masque automatiquement la commande.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:50:28 +02:00
d23d79b6c1 feat(bootstrap): ajouter DefaultLoginHandler et StandardConfigTestHandler
- DefaultLoginHandler(binaryName) : handler de login Bitwarden prêt à
  l'emploi avec confirmation. Remplace les réimplémentations identiques
  dans chaque MCP.
- StandardConfigTestHandler(opts) : handler de config test standard sans
  ManifestCheck. Accepte ConfigCheck, OpenStore, ConnectivityCheck et
  ExtraChecks.
- ManifestCheck dans RunDoctor devient opt-in : inclus uniquement si
  ManifestDir est fourni (artefact de build, pas de contrainte runtime).
- Supprime le handler mort CommandLogin dans bootstrap.Run, désormais
  remplacé par l'auto-disable et DefaultLoginHandler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:45:51 +02:00
9a52b5dce1 feat(bootstrap): auto-hide commands with no hook configured
All checks were successful
CI / test (push) Successful in 11s
Release / release (push) Successful in 5s
Commands are now hidden from help and return ErrUnknownCommand when
invoked if their hook is nil (and for version, if Version string is
also empty). No explicit DisabledCommands needed for MCPs that don't
use login/setup/config/etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:37:46 +02:00
4a7248cfa9 feat(bootstrap): add DisabledCommands option to hide unused commands
Commands listed in DisabledCommands are excluded from global help output
and return ErrUnknownCommand when invoked or help is requested for them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:30:11 +02:00
955c96650a fix: rename .forgejo
All checks were successful
Release / release (push) Successful in 29s
CI / test (push) Successful in 27s
2026-05-11 11:53:59 +02:00
cd0740c75f feat: default login handler in bootstrap
When Hooks.Login is nil, Run() now handles the login command directly
using LoginBitwarden with BinaryName as the service name, removing
the need for glue code in each consumer binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:37:35 +02:00
6bf9dd1866 chore: update module path to forge 2026-05-05 12:23:14 +02:00
6e85969cf4 migrate workflows to forgejo 2026-05-05 12:13:01 +02:00
1e11181c02 perf: avoid bitwarden probe in runtime description 2026-05-02 15:47:07 +02:00
893600ffd5 perf: lazy check bitwarden readiness 2026-05-02 15:30:18 +02:00
40 changed files with 1433 additions and 176 deletions

View file

@ -6,12 +6,13 @@ name: Release
- "**"
permissions:
contents: read
contents: write
releases: write
jobs:
release:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout repository
@ -19,38 +20,55 @@ jobs:
with:
fetch-depth: 0
- name: Build changelog
- name: Extract changelog and update CHANGELOG.md
id: changelog
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
shell: bash
run: |
set -euo pipefail
current_tag="${GITHUB_REF_NAME}"
previous_stable_tag=""
today=$(date +%Y-%m-%d)
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
# 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."
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 }}
@ -69,11 +87,32 @@ 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 < CHANGELOG.md)"
body="$(json_escape < release_body.md)"
payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
"${current_tag}" \
"${current_tag}" \

View file

@ -60,3 +60,11 @@ 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.

240
CHANGELOG.md Normal file
View file

@ -0,0 +1,240 @@
# 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

1
CLAUDE.md Symbolic link
View file

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

View file

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

View file

@ -50,17 +50,18 @@ type Hooks struct {
}
type Options struct {
BinaryName string
Description string
Version string
Aliases map[string][]string
AliasDescriptions map[string]string
EnableDoctorAlias bool
Args []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Hooks Hooks
BinaryName string
Description string
Version string
Aliases map[string][]string
AliasDescriptions map[string]string
EnableDoctorAlias bool
DisabledCommands []string
Args []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Hooks Hooks
}
type Invocation struct {
@ -144,6 +145,10 @@ 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)
}
@ -154,14 +159,16 @@ func Run(ctx context.Context, opts Options) error {
}
if handler == nil {
if command == CommandVersion {
switch command {
case 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{
@ -188,6 +195,11 @@ 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
}
@ -444,6 +456,10 @@ 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)
}
@ -523,6 +539,9 @@ 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
}
@ -559,6 +578,39 @@ 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,11 +91,13 @@ 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)
@ -148,7 +150,7 @@ func TestRunVersionHookOverridesDefault(t *testing.T) {
}
}
func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@ -158,8 +160,8 @@ func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
Stdout: &stdout,
Stderr: &stderr,
})
if !errors.Is(err, ErrVersionRequired) {
t.Fatalf("Run error = %v, want ErrVersionRequired", err)
if !errors.Is(err, ErrUnknownCommand) {
t.Fatalf("Run error = %v, want ErrUnknownCommand", err)
}
}
@ -167,12 +169,15 @@ 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)
@ -197,12 +202,15 @@ 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)
@ -227,11 +235,13 @@ 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)
@ -250,11 +260,13 @@ 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)
@ -273,11 +285,13 @@ 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)
@ -299,11 +313,13 @@ 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)
@ -349,11 +365,13 @@ 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)
@ -364,11 +382,13 @@ 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)
@ -412,6 +432,7 @@ 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{
@ -420,6 +441,7 @@ 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)
@ -435,6 +457,7 @@ 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{
@ -443,6 +466,7 @@ 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)
@ -607,3 +631,58 @@ 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)
}
}

56
bootstrap/configtest.go Normal file
View file

@ -0,0 +1,56 @@
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

@ -0,0 +1,155 @@
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())
}
}

34
bootstrap/login.go Normal file
View file

@ -0,0 +1,34 @@
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
}
}

103
bootstrap/login_test.go Normal file
View file

@ -0,0 +1,103 @@
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"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/manifest"
"forge.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 {
} else if strings.TrimSpace(options.ManifestDir) != "" {
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
}
if options.ConnectivityCheck != nil {

View file

@ -10,9 +10,9 @@ import (
"strings"
"testing"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
type doctorProfile struct {
@ -198,6 +198,24 @@ 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"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
type KeyLookupFunc func(key string) (string, bool, error)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,27 @@
# Bootstrap CLI
Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique.
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.
Exemple minimal :
## 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
```go
func main() {
@ -10,26 +29,22 @@ func main() {
BinaryName: "my-mcp",
Description: "Client MCP",
Version: version,
EnableDoctorAlias: true, // expose `doctor` comme alias de `config test`
AliasDescriptions: map[string]string{
"doctor": "Diagnostiquer la configuration locale.",
},
EnableDoctorAlias: true,
Hooks: bootstrap.Hooks{
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
return runSetup(ctx, inv.Args)
},
Login: func(ctx context.Context, inv bootstrap.Invocation) error {
return runLogin(ctx, inv.Args)
},
Login: bootstrap.BitwardenLoginHandler("my-mcp"),
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: func(ctx context.Context, inv bootstrap.Invocation) error {
return runConfigTest(ctx, inv.Args)
},
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
OpenStore: openStore,
ConnectivityCheck: connectivityCheck,
}),
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
return runUpdate(ctx, inv.Args)
},
@ -41,11 +56,97 @@ func main() {
}
```
Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`.
## Handlers fournis
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
### `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
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).

View file

@ -126,8 +126,11 @@ 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 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 :
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 :
```go
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
@ -137,32 +140,19 @@ 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 is unreachable",
Summary: "backend inaccessible",
Detail: err.Error(),
}
}
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusOK,
Summary: "backend is reachable",
Summary: "backend accessible",
}
},
})
@ -176,6 +166,10 @@ 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 gitea.lclr.dev/AI/mcp-framework
go get forge.lclr.dev/AI/mcp-framework
```
## CLI de scaffold
@ -11,7 +11,7 @@ go get gitea.lclr.dev/AI/mcp-framework
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
```bash
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
go install forge.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"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/update"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.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: "gitea.lclr.dev/AI/my-mcp",
ModulePath: "forge.lclr.dev/AI/my-mcp",
BinaryName: "my-mcp",
Description: "Client MCP interne",
DefaultProfile: "prod",

View file

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

View file

@ -12,7 +12,7 @@ import (
"strconv"
"strings"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"forge.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 "gitea.lclr.dev/AI/mcp-framework/manifest"
import fwmanifest "forge.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 "gitea.lclr.dev/AI/mcp-framework/manifest"
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
const BinaryName = %s
const DefaultDescription = %s
@ -275,7 +275,7 @@ import (
"io"
"strings"
fwupdate "gitea.lclr.dev/AI/mcp-framework/update"
fwupdate "forge.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 "gitea.lclr.dev/AI/mcp-framework/secretstore"
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
)
type SecretStoreOptions struct {
@ -483,7 +483,7 @@ import (
"flag"
"strings"
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
fwcli "forge.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 \"gitea.lclr.dev/AI/mcp-framework/manifest\"",
"import fwmanifest \"forge.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\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.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\tforge.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace forge.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 "gitea.lclr.dev/AI/mcp-framework/cli"
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
"example.com/generated-demo/mcpgen"
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {

2
go.mod
View file

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

View file

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

View file

@ -1539,12 +1539,12 @@ import (
"strings"
"sync"
"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"
"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"
)
var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}"

View file

@ -50,6 +50,8 @@ type bitwardenStore struct {
command string
serviceName string
debug bool
lookupEnv func(string) (string, bool)
shell string
cache *bitwardenCache
}
@ -86,6 +88,8 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
command: command,
serviceName: serviceName,
debug: debugEnabled,
lookupEnv: options.LookupEnv,
shell: options.Shell,
cache: newBitwardenCache(bitwardenCacheOptions{
ServiceName: serviceName,
Session: session,
@ -95,39 +99,41 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
}),
}
if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil {
return store, nil
}
func verifyBitwardenCLIReady(options Options) error {
command := strings.TrimSpace(options.BitwardenCommand)
if command == "" {
command = defaultBitwardenCommand
}
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return nil, fmt.Errorf(
"secret backend policy %q requires bitwarden CLI command %q in PATH: %w",
policy,
return fmt.Errorf(
"requires bitwarden CLI command %q in PATH: %w",
command,
ErrBackendUnavailable,
errors.Join(ErrBackendUnavailable, err),
)
}
return nil, fmt.Errorf(
"secret backend policy %q cannot verify bitwarden CLI command %q: %w",
policy,
return fmt.Errorf(
"cannot verify bitwarden CLI command %q: %w",
command,
errors.Join(ErrBackendUnavailable, err),
)
}
if err := EnsureBitwardenReady(Options{
BitwardenCommand: command,
BitwardenDebug: debugEnabled,
LookupEnv: options.LookupEnv,
Shell: options.Shell,
}); err != nil {
return nil, fmt.Errorf(
"secret backend policy %q cannot use bitwarden CLI command %q right now: %w",
policy,
if err := EnsureBitwardenReady(options); err != nil {
return fmt.Errorf(
"cannot use bitwarden CLI command %q right now: %w",
command,
errors.Join(ErrBackendUnavailable, err),
)
}
return store, nil
return nil
}
func EnsureBitwardenReady(options Options) error {
@ -252,6 +258,10 @@ func detectShellFlavor(shellHint string) string {
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
secretName := s.scopedName(name)
if err := s.ensureReady(); err != nil {
return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err)
}
item, payload, err := s.findItem(secretName, name)
switch {
case errors.Is(err, ErrNotFound):
@ -316,6 +326,10 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) {
}
}
if err := s.ensureReady(); err != nil {
return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err)
}
_, payload, err := s.findItem(secretName, name)
if err != nil {
return "", err
@ -334,6 +348,10 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) {
func (s *bitwardenStore) DeleteSecret(name string) error {
secretName := s.scopedName(name)
if err := s.ensureReady(); err != nil {
return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err)
}
item, _, err := s.findItem(secretName, name)
if errors.Is(err, ErrNotFound) {
if s.cache != nil {
@ -365,6 +383,24 @@ func (s *bitwardenStore) scopedName(name string) string {
return fmt.Sprintf("%s/%s", s.serviceName, name)
}
func (s *bitwardenStore) ensureReady() error {
s.refreshSessionEnv()
return verifyBitwardenCLIReady(Options{
BitwardenCommand: s.command,
BitwardenDebug: s.debug,
LookupEnv: s.lookupEnv,
Shell: s.shell,
})
}
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

@ -32,6 +32,8 @@ const (
bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1"
)
var bitwardenUserCacheDir = os.UserCacheDir
type bitwardenCacheOptions struct {
ServiceName string
Session string
@ -355,7 +357,7 @@ func (c *bitwardenCache) cacheContext(secretName, scopedName string) string {
}
func resolveBitwardenCacheDir(serviceName string) string {
cacheRoot, err := os.UserCacheDir()
cacheRoot, err := bitwardenUserCacheDir()
if err != nil {
return ""
}

View file

@ -129,3 +129,13 @@ func TestBitwardenCacheExpiresEntries(t *testing.T) {
t.Fatalf("expired cache hit = %q, want miss", got)
}
}
func withBitwardenUserCacheDir(t *testing.T, resolver func() (string, error)) {
t.Helper()
previous := bitwardenUserCacheDir
bitwardenUserCacheDir = resolver
t.Cleanup(func() {
bitwardenUserCacheDir = previous
})
}

View file

@ -9,7 +9,10 @@ import (
"strings"
)
const bitwardenSessionFileName = "bw-session"
const (
bitwardenSessionFileName = "bw-session"
bitwardenSharedSessionName = "mcp-framework"
)
var bitwardenUserConfigDir = os.UserConfigDir
@ -108,10 +111,14 @@ func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
session, err := LoadBitwardenSession(options)
if err != nil {
if errors.Is(err, ErrNotFound) {
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 {
return false, nil
}
return false, err
}
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
@ -156,7 +163,20 @@ 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 "locked", "unlocked":
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":
default:
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
}
@ -175,13 +195,25 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil {
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, 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: "email-mcp"})
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
t.Fatalf("LoadBitwardenSession returned error: %v", err)
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
}
if persisted != "persisted-session" {
t.Fatalf("persisted session = %q, want persisted-session", persisted)
t.Fatalf("shared persisted session = %q, want persisted-session", persisted)
}
}
@ -207,6 +207,112 @@ 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"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
@ -34,23 +34,29 @@ func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
if _, ok := store.(*bitwardenStore); !ok {
t.Fatalf("store type = %T, want *bitwardenStore", store)
}
if !fakeCLI.versionChecked {
t.Fatal("expected bitwarden CLI version check")
if fakeCLI.versionChecked {
t.Fatal("Open should not check bitwarden CLI version before a cache miss or write")
}
if !fakeCLI.statusChecked {
t.Fatal("expected bitwarden CLI status check")
if fakeCLI.statusChecked {
t.Fatal("Open should not check bitwarden CLI status before a cache miss or write")
}
}
func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
withBitwardenSession(t)
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
})
_, err := Open(Options{
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
_, err = store.GetSecret("api-token")
if err == nil {
t.Fatal("expected error")
}
@ -59,26 +65,31 @@ func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
}
}
func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) {
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw")
withBitwardenRunner(t, fakeCLI.run)
_, err := Open(Options{
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
LookupEnv: func(name string) (string, bool) {
return "", false
},
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
_, err = store.GetSecret("api-token")
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrBWLocked) {
t.Fatalf("error = %v, want ErrBWLocked", err)
}
if !errors.Is(err, ErrBackendUnavailable) {
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
}
}
func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) {
@ -404,6 +415,81 @@ func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) {
}
}
func TestBitwardenStoreDiskCacheHitSkipsBitwardenCLI(t *testing.T) {
withBitwardenSession(t)
cacheRoot := t.TempDir()
withBitwardenUserCacheDir(t, func() (string, error) {
return cacheRoot, nil
})
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: os.Getenv("BW_SESSION"),
TTL: defaultBitwardenCacheTTL,
CacheDir: resolveBitwardenCacheDir("email-mcp"),
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-from-cache")
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
return nil, fmt.Errorf("unexpected bitwarden invocation: %v", args)
})
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
value, err := store.GetSecret("api-token")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "secret-from-cache" {
t.Fatalf("GetSecret = %q, want secret-from-cache", value)
}
}
func TestBitwardenStoreCacheMissChecksReadinessLazily(t *testing.T) {
withBitwardenSession(t)
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 fakeCLI.statusChecked {
t.Fatal("Open should not check bitwarden status")
}
value, err := store.GetSecret("api-token")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "secret-v1" {
t.Fatalf("GetSecret = %q, want secret-v1", value)
}
if !fakeCLI.statusChecked {
t.Fatal("cache miss should check bitwarden status before CLI lookup")
}
}
func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) {
withBitwardenSession(t)
t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0")
@ -489,6 +575,9 @@ 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" {
@ -522,20 +611,33 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) {
func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) {
withBitwardenSession(t)
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)
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1")
var logs bytes.Buffer
withBitwardenDebugOutput(t, &logs)
_, err := Open(Options{
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
text := logs.String()
if !strings.Contains(text, "bw --version") {
@ -632,6 +734,84 @@ 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")
@ -669,6 +849,12 @@ func withBitwardenSession(t *testing.T) {
t.Helper()
sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
t.Setenv("BW_SESSION", "test-session-"+sessionName)
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"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"forge.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"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {

View file

@ -16,6 +16,7 @@ type DescribeRuntimeOptions struct {
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
CheckReady bool
Shell string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
@ -91,11 +92,23 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
desc.EffectivePolicy = effective
desc.DisplayName = BackendDisplayName(effective)
}
if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI {
if err := verifyBitwardenCLIReady(Options{
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
LookupEnv: options.LookupEnv,
Shell: options.Shell,
}); err != nil {
desc.Ready = false
desc.ReadyError = err
}
}
return desc, nil
}
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"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
@ -46,16 +46,9 @@ func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
}
}
func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) {
func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(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")
}
return nil, errors.New("unexpected bitwarden invocation")
})
desc, err := DescribeRuntime(DescribeRuntimeOptions{
@ -80,14 +73,11 @@ func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) {
if desc.EffectivePolicy != BackendBitwardenCLI {
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI)
}
if desc.Ready {
t.Fatalf("Ready = %v, want false", desc.Ready)
if !desc.Ready {
t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready)
}
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)
if desc.ReadyError != nil {
t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError)
}
}