Compare commits

...

26 commits
v1.8.1 ... 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
38 changed files with 1254 additions and 146 deletions

View file

@ -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}" \

View file

@ -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
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 : Dans un projet Go :
```bash ```bash
go get gitea.lclr.dev/AI/mcp-framework go get forge.lclr.dev/AI/mcp-framework
``` ```
Pour utiliser le CLI : Pour utiliser le CLI :
```bash ```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 ## Créer un projet MCP

View file

@ -50,17 +50,18 @@ type Hooks struct {
} }
type Options struct { type Options struct {
BinaryName string BinaryName string
Description string Description string
Version string Version string
Aliases map[string][]string Aliases map[string][]string
AliasDescriptions map[string]string AliasDescriptions map[string]string
EnableDoctorAlias bool EnableDoctorAlias bool
Args []string DisabledCommands []string
Stdin io.Reader Args []string
Stdout io.Writer Stdin io.Reader
Stderr io.Writer Stdout io.Writer
Hooks Hooks Stderr io.Writer
Hooks Hooks
} }
type Invocation struct { type Invocation struct {
@ -144,6 +145,10 @@ func Run(ctx context.Context, opts Options) error {
return printHelp(normalized, "") return printHelp(normalized, "")
} }
if isCommandDisabled(command, normalized.DisabledCommands) {
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
}
if command == CommandConfig { if command == CommandConfig {
return runConfigCommand(ctx, normalized, commandArgs) return runConfigCommand(ctx, normalized, commandArgs)
} }
@ -154,14 +159,16 @@ func Run(ctx context.Context, opts Options) error {
} }
if handler == nil { if handler == nil {
if command == CommandVersion { switch command {
case CommandVersion:
if strings.TrimSpace(normalized.Version) == "" { if strings.TrimSpace(normalized.Version) == "" {
return ErrVersionRequired return ErrVersionRequired
} }
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version) _, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
return err return err
default:
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
} }
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
} }
return handler(ctx, Invocation{ return handler(ctx, Invocation{
@ -188,6 +195,11 @@ func normalize(opts Options) Options {
} }
opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias) opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias)
opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, 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 return opts
} }
@ -444,6 +456,10 @@ func printHelp(opts Options, command string, args ...[]string) error {
return printGlobalHelp(opts) return printGlobalHelp(opts)
} }
if isCommandDisabled(command, opts.DisabledCommands) {
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
}
if command == CommandConfig { if command == CommandConfig {
return printConfigHelp(opts, commandArgs) return printConfigHelp(opts, commandArgs)
} }
@ -523,6 +539,9 @@ func printGlobalHelp(opts Options) error {
} }
for _, def := range commands { 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 { if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil {
return err return err
} }
@ -559,6 +578,39 @@ func printGlobalHelp(opts Options) error {
return err 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 { func aliasDescription(descriptions map[string]string, name, target string) string {
description := strings.TrimSpace(descriptions[name]) description := strings.TrimSpace(descriptions[name])
if description == "" { if description == "" {

View file

@ -91,11 +91,13 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Args: []string{"config"}, Args: []string{"config"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
}) })
if !errors.Is(err, ErrSubcommandRequired) { if !errors.Is(err, ErrSubcommandRequired) {
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err) 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 stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@ -158,8 +160,8 @@ func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
}) })
if !errors.Is(err, ErrVersionRequired) { if !errors.Is(err, ErrUnknownCommand) {
t.Fatalf("Run error = %v, want ErrVersionRequired", err) t.Fatalf("Run error = %v, want ErrUnknownCommand", err)
} }
} }
@ -167,12 +169,15 @@ func TestRunPrintsGlobalHelp(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Description: "Binaire MCP de test.", Description: "Binaire MCP de test.",
Version: "v1.2.3",
Args: []string{"help"}, Args: []string{"help"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
}) })
if err != nil { if err != nil {
t.Fatalf("Run error = %v", err) t.Fatalf("Run error = %v", err)
@ -197,12 +202,15 @@ func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Description: "Binaire MCP de test.", Description: "Binaire MCP de test.",
Version: "v1.2.3",
Args: []string{}, Args: []string{},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
}) })
if err != nil { if err != nil {
t.Fatalf("Run error = %v", err) t.Fatalf("Run error = %v", err)
@ -227,11 +235,13 @@ func TestRunPrintsCommandHelp(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Args: []string{"help", "update"}, Args: []string{"help", "update"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{Update: noop},
}) })
if err != nil { if err != nil {
t.Fatalf("Run error = %v", err) t.Fatalf("Run error = %v", err)
@ -250,11 +260,13 @@ func TestRunPrintsLoginCommandHelp(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Args: []string{"help", "login"}, Args: []string{"help", "login"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{Login: noop},
}) })
if err != nil { if err != nil {
t.Fatalf("Run error = %v", err) t.Fatalf("Run error = %v", err)
@ -273,11 +285,13 @@ func TestRunPrintsConfigHelp(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Args: []string{"help", "config"}, Args: []string{"help", "config"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
}) })
if err != nil { if err != nil {
t.Fatalf("Run error = %v", err) t.Fatalf("Run error = %v", err)
@ -299,11 +313,13 @@ func TestRunPrintsConfigSubcommandHelp(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Args: []string{"help", "config", "show"}, Args: []string{"help", "config", "show"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
}) })
if err != nil { if err != nil {
t.Fatalf("Run error = %v", err) t.Fatalf("Run error = %v", err)
@ -349,11 +365,13 @@ func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Args: []string{"config", "show"}, Args: []string{"config", "show"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
}) })
if !errors.Is(err, ErrCommandNotConfigured) { if !errors.Is(err, ErrCommandNotConfigured) {
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err) t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
@ -364,11 +382,13 @@ func TestRunConfigReturnsUnknownSubcommand(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Args: []string{"config", "sync"}, Args: []string{"config", "sync"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
}) })
if !errors.Is(err, ErrUnknownSubcommand) { if !errors.Is(err, ErrUnknownSubcommand) {
t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err) t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err)
@ -412,6 +432,7 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Aliases: map[string][]string{ Aliases: map[string][]string{
@ -420,6 +441,7 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
Args: []string{"help", "doctor"}, Args: []string{"help", "doctor"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
}) })
if err != nil { if err != nil {
t.Fatalf("Run error = %v", err) t.Fatalf("Run error = %v", err)
@ -435,6 +457,7 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{ err := Run(context.Background(), Options{
BinaryName: "my-mcp", BinaryName: "my-mcp",
Aliases: map[string][]string{ Aliases: map[string][]string{
@ -443,6 +466,7 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
Args: []string{"doctor", "--help"}, Args: []string{"doctor", "--help"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
}) })
if err != nil { if err != nil {
t.Fatalf("Run error = %v", err) 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") 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" "os"
"strings" "strings"
"gitea.lclr.dev/AI/mcp-framework/config" "forge.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest" "forge.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore" "forge.lclr.dev/AI/mcp-framework/secretstore"
) )
type DoctorStatus string type DoctorStatus string
@ -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 {

View file

@ -10,9 +10,9 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.lclr.dev/AI/mcp-framework/config" "forge.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest" "forge.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore" "forge.lclr.dev/AI/mcp-framework/secretstore"
) )
type doctorProfile struct { 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) { func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
prev := checkBitwardenReady prev := checkBitwardenReady
t.Cleanup(func() { t.Cleanup(func() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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`).

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. `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 :

View file

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

View file

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

View file

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

View file

@ -187,7 +187,8 @@ effective := secretstore.EffectiveBackendPolicy(store)
fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any... 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 ```go
desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{ desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{
@ -202,7 +203,8 @@ fmt.Println(secretstore.FormatBackendStatus(desc))
// declared=... effective=... display=... ready=... source=... // 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 ```go
report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{ report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{

View file

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

View file

@ -37,7 +37,7 @@ description = "Demo MCP"
for _, snippet := range []string{ for _, snippet := range []string{
"// Code generated by mcp-framework generate. DO NOT EDIT.", "// Code generated by mcp-framework generate. DO NOT EDIT.",
"package mcpgen", "package mcpgen",
"import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"", "import fwmanifest \"forge.lclr.dev/AI/mcp-framework/manifest\"",
"const embeddedManifest = ", "const embeddedManifest = ",
"func LoadManifest(startDir string) (fwmanifest.File, string, error) {", "func LoadManifest(startDir string) (fwmanifest.File, string, error) {",
"return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)", "return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)",
@ -386,7 +386,7 @@ func writeModule(t *testing.T, projectDir string) {
t.Fatalf("Abs repo root: %v", err) 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 { if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil {
t.Fatalf("WriteFile go.mod: %v", err) t.Fatalf("WriteFile go.mod: %v", err)
} }
@ -406,10 +406,10 @@ import (
"io" "io"
"testing" "testing"
fwcli "gitea.lclr.dev/AI/mcp-framework/cli" fwcli "forge.lclr.dev/AI/mcp-framework/cli"
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
"example.com/generated-demo/mcpgen" "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) { 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 go 1.25.0

View file

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

View file

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

View file

@ -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

View file

@ -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 == "" {

View file

@ -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),

View file

@ -15,7 +15,7 @@ import (
"testing" "testing"
"unicode/utf8" "unicode/utf8"
"gitea.lclr.dev/AI/mcp-framework/manifest" "forge.lclr.dev/AI/mcp-framework/manifest"
) )
func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
@ -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(

View file

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

View file

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

View file

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

View file

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