Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0e1f6de6 | ||
| 7c999d2aba | |||
| 846894c1a7 | |||
| 7c016e8c5e | |||
| 90dbed4d37 | |||
| 078aa17285 | |||
|
|
200674778b | ||
| 267b83bd0c | |||
| 92b63fe83d | |||
| 64671fc8b2 | |||
| 9ac814fda4 | |||
| 39b2bfbcf9 | |||
| ea3a37559a | |||
| 4e2bfbee02 | |||
| 3a61387215 | |||
| b9b729e439 | |||
| f8eb0d3449 | |||
| e6c372bffc | |||
| d23d79b6c1 |
17 changed files with 1052 additions and 79 deletions
|
|
@ -6,12 +6,13 @@ name: Release
|
||||||
- "**"
|
- "**"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
releases: write
|
releases: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
@ -19,38 +20,55 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Build changelog
|
- name: Extract changelog and update CHANGELOG.md
|
||||||
id: changelog
|
id: changelog
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
current_tag="${GITHUB_REF_NAME}"
|
current_tag="${GITHUB_REF_NAME}"
|
||||||
previous_stable_tag=""
|
today=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
if previous_stable_tag="$(
|
# Extract content of [Unreleased] section (non-empty lines)
|
||||||
git describe --tags --abbrev=0 \
|
release_notes=$(awk '/^## \[Unreleased\]/{found=1; next} found && /^## \[/{exit} found{print}' CHANGELOG.md | sed '/^[[:space:]]*$/d')
|
||||||
--exclude '*-rc*' \
|
|
||||||
--exclude '*-beta*' \
|
if [ -z "${release_notes}" ]; then
|
||||||
--exclude '*-alpha*' \
|
release_notes="Voir les commits pour le détail des changements."
|
||||||
"${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
|
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
|
- name: Create or update release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
@ -69,11 +87,32 @@ jobs:
|
||||||
;;
|
;;
|
||||||
esac
|
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() {
|
json_escape() {
|
||||||
sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g'
|
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}' \
|
payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
|
||||||
"${current_tag}" \
|
"${current_tag}" \
|
||||||
"${current_tag}" \
|
"${current_tag}" \
|
||||||
|
|
|
||||||
|
|
@ -60,3 +60,11 @@ Avant d'ouvrir ou de mettre à jour une PR :
|
||||||
## Commits
|
## Commits
|
||||||
|
|
||||||
Conserver des messages de commit au format conventional commits, conformément aux règles globales.
|
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
240
CHANGELOG.md
Normal 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
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
AGENTS.md
|
||||||
|
|
@ -8,8 +8,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -168,14 +166,6 @@ func Run(ctx context.Context, opts Options) error {
|
||||||
}
|
}
|
||||||
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
||||||
return err
|
return err
|
||||||
case CommandLogin:
|
|
||||||
_, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
|
|
||||||
ServiceName: normalized.BinaryName,
|
|
||||||
Stdin: normalized.Stdin,
|
|
||||||
Stdout: normalized.Stdout,
|
|
||||||
Stderr: normalized.Stderr,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
bootstrap/configtest.go
Normal file
56
bootstrap/configtest.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
155
bootstrap/configtest_test.go
Normal file
155
bootstrap/configtest_test.go
Normal 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
34
bootstrap/login.go
Normal 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
103
bootstrap/login_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,7 +86,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
||||||
}
|
}
|
||||||
if options.ManifestCheck != nil {
|
if options.ManifestCheck != nil {
|
||||||
checks = append(checks, options.ManifestCheck)
|
checks = append(checks, options.ManifestCheck)
|
||||||
} else {
|
} else if strings.TrimSpace(options.ManifestDir) != "" {
|
||||||
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
||||||
}
|
}
|
||||||
if options.ConnectivityCheck != nil {
|
if options.ConnectivityCheck != nil {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
|
||||||
prev := checkBitwardenReady
|
prev := checkBitwardenReady
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,27 @@
|
||||||
# Bootstrap CLI
|
# 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
|
```go
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -10,26 +29,22 @@ func main() {
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Description: "Client MCP",
|
Description: "Client MCP",
|
||||||
Version: version,
|
Version: version,
|
||||||
EnableDoctorAlias: true, // expose `doctor` comme alias de `config test`
|
EnableDoctorAlias: true,
|
||||||
AliasDescriptions: map[string]string{
|
|
||||||
"doctor": "Diagnostiquer la configuration locale.",
|
|
||||||
},
|
|
||||||
Hooks: bootstrap.Hooks{
|
Hooks: bootstrap.Hooks{
|
||||||
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runSetup(ctx, inv.Args)
|
return runSetup(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
Login: func(ctx context.Context, inv bootstrap.Invocation) error {
|
Login: bootstrap.BitwardenLoginHandler("my-mcp"),
|
||||||
return runLogin(ctx, inv.Args)
|
|
||||||
},
|
|
||||||
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runMCP(ctx, inv.Args)
|
return runMCP(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runConfigShow(ctx, inv.Args)
|
return runConfigShow(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error {
|
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
|
||||||
return runConfigTest(ctx, inv.Args)
|
OpenStore: openStore,
|
||||||
},
|
ConnectivityCheck: connectivityCheck,
|
||||||
|
}),
|
||||||
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
|
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runUpdate(ctx, inv.Args)
|
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`).
|
### `BitwardenLoginHandler`
|
||||||
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`).
|
```go
|
||||||
Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale.
|
bootstrap.BitwardenLoginHandler(binaryName string) bootstrap.Handler
|
||||||
Le flag global `--debug` est supporté et active le debug des appels Bitwarden
|
```
|
||||||
|
|
||||||
|
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`).
|
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
`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`.
|
Le package fournit 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 :
|
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
|
```go
|
||||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||||
|
|
@ -137,32 +140,19 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||||
ServiceName: "my-mcp",
|
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 {
|
ConnectivityCheck: func(context.Context) cli.DoctorResult {
|
||||||
if err := pingBackend(); err != nil {
|
if err := pingBackend(); err != nil {
|
||||||
return cli.DoctorResult{
|
return cli.DoctorResult{
|
||||||
Name: "connectivity",
|
Name: "connectivity",
|
||||||
Status: cli.DoctorStatusFail,
|
Status: cli.DoctorStatusFail,
|
||||||
Summary: "backend is unreachable",
|
Summary: "backend inaccessible",
|
||||||
Detail: err.Error(),
|
Detail: err.Error(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cli.DoctorResult{
|
return cli.DoctorResult{
|
||||||
Name: "connectivity",
|
Name: "connectivity",
|
||||||
Status: cli.DoctorStatusOK,
|
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.
|
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
|
||||||
Pour le désactiver explicitement :
|
Pour le désactiver explicitement :
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,7 @@ func (s *bitwardenStore) scopedName(name string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *bitwardenStore) ensureReady() error {
|
func (s *bitwardenStore) ensureReady() error {
|
||||||
|
s.refreshSessionEnv()
|
||||||
return verifyBitwardenCLIReady(Options{
|
return verifyBitwardenCLIReady(Options{
|
||||||
BitwardenCommand: s.command,
|
BitwardenCommand: s.command,
|
||||||
BitwardenDebug: s.debug,
|
BitwardenDebug: s.debug,
|
||||||
|
|
@ -392,6 +393,14 @@ 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 {
|
type bitwardenResolvedItem struct {
|
||||||
item bitwardenListItem
|
item bitwardenListItem
|
||||||
payload map[string]any
|
payload map[string]any
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const bitwardenSessionFileName = "bw-session"
|
const (
|
||||||
|
bitwardenSessionFileName = "bw-session"
|
||||||
|
bitwardenSharedSessionName = "mcp-framework"
|
||||||
|
)
|
||||||
|
|
||||||
var bitwardenUserConfigDir = os.UserConfigDir
|
var bitwardenUserConfigDir = os.UserConfigDir
|
||||||
|
|
||||||
|
|
@ -108,10 +111,14 @@ func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
|
||||||
|
|
||||||
session, err := LoadBitwardenSession(options)
|
session, err := LoadBitwardenSession(options)
|
||||||
if err != nil {
|
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, nil
|
||||||
}
|
}
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
|
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 {
|
if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil {
|
||||||
return "", fmt.Errorf("login to bitwarden CLI: %w", err)
|
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:
|
default:
|
||||||
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
|
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)
|
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 "", fmt.Errorf("persist bitwarden session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return session, nil
|
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) {
|
func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) {
|
||||||
serviceName := strings.TrimSpace(options.ServiceName)
|
serviceName := strings.TrimSpace(options.ServiceName)
|
||||||
if serviceName == "" {
|
if serviceName == "" {
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,12 @@ func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) {
|
||||||
t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1])
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("LoadBitwardenSession returned error: %v", err)
|
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
|
||||||
}
|
}
|
||||||
if persisted != "persisted-session" {
|
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(
|
func withBitwardenInteractiveRunner(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),
|
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
|
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
|
||||||
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||||
|
return t.TempDir(), nil
|
||||||
|
})
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
fakeCLI := newFakeBitwardenCLI("bw")
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
withBitwardenRunner(t, fakeCLI.run)
|
||||||
|
|
||||||
|
|
@ -572,6 +575,9 @@ func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
|
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
|
||||||
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||||
|
return t.TempDir(), nil
|
||||||
|
})
|
||||||
store := &bitwardenStore{command: "bw", serviceName: "email-mcp"}
|
store := &bitwardenStore{command: "bw", serviceName: "email-mcp"}
|
||||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||||
if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" {
|
if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" {
|
||||||
|
|
@ -728,6 +734,84 @@ func stripANSIControlSequences(value string) string {
|
||||||
return strings.ReplaceAll(noANSI, "\r", "")
|
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) {
|
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||||
withBitwardenSession(t)
|
withBitwardenSession(t)
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
fakeCLI := newFakeBitwardenCLI("bw")
|
||||||
|
|
@ -768,6 +852,9 @@ func withBitwardenSession(t *testing.T) {
|
||||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
withBitwardenUserCacheDir(t, func() (string, error) {
|
||||||
return t.TempDir(), nil
|
return t.TempDir(), nil
|
||||||
})
|
})
|
||||||
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
||||||
|
return t.TempDir(), nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func withBitwardenRunner(
|
func withBitwardenRunner(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue