Compare commits

...

40 commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:37:35 +02:00
6bf9dd1866 chore: update module path to forge 2026-05-05 12:23:14 +02:00
6e85969cf4 migrate workflows to forgejo 2026-05-05 12:13:01 +02:00
1e11181c02 perf: avoid bitwarden probe in runtime description 2026-05-02 15:47:07 +02:00
893600ffd5 perf: lazy check bitwarden readiness 2026-05-02 15:30:18 +02:00
0135b093a5 docs: document bitwarden cache controls 2026-05-02 15:04:54 +02:00
5552e63974 feat: expose bitwarden cache in generated helpers 2026-05-02 15:04:29 +02:00
fd08615950 feat: cache bitwarden secret reads 2026-05-02 15:03:35 +02:00
85da274772 feat: add encrypted bitwarden cache core 2026-05-02 15:00:57 +02:00
1a44a2ea35 feat: wire bitwarden cache options 2026-05-02 14:59:04 +02:00
9675490cd3 feat: parse bitwarden cache manifest option 2026-05-02 14:57:52 +02:00
e5f2244ad8 docs: plan bitwarden cache implementation 2026-05-02 14:55:52 +02:00
e99a1c109a docs: design bitwarden cache 2026-05-02 14:46:19 +02:00
afe4c681a1 docs: refresh usage documentation 2026-05-02 12:07:34 +02:00
17b1b99686 feat: generate config field helpers 2026-05-02 12:02:23 +02:00
a79f73825f feat: generate manifest helper glue 2026-05-02 11:57:44 +02:00
20b5026f9d feat: add manifest code generation 2026-05-02 11:46:47 +02:00
ef22b1aa8a fix: prompt login in red when bitwarden session is missing 2026-04-20 17:52:50 +02:00
52 changed files with 5588 additions and 300 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

@ -1,52 +1,90 @@
# mcp-framework # mcp-framework
`mcp-framework` est une bibliothèque Go pour construire des binaires MCP robustes, sans imposer un runtime lourd. `mcp-framework` est une bibliothèque Go et un petit CLI pour construire des
binaires MCP avec une base commune : CLI, configuration locale, secrets,
manifeste `mcp.toml`, diagnostic et auto-update.
## Le principal à savoir ## Installation
- Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update. Dans un projet Go :
- Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi.
- Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement.
- Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties.
## Démarrage rapide
Installer le framework dans un projet Go existant :
```bash ```bash
go get gitea.lclr.dev/AI/mcp-framework go get forge.lclr.dev/AI/mcp-framework
``` ```
Initialiser un nouveau projet MCP depuis un dossier vide : Pour utiliser le CLI :
```bash
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
```
## Créer un projet MCP
```bash ```bash
go install gitea.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 \
--binary my-mcp \ --binary my-mcp \
--profiles dev,prod --profiles dev,prod
```
Puis dans le projet généré :
```bash
cd my-mcp cd my-mcp
go mod tidy go mod tidy
go run ./cmd/my-mcp help go run ./cmd/my-mcp help
``` ```
Le scaffold crée une arborescence prête à adapter :
```text
cmd/<binary>/main.go
internal/app/app.go
mcp.toml
install.sh
README.md
```
## Générer la glue depuis `mcp.toml`
Dans un projet qui possède un `mcp.toml` à la racine :
```bash
mcp-framework generate
```
La commande génère un package `mcpgen/` avec un loader de manifeste embarqué,
des helpers de métadonnées, update, secret store, et des helpers de config si
`[[config.fields]]` est déclaré.
En CI :
```bash
mcp-framework generate --check
```
## Utiliser les packages
Les packages peuvent être utilisés séparément :
- `bootstrap` : CLI commune (`setup`, `login`, `mcp`, `config`, `update`, `version`).
- `cli` : résolution de profil, setup interactif, résolution `flag/env/config/secret`, doctor.
- `config` : stockage JSON versionné dans le répertoire de config utilisateur.
- `manifest` : lecture de `mcp.toml` et fallback embarqué.
- `secretstore` : keyring natif, environnement ou Bitwarden CLI.
- `update` : téléchargement et remplacement du binaire depuis une release.
- `scaffold` : génération d'un squelette de projet.
- `generate` : génération de code Go depuis `mcp.toml`.
## Documentation ## Documentation
- Vue d'ensemble : [docs/README.md](docs/README.md) - [Vue d'ensemble](docs/README.md)
- Installation et usage type : [docs/getting-started.md](docs/getting-started.md) - [Installation et utilisation](docs/getting-started.md)
- Packages : [docs/packages.md](docs/packages.md) - [Packages](docs/packages.md)
- Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md) - [Bootstrap CLI](docs/bootstrap-cli.md)
- Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md) - [Manifeste `mcp.toml`](docs/manifest.md)
- Scaffolding : [docs/scaffolding.md](docs/scaffolding.md) - [Génération depuis `mcp.toml`](docs/generate.md)
- Config JSON : [docs/config.md](docs/config.md) - [Scaffolding](docs/scaffolding.md)
- Secrets : [docs/secrets.md](docs/secrets.md) - [Config JSON](docs/config.md)
- Helpers CLI : [docs/cli-helpers.md](docs/cli-helpers.md) - [Secrets](docs/secrets.md)
- Auto-update : [docs/auto-update.md](docs/auto-update.md) - [Helpers CLI](docs/cli-helpers.md)
- Exemple minimal : [docs/minimal-example.md](docs/minimal-example.md) - [Auto-update](docs/auto-update.md)
- Limites actuelles : [docs/limitations.md](docs/limitations.md) - [Exemple minimal](docs/minimal-example.md)
- [Limites](docs/limitations.md)

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

@ -6,9 +6,11 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"strings" "strings"
scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold" generatepkg "forge.lclr.dev/AI/mcp-framework/generate"
scaffoldpkg "forge.lclr.dev/AI/mcp-framework/scaffold"
) )
const toolName = "mcp-framework" const toolName = "mcp-framework"
@ -34,6 +36,8 @@ func run(args []string, stdout, stderr io.Writer) error {
} }
switch args[0] { switch args[0] {
case "generate":
return runGenerate(args[1:], stdout, stderr)
case "scaffold": case "scaffold":
return runScaffold(args[1:], stdout, stderr) return runScaffold(args[1:], stdout, stderr)
default: default:
@ -41,6 +45,60 @@ func run(args []string, stdout, stderr io.Writer) error {
} }
} }
func runGenerate(args []string, stdout, stderr io.Writer) error {
if shouldShowHelp(args) {
printGenerateHelp(stdout)
return nil
}
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var manifestPath string
var packageDir string
var packageName string
var check bool
fs.StringVar(&manifestPath, "manifest", "", "Chemin du mcp.toml à lire (défaut: ./mcp.toml)")
fs.StringVar(&packageDir, "package-dir", "mcpgen", "Répertoire du package Go généré")
fs.StringVar(&packageName, "package-name", "", "Nom du package Go généré (défaut: dérivé du dossier)")
fs.BoolVar(&check, "check", false, "Échoue si les fichiers générés ne sont pas à jour")
if err := fs.Parse(args); err != nil {
_ = stderr
return fmt.Errorf("parse generate flags: %w", err)
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", "))
}
result, err := generatepkg.Generate(generatepkg.Options{
ManifestPath: manifestPath,
PackageDir: packageDir,
PackageName: packageName,
Check: check,
})
if err != nil {
return err
}
if check {
if _, err := fmt.Fprintln(stdout, "Generated files are up to date"); err != nil {
return err
}
return nil
}
for _, file := range result.Files {
if _, err := fmt.Fprintf(stdout, "Generated %s\n", filepath.ToSlash(file)); err != nil {
return err
}
}
return nil
}
func runScaffold(args []string, stdout, stderr io.Writer) error { func runScaffold(args []string, stdout, stderr io.Writer) error {
if len(args) == 0 || isHelpArg(args[0]) { if len(args) == 0 || isHelpArg(args[0]) {
printScaffoldHelp(stdout) printScaffoldHelp(stdout)
@ -145,12 +203,20 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
func printGlobalHelp(w io.Writer) { func printGlobalHelp(w io.Writer) {
fmt.Fprintf( fmt.Fprintf(
w, w,
"Usage:\n %s <command> [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n", "Usage:\n %s <command> [options]\n\nCommands:\n generate Génère la glue Go depuis mcp.toml\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n",
toolName, toolName,
toolName, toolName,
) )
} }
func printGenerateHelp(w io.Writer) {
fmt.Fprintf(
w,
"Usage:\n %s generate [flags]\n\nFlags:\n --manifest Chemin du mcp.toml à lire\n --package-dir Répertoire du package Go généré (défaut: mcpgen)\n --package-name Nom du package Go généré (défaut: dérivé du dossier)\n --check Vérifie que les fichiers générés sont à jour\n",
toolName,
)
}
func printScaffoldHelp(w io.Writer) { func printScaffoldHelp(w io.Writer) {
fmt.Fprintf( fmt.Fprintf(
w, w,

View file

@ -60,6 +60,46 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) {
} }
} }
func TestRunGenerateCreatesManifestLoader(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml")}, &stdout, &stderr)
if err != nil {
t.Fatalf("run returned error: %v", err)
}
if _, err := os.Stat(filepath.Join(projectDir, "mcpgen", "manifest.go")); err != nil {
t.Fatalf("generated manifest.go missing: %v", err)
}
if !strings.Contains(stdout.String(), "Generated mcpgen/manifest.go") {
t.Fatalf("stdout should include generation summary: %q", stdout.String())
}
}
func TestRunGenerateCheckReturnsErrorWhenOutdated(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml"), "--check"}, &stdout, &stderr)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "generated files are not up to date") {
t.Fatalf("error = %v", err)
}
}
func TestRunScaffoldInitRequiresTarget(t *testing.T) { func TestRunScaffoldInitRequiresTarget(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer

View file

@ -1,6 +1,8 @@
# Documentation mcp-framework # Documentation mcp-framework
Cette documentation est organisée par grandes parties pour séparer la vue d'ensemble des détails d'implémentation. `mcp-framework` fournit des packages Go et un CLI pour construire des binaires
MCP avec une base commune : bootstrap CLI, configuration, secrets, manifeste,
génération de code, scaffold, diagnostic et auto-update.
## Navigation ## Navigation
@ -8,10 +10,11 @@ Cette documentation est organisée par grandes parties pour séparer la vue d'en
- [Packages](packages.md) - [Packages](packages.md)
- [Bootstrap CLI](bootstrap-cli.md) - [Bootstrap CLI](bootstrap-cli.md)
- [Manifeste `mcp.toml`](manifest.md) - [Manifeste `mcp.toml`](manifest.md)
- [Génération depuis `mcp.toml`](generate.md)
- [Scaffolding](scaffolding.md) - [Scaffolding](scaffolding.md)
- [Config JSON](config.md) - [Config JSON](config.md)
- [Secrets](secrets.md) - [Secrets](secrets.md)
- [Helpers CLI](cli-helpers.md) - [Helpers CLI](cli-helpers.md)
- [Auto-update](auto-update.md) - [Auto-update](auto-update.md)
- [Exemple minimal](minimal-example.md) - [Exemple minimal](minimal-example.md)
- [Limites actuelles](limitations.md) - [Limites](limitations.md)

View file

@ -8,7 +8,7 @@ Le parseur de release supporte :
- format `assets.links` (Gitea/GitLab) - format `assets.links` (Gitea/GitLab)
- format `assets[]` avec `browser_download_url` (GitHub et Gitea API) - format `assets[]` avec `browser_download_url` (GitHub et Gitea API)
Le format attendu pour la réponse `latest release` est actuellement : Le format attendu pour la réponse `latest release` est :
```json ```json
{ {

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 :

150
docs/generate.md Normal file
View file

@ -0,0 +1,150 @@
# Génération depuis `mcp.toml`
La commande `mcp-framework generate` génère la glue Go dérivée du manifeste
racine d'un projet existant.
## Usage
Depuis la racine du projet Go :
```bash
mcp-framework generate
```
La commande lit `./mcp.toml`, valide son contenu avec le package `manifest`, et
génère :
```text
mcpgen/
manifest.go
metadata.go
update.go
secretstore.go
config.go # si [[config.fields]] existe
```
Le package généré expose le loader de manifeste :
```go
func LoadManifest(startDir string) (manifest.File, string, error)
```
Cette fonction appelle `manifest.LoadDefaultOrEmbedded`. En développement, un
`mcp.toml` présent sur disque reste prioritaire. Pour un binaire copié seul,
elle utilise le contenu du manifeste embarqué au moment de la génération.
Il expose aussi des helpers dérivés du manifeste :
```go
const BinaryName = "my-mcp"
const DefaultDescription = "..."
const DocsURL = "..."
func BootstrapInfo(startDir string) (manifest.BootstrapMetadata, string, error)
func ScaffoldInfo(startDir string) (manifest.ScaffoldMetadata, string, error)
```
Pour l'auto-update :
```go
func UpdateOptions(version string, stdout io.Writer) (update.Options, error)
func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (update.Options, error)
func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error
func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error
```
`RunUpdate` parse les flags de la commande `update`, refuse les arguments
positionnels, charge le manifeste via `LoadManifest`, puis appelle
`update.Run`.
Pour les secrets :
```go
type SecretStoreOptions struct {
ServiceName string
LookupEnv func(string) (string, bool)
}
func OpenSecretStore(options SecretStoreOptions) (secretstore.Store, error)
func DescribeSecretRuntime(options SecretStoreOptions) (secretstore.RuntimeDescription, error)
func PreflightSecretStore(options SecretStoreOptions) (secretstore.PreflightReport, error)
```
`SecretStoreOptions` contient aussi les options techniques du package
`secretstore` (`KWalletAppID`, `KWalletFolder`, `BitwardenCommand`,
`BitwardenDebug`, `Shell`, `ExecutableResolver`). Si `ServiceName` est vide,
le nom du binaire déclaré dans le manifeste est utilisé.
Si le manifest déclare `[[config.fields]]`, le package généré expose aussi :
```go
type ConfigFlags struct { /* champs internes */ }
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags
func ConfigFlagValues(flags ConfigFlags) map[string]string
func ResolveFieldSpecs(profile string) []cli.FieldSpec
func SetupFields(existing map[string]string) []cli.SetupField
```
`AddConfigFlags` branche les flags déclarés sur le `FlagSet` du projet.
`ConfigFlagValues` retourne uniquement les valeurs de flags non vides.
`ResolveFieldSpecs` génère les specs à passer à `cli.ResolveFields`, en
remplaçant `{profile}` dans les templates de secrets. `SetupFields` génère les
champs attendus par `cli.RunSetup`; le paramètre `existing` permet de fournir
les secrets déjà stockés par nom de champ.
## Flags
- `--manifest` : chemin du `mcp.toml` à lire. Par défaut, `./mcp.toml`.
- `--package-dir` : répertoire du package généré. Par défaut, `mcpgen`.
- `--package-name` : nom du package Go généré. Par défaut, dérivé du dossier.
- `--check` : mode CI, échoue si les fichiers générés sont absents ou obsolètes.
Exemple CI :
```bash
mcp-framework generate --check
```
## Utilisation dans l'application
Importer le package généré depuis le module de l'application :
```go
import "example.com/my-mcp/mcpgen"
```
Charger le manifeste :
```go
file, source, err := mcpgen.LoadManifest(".")
if err != nil {
return err
}
_ = file
_ = source
```
Construire les options d'update :
```go
opts, err := mcpgen.UpdateOptions(version, os.Stdout)
if err != nil {
return err
}
```
Ouvrir le secret store configuré par le manifeste :
```go
store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
LookupEnv: os.LookupEnv,
})
if err != nil {
return err
}
_ = store
```
Après génération, un simple `go build ./...` suffit. La compilation ne dépend
pas de la commande `mcp-framework`.

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 \
@ -29,12 +29,20 @@ go run ./cmd/my-mcp help
## Utilisation type ## Utilisation type
Le flux typique côté application est : Un flux complet côté application :
1. Déclarer les sous-commandes communes via `bootstrap` (optionnel). 1. Déclarer `mcp.toml` à la racine du module.
2. Résoudre le profil actif avec `cli`. 2. Lancer `mcp-framework generate` pour produire le package `mcpgen`.
3. Charger la config versionnée avec `config`. 3. Déclarer les sous-commandes communes via `bootstrap` si l'application utilise le bootstrap CLI.
4. Lire les secrets avec `secretstore`. 4. Résoudre le profil actif avec `cli`.
5. Charger le manifest runtime avec `manifest` (`mcp.toml` local, ou fallback embarqué). 5. Charger la config versionnée avec `config`.
6. Exécuter l'auto-update avec `update` si nécessaire. 6. Lire les secrets avec `secretstore` ou `mcpgen.OpenSecretStore`.
7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier. 7. Charger le manifest runtime avec `mcpgen.LoadManifest`.
8. Exécuter l'auto-update avec `mcpgen.RunUpdate` ou `update.Run`.
9. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier.
Pour vérifier que le code généré est synchronisé avec le manifeste :
```bash
mcp-framework generate --check
```

View file

@ -1,3 +1,3 @@
# Limites actuelles # Limites
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes - l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes

View file

@ -29,6 +29,8 @@ known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"]
[secret_store] [secret_store]
backend_policy = "auto" backend_policy = "auto"
# Optionnel : mettre false pour désactiver le cache Bitwarden.
bitwarden_cache = true
[profiles] [profiles]
default = "prod" default = "prod"
@ -36,6 +38,26 @@ known = ["dev", "staging", "prod"]
[bootstrap] [bootstrap]
description = "Client MCP interne" description = "Client MCP interne"
[[config.fields]]
name = "base_url"
flag = "base-url"
env = "MY_MCP_URL"
config_key = "base_url"
type = "url"
label = "Base URL"
required = true
sources = ["flag", "env", "config"]
[[config.fields]]
name = "api_token"
flag = "api-token"
env = "MY_MCP_TOKEN"
secret_key_template = "profile/{profile}/api-token"
type = "secret"
label = "API token"
required = true
sources = ["flag", "env", "secret"]
``` ```
Champs supportés : Champs supportés :
@ -60,9 +82,25 @@ Champs supportés :
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. - `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
- `[environment].known` : variables d'environnement connues du projet. - `[environment].known` : variables d'environnement connues du projet.
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`). - `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`).
- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire et disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver.
- `[profiles].default` : profil recommandé par défaut. - `[profiles].default` : profil recommandé par défaut.
- `[profiles].known` : profils connus du projet. - `[profiles].known` : profils connus du projet.
- `[bootstrap].description` : description CLI utilisée par le bootstrap. - `[bootstrap].description` : description CLI utilisée par le bootstrap.
- `[[config.fields]]` : champs de configuration déclaratifs consommés par
`mcp-framework generate`.
- `name` : identifiant stable du champ.
- `flag` : nom du flag CLI, sans `--`.
- `env` : variable d'environnement associée.
- `config_key` : clé dans la config fichier du projet.
- `secret_key_template` : clé de secret, avec `{profile}` remplacé par le
profil courant dans le code généré.
- `type` : type de setup (`string`, `url`, `secret`, `bool`, `list`).
- `label` : libellé humain utilisé pendant le setup.
- `default` : valeur par défaut optionnelle.
- `required` : si `true`, la résolution échoue quand aucune source ne fournit
de valeur.
- `sources` : ordre de résolution spécifique au champ (`flag`, `env`,
`config`, `secret`).
Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles. Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles.

View file

@ -1,38 +1,63 @@
# Exemple minimal # Exemple minimal
Cet exemple suppose qu'un `mcp.toml` existe à la racine du module et que le
package généré est à jour :
```bash
mcp-framework generate
```
Exemple de runner Go :
```go ```go
package main
import (
"context"
"fmt"
"os"
"example.com/my-mcp/mcpgen"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/update"
)
var version = "dev"
type Profile struct { type Profile struct {
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
} }
var embeddedManifest = `...` // fallback utilisé si aucun mcp.toml runtime n'est trouvé func main() {
if err := run(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(ctx context.Context, flagProfile string) error { func run(ctx context.Context) error {
cfgStore := config.NewStore[Profile]("my-mcp") cfgStore := config.NewStore[Profile](mcpgen.BinaryName)
cfg, _, err := cfgStore.LoadDefault() cfg, _, err := cfgStore.LoadDefault()
if err != nil { if err != nil {
return err return err
} }
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile) _, source, err := mcpgen.LoadManifest(".")
profile := cfg.Profiles[profileName]
manifestFile, _, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest)
if err != nil { if err != nil {
return err return err
} }
fmt.Println("manifest:", source)
err = update.Run(ctx, update.Options{ updateOptions, err := mcpgen.UpdateOptions(version, os.Stdout)
CurrentVersion: version,
BinaryName: "my-mcp",
ReleaseSource: manifestFile.Update.ReleaseSource(),
})
if err != nil { if err != nil {
return err return err
} }
if err := update.Run(ctx, updateOptions); err != nil {
return err
}
_ = profile _ = cfg
return nil return nil
} }
``` ```

View file

@ -4,6 +4,7 @@
- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`. - `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`.
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
- `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
- `generate` : génération de code Go depuis `mcp.toml` (`mcpgen/manifest.go`, metadata, update, secret store, config fields).
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage). - `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage).
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`. - `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`.
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release. - `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.

View file

@ -3,7 +3,7 @@
Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP : Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP :
- arborescence recommandée (`cmd/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`) - arborescence recommandée (`cmd/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`)
- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP) - script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI, setup local et export JSON MCP
- wiring initial `bootstrap + config + secretstore + update` - wiring initial `bootstrap + config + secretstore + update`
- `README.md` de démarrage - `README.md` de démarrage
@ -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

@ -56,7 +56,7 @@ Pour imposer KWallet sur Linux :
```go ```go
store, err := secretstore.Open(secretstore.Options{ store, err := secretstore.Open(secretstore.Options{
ServiceName: "email-mcp", ServiceName: "my-mcp",
BackendPolicy: secretstore.BackendKWalletOnly, BackendPolicy: secretstore.BackendKWalletOnly,
}) })
``` ```
@ -65,13 +65,41 @@ Pour imposer Bitwarden via son CLI :
```go ```go
store, err := secretstore.Open(secretstore.Options{ store, err := secretstore.Open(secretstore.Options{
ServiceName: "email-mcp", ServiceName: "my-mcp",
BackendPolicy: secretstore.BackendBitwardenCLI, BackendPolicy: secretstore.BackendBitwardenCLI,
// Optionnel si `bw` n'est pas dans le PATH : // Optionnel si `bw` n'est pas dans le PATH :
// BitwardenCommand: "/usr/local/bin/bw", // BitwardenCommand: "/usr/local/bin/bw",
}) })
``` ```
## Cache Bitwarden
Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut.
Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache
disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et
AES-GCM.
TTL par défaut : 10 minutes.
Pour désactiver le cache dans `mcp.toml` :
```toml
[secret_store]
backend_policy = "bitwarden-cli"
bitwarden_cache = false
```
Pour le désactiver sans modifier le manifeste :
```bash
MCP_FRAMEWORK_BITWARDEN_CACHE=0
```
Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les
secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes
deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut
lire l'environnement ou la mémoire du process pendant l'exécution.
Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) : Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) :
```go ```go
@ -96,7 +124,7 @@ et le persister localement (fichier `0600` sous le répertoire de config utilisa
```go ```go
session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
ServiceName: "email-mcp", ServiceName: "my-mcp",
Stdin: os.Stdin, Stdin: os.Stdin,
Stdout: os.Stdout, Stdout: os.Stdout,
Stderr: os.Stderr, Stderr: os.Stderr,
@ -111,7 +139,7 @@ Pour réinjecter automatiquement une session persistée dans l'environnement cou
```go ```go
loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{
ServiceName: "email-mcp", ServiceName: "my-mcp",
}) })
if err != nil { if err != nil {
return err return err
@ -159,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{
@ -174,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{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,217 @@
# Bitwarden Cache Design
Date: 2026-05-02
## Context
The `secretstore` package supports a `bitwarden-cli` backend. Each secret read can call the Bitwarden CLI several times:
- `bw list items --search <service>/<secret>`
- `bw get item <id>` for each candidate item
These calls can take several seconds when commands such as MCP `setup`, `doctor`, or generated config resolution read multiple secrets or are run repeatedly.
The framework already requires an unlocked Bitwarden session for this backend and restores a persisted `BW_SESSION` into the process environment when available. The cache must use that runtime session as the trust root without embedding a static key in the binary or repository.
## Goals
- Avoid repeated Bitwarden CLI calls for short-lived and long-running framework processes.
- Keep the feature portable across Windows, macOS, Linux, and WSL.
- Enable the encrypted disk cache by default when `BW_SESSION` is available.
- Allow projects and operators to disable the cache.
- Never make cached secrets decryptable with only the installed binary, cache files, and repository content.
## Non-Goals
- Protect against an attacker who can read the process memory or environment while the process is running.
- Add an OS keyring dependency for the first implementation.
- Cache non-Bitwarden secret backends.
- Persist decrypted secrets on disk.
## Configuration
`manifest.SecretStore` gains:
```toml
[secret_store]
backend_policy = "bitwarden-cli"
bitwarden_cache = true
```
`bitwarden_cache` defaults to `true` when omitted.
The generated helper options continue to flow through `OpenFromManifest` and `DescribeRuntime`. The runtime option is represented in `secretstore.Options` and `secretstore.OpenFromManifestOptions` so generated packages can still override it programmatically if needed.
An environment variable can force-disable the cache without editing `mcp.toml`:
```text
MCP_FRAMEWORK_BITWARDEN_CACHE=0
```
Accepted false values are `0`, `false`, `no`, `off`, and `disabled`, case-insensitive. Any other value leaves the manifest/default behavior in place.
## Architecture
The cache is internal to the `bitwarden-cli` backend.
`bitwardenStore.GetSecret(name)` resolves secrets in this order:
1. In-memory cache.
2. Encrypted disk cache, only if a cache key can be derived from the effective `BW_SESSION`.
3. Bitwarden CLI lookup.
After a successful CLI lookup, the secret is written to the memory cache and, when available, the encrypted disk cache.
`SetSecret` and `DeleteSecret` update Bitwarden first. After success, they invalidate the cache entry for the affected secret. `SetSecret` may repopulate the memory and disk cache with the new value after the Bitwarden write succeeds, but it must not expose a value that failed to persist to Bitwarden.
## Cache Contents
Memory entries store:
- secret value
- expiration timestamp
Disk entries store encrypted JSON:
```json
{
"version": 1,
"service_name": "graylog-mcp",
"secret_name": "profile/prod/api-token",
"scoped_name": "graylog-mcp/profile/prod/api-token",
"created_at": "2026-05-02T10:00:00Z",
"expires_at": "2026-05-02T10:10:00Z",
"value": "secret"
}
```
The plaintext exists only before encryption and after decryption in memory.
## Key Derivation
Disk cache is enabled only when the effective `BW_SESSION` is non-empty. The effective session is the value available after `EnsureBitwardenSessionEnv`, so it can come from either the process environment or the framework-restored session file.
The cache derives dedicated keys with HKDF-SHA256:
```text
master_key = HKDF-SHA256(
input key material: BW_SESSION,
salt: "mcp-framework bitwarden cache salt v1",
info: "mcp-framework bitwarden cache v1"
)
```
Separate subkeys are derived from `master_key`:
- encryption key: `info = "mcp-framework bitwarden cache encryption v1"`
- entry ID key: `info = "mcp-framework bitwarden cache entry id v1"`
`BW_SESSION` is never used directly as an AES key.
## Disk Encryption
Disk entries use AES-256-GCM from the Go standard library.
Each write generates a fresh random nonce with `crypto/rand`. The file format is JSON with metadata needed to decrypt:
```json
{
"version": 1,
"algorithm": "AES-256-GCM",
"nonce": "<base64>",
"ciphertext": "<base64>"
}
```
Authenticated additional data includes stable, non-secret cache context:
```text
mcp-framework bitwarden cache v1
service=<serviceName>
secret=<secretName>
scoped=<serviceName>/<secretName>
```
If decryption fails, the entry is treated as a cache miss. The framework may remove the unusable file as best effort.
## Entry Identity
Disk file names do not expose secret names or Bitwarden item refs. The file name is:
```text
hex(HMAC-SHA256(entry_id_key, cache_context)) + ".json"
```
The cache context includes:
- cache format version
- service name
- raw secret name
- scoped Bitwarden item name
- backend scope marker
Because the HMAC key is derived from `BW_SESSION`, changing or losing `BW_SESSION` makes existing file names undiscoverable and existing entries undecryptable.
## TTL and Invalidation
Default TTL: 10 minutes.
TTL applies to both memory and disk entries. Expired entries are treated as misses and may be deleted best-effort.
The first implementation uses a constant default TTL. It keeps room for a future `bitwarden_cache_ttl` option, but does not add that option now to keep scope tight.
Invalidation rules:
- `SetSecret` invalidates the affected entry after the Bitwarden write succeeds.
- `DeleteSecret` invalidates the affected entry after the Bitwarden delete succeeds or confirms the item is already absent.
- `BW_SESSION` change implicitly invalidates disk cache through key derivation.
- `bitwarden_cache = false` disables both memory and disk cache for the backend instance.
- `MCP_FRAMEWORK_BITWARDEN_CACHE=0` disables both memory and disk cache.
## Storage Location and Permissions
Disk cache path:
```text
os.UserCacheDir()/serviceName/bitwarden-cache
```
If `os.UserCacheDir` fails, disk cache is disabled and secret reads continue through memory cache and Bitwarden CLI.
The cache directory is created with `0700` and cache files with `0600` where the platform supports Unix-style permissions. Permission setting errors disable disk cache for that operation rather than failing secret resolution, because Bitwarden remains the source of truth.
## Error Handling
Cache failures must not make a healthy Bitwarden backend unusable.
- Memory cache errors are not expected.
- Disk cache read/decrypt/parse errors are treated as misses.
- Disk cache write errors are ignored after optional debug logging.
- Bitwarden CLI errors keep their current behavior and typed error classification.
- Malformed or expired cache entries are never returned.
## Tests
Unit tests should cover:
- repeated `GetSecret` hits memory and avoids a second CLI item read
- reopened store can read from encrypted disk cache without calling `bw get item`
- disk cache file does not contain the secret value or clear secret name
- cache is disabled by `bitwarden_cache = false`
- cache is disabled by `MCP_FRAMEWORK_BITWARDEN_CACHE=0`
- expired entries are missed and refreshed from Bitwarden
- `SetSecret` invalidates or refreshes stale cache data
- `DeleteSecret` removes cached data
- missing `BW_SESSION` disables disk cache
- changed `BW_SESSION` cannot decrypt a previous disk entry
- manifest parsing preserves the default enabled behavior when the field is omitted
## Documentation
Update:
- `docs/manifest.md` for `[secret_store].bitwarden_cache`
- `docs/secrets.md` for cache behavior, TTL, disable controls, and threat model
- generated/scaffolded `mcp.toml` examples only if the option should be visible by default
The preferred scaffold output should omit `bitwarden_cache` because the default is enabled. Documentation should show it as the explicit disable knob.

617
generate/generate.go Normal file
View file

@ -0,0 +1,617 @@
package generate
import (
"bytes"
"errors"
"fmt"
"go/format"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"forge.lclr.dev/AI/mcp-framework/manifest"
)
var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date")
type Options struct {
ProjectDir string
ManifestPath string
PackageDir string
PackageName string
Check bool
}
type Result struct {
Root string
Files []string
}
func Generate(options Options) (Result, error) {
normalized, err := normalizeOptions(options)
if err != nil {
return Result{}, err
}
manifestFile, err := manifest.Load(normalized.ManifestPath)
if err != nil {
return Result{}, err
}
manifestContent, err := os.ReadFile(normalized.ManifestPath)
if err != nil {
return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err)
}
manifestLoader, err := renderManifestLoader(normalized.PackageName, string(manifestContent))
if err != nil {
return Result{}, err
}
metadata, err := renderMetadata(normalized.PackageName, manifestFile)
if err != nil {
return Result{}, err
}
update, err := renderUpdate(normalized.PackageName)
if err != nil {
return Result{}, err
}
secretstore, err := renderSecretStore(normalized.PackageName)
if err != nil {
return Result{}, err
}
config, err := renderConfig(normalized.PackageName, manifestFile.Config.Fields)
if err != nil {
return Result{}, err
}
files := []generatedFile{
{
Path: filepath.Join(normalized.PackageDir, "manifest.go"),
Content: manifestLoader,
Mode: 0o644,
},
{
Path: filepath.Join(normalized.PackageDir, "metadata.go"),
Content: metadata,
Mode: 0o644,
},
{
Path: filepath.Join(normalized.PackageDir, "update.go"),
Content: update,
Mode: 0o644,
},
{
Path: filepath.Join(normalized.PackageDir, "secretstore.go"),
Content: secretstore,
Mode: 0o644,
},
}
if strings.TrimSpace(config) != "" {
files = append(files, generatedFile{
Path: filepath.Join(normalized.PackageDir, "config.go"),
Content: config,
Mode: 0o644,
})
}
written := make([]string, 0, len(files))
for _, file := range files {
target := filepath.Join(normalized.ProjectDir, file.Path)
if normalized.Check {
current, err := os.ReadFile(target)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
}
return Result{}, fmt.Errorf("read generated file %q: %w", target, err)
}
if !bytes.Equal(current, []byte(file.Content)) {
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
}
written = append(written, file.Path)
continue
}
if err := writeGeneratedFile(target, file.Content, file.Mode); err != nil {
return Result{}, err
}
written = append(written, file.Path)
}
sort.Strings(written)
return Result{
Root: normalized.ProjectDir,
Files: written,
}, nil
}
type normalizedOptions struct {
ProjectDir string
ManifestPath string
PackageDir string
PackageName string
Check bool
}
type generatedFile struct {
Path string
Content string
Mode os.FileMode
}
func normalizeOptions(options Options) (normalizedOptions, error) {
manifestPath := strings.TrimSpace(options.ManifestPath)
projectDir := strings.TrimSpace(options.ProjectDir)
if manifestPath == "" {
baseDir := projectDir
if baseDir == "" {
wd, err := os.Getwd()
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
}
baseDir = wd
}
manifestPath = filepath.Join(baseDir, manifest.DefaultFile)
} else if !filepath.IsAbs(manifestPath) {
baseDir := projectDir
if baseDir == "" {
wd, err := os.Getwd()
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
}
baseDir = wd
}
manifestPath = filepath.Join(baseDir, manifestPath)
}
resolvedManifest, err := filepath.Abs(manifestPath)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve manifest path %q: %w", manifestPath, err)
}
if projectDir == "" {
projectDir = filepath.Dir(resolvedManifest)
}
resolvedProjectDir, err := filepath.Abs(projectDir)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve project dir %q: %w", projectDir, err)
}
packageDir := filepath.Clean(strings.TrimSpace(options.PackageDir))
if packageDir == "." || packageDir == "" {
packageDir = "mcpgen"
}
if filepath.IsAbs(packageDir) || packageDir == ".." || strings.HasPrefix(packageDir, ".."+string(filepath.Separator)) {
return normalizedOptions{}, fmt.Errorf("package dir %q must be relative to the project", options.PackageDir)
}
packageName := strings.TrimSpace(options.PackageName)
if packageName == "" {
packageName = filepath.Base(packageDir)
}
if !token.IsIdentifier(packageName) {
return normalizedOptions{}, fmt.Errorf("package name %q is not a valid Go identifier", packageName)
}
return normalizedOptions{
ProjectDir: resolvedProjectDir,
ManifestPath: resolvedManifest,
PackageDir: packageDir,
PackageName: packageName,
Check: options.Check,
}, nil
}
func renderManifestLoader(packageName, manifestContent string) (string, error) {
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
const embeddedManifest = %s
func LoadManifest(startDir string) (fwmanifest.File, string, error) {
return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)
}
`, packageName, strconv.Quote(manifestContent))
formatted, err := format.Source([]byte(source))
if err != nil {
return "", fmt.Errorf("format generated manifest loader: %w", err)
}
return string(formatted), nil
}
func renderMetadata(packageName string, manifestFile manifest.File) (string, error) {
bootstrapInfo := manifestFile.BootstrapInfo()
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
const BinaryName = %s
const DefaultDescription = %s
const DocsURL = %s
func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {
manifestFile, source, err := LoadManifest(startDir)
if err != nil {
return fwmanifest.BootstrapMetadata{}, "", err
}
return manifestFile.BootstrapInfo(), source, nil
}
func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {
manifestFile, source, err := LoadManifest(startDir)
if err != nil {
return fwmanifest.ScaffoldMetadata{}, "", err
}
return manifestFile.ScaffoldInfo(), source, nil
}
`, packageName, strconv.Quote(manifestFile.BinaryName), strconv.Quote(bootstrapInfo.Description), strconv.Quote(manifestFile.DocsURL))
return formatGenerated("metadata", source)
}
func renderUpdate(packageName string) (string, error) {
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import (
"context"
"flag"
"fmt"
"io"
"strings"
fwupdate "forge.lclr.dev/AI/mcp-framework/update"
)
func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {
return UpdateOptionsFrom(".", version, stdout)
}
func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {
manifestFile, _, err := LoadManifest(startDir)
if err != nil {
return fwupdate.Options{}, err
}
binaryName := strings.TrimSpace(manifestFile.BinaryName)
if binaryName == "" {
binaryName = BinaryName
}
return fwupdate.Options{
CurrentVersion: version,
Stdout: stdout,
BinaryName: binaryName,
ReleaseSource: manifestFile.Update.ReleaseSource(),
}, nil
}
func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {
return RunUpdateFrom(ctx, args, ".", version, stdout)
}
func RunUpdateFrom(ctx context.Context, args []string, startDir string, version string, stdout io.Writer) error {
fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return fmt.Errorf("update does not accept positional arguments: %%s", strings.Join(fs.Args(), ", "))
}
options, err := UpdateOptionsFrom(startDir, version, stdout)
if err != nil {
return err
}
return fwupdate.Run(ctx, options)
}
`, packageName)
return formatGenerated("update", source)
}
func renderSecretStore(packageName string) (string, error) {
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import (
"os"
"path/filepath"
"strings"
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
)
type SecretStoreOptions struct {
ServiceName string
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
Shell string
ExecutableResolver fwsecretstore.ExecutableResolver
}
func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {
return fwsecretstore.OpenFromManifest(secretStoreOpenOptions(options))
}
func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {
return fwsecretstore.DescribeRuntime(secretStoreDescribeOptions(options))
}
func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {
return fwsecretstore.PreflightFromManifest(secretStoreDescribeOptions(options))
}
func secretStoreOpenOptions(options SecretStoreOptions) fwsecretstore.OpenFromManifestOptions {
return fwsecretstore.OpenFromManifestOptions{
ServiceName: secretStoreServiceName(options),
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
DisableBitwardenCache: options.DisableBitwardenCache,
Shell: options.Shell,
ManifestLoader: LoadManifest,
ExecutableResolver: options.ExecutableResolver,
}
}
func secretStoreDescribeOptions(options SecretStoreOptions) fwsecretstore.DescribeRuntimeOptions {
return fwsecretstore.DescribeRuntimeOptions{
ServiceName: secretStoreServiceName(options),
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
DisableBitwardenCache: options.DisableBitwardenCache,
Shell: options.Shell,
ManifestLoader: LoadManifest,
ExecutableResolver: options.ExecutableResolver,
}
}
func secretStoreServiceName(options SecretStoreOptions) string {
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName != "" {
return serviceName
}
startDir := "."
executableResolver := options.ExecutableResolver
if executableResolver == nil {
executableResolver = os.Executable
}
if executablePath, err := executableResolver(); err == nil {
if dir := strings.TrimSpace(filepath.Dir(strings.TrimSpace(executablePath))); dir != "" {
startDir = dir
}
}
if manifestFile, _, err := LoadManifest(startDir); err == nil {
if binaryName := strings.TrimSpace(manifestFile.BinaryName); binaryName != "" {
return binaryName
}
}
return BinaryName
}
`, packageName)
return formatGenerated("secretstore", source)
}
func renderConfig(packageName string, fields []manifest.ConfigField) (string, error) {
if len(fields) == 0 {
return "", nil
}
var flagsBuilder strings.Builder
var specsBuilder strings.Builder
var setupBuilder strings.Builder
for _, field := range fields {
name := strings.TrimSpace(field.Name)
if name == "" {
return "", fmt.Errorf("generate config field: name must not be empty")
}
flagName := strings.TrimSpace(field.Flag)
if flagName != "" {
fmt.Fprintf(
&flagsBuilder,
"\tflags.values[%s] = fs.String(%s, \"\", %s)\n",
strconv.Quote(name),
strconv.Quote(flagName),
strconv.Quote(configFieldLabel(field)),
)
}
fmt.Fprintf(
&specsBuilder,
"\t\t{Name: %s, Required: %t, DefaultValue: %s, Sources: []fwcli.ValueSource{%s}, FlagKey: %s, EnvKey: %s, ConfigKey: %s, SecretKey: replaceProfile(%s, profile)},\n",
strconv.Quote(name),
field.Required,
strconv.Quote(field.Default),
configSourceList(field.Sources),
strconv.Quote(flagName),
strconv.Quote(field.Env),
strconv.Quote(field.ConfigKey),
strconv.Quote(field.SecretKeyTemplate),
)
fmt.Fprintf(
&setupBuilder,
"\t\t{Name: %s, Label: %s, Type: %s, Required: %t, Default: %s, ExistingSecret: existing[%s]},\n",
strconv.Quote(name),
strconv.Quote(configFieldLabel(field)),
configSetupFieldType(field.Type),
field.Required,
strconv.Quote(field.Default),
strconv.Quote(name),
)
}
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import (
"flag"
"strings"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
)
type ConfigFlags struct {
values map[string]*string
}
func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {
if fs == nil {
fs = flag.CommandLine
}
flags := ConfigFlags{
values: make(map[string]*string),
}
%s
return flags
}
func ConfigFlagValues(flags ConfigFlags) map[string]string {
values := make(map[string]string)
for name, value := range flags.values {
if value == nil {
continue
}
if trimmed := strings.TrimSpace(*value); trimmed != "" {
values[name] = trimmed
}
}
return values
}
func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {
return []fwcli.FieldSpec{
%s
}
}
func SetupFields(existing map[string]string) []fwcli.SetupField {
if existing == nil {
existing = map[string]string{}
}
return []fwcli.SetupField{
%s
}
}
func replaceProfile(value, profile string) string {
return strings.ReplaceAll(value, "{profile}", strings.TrimSpace(profile))
}
`, packageName, flagsBuilder.String(), specsBuilder.String(), setupBuilder.String())
return formatGenerated("config", source)
}
func configFieldLabel(field manifest.ConfigField) string {
if label := strings.TrimSpace(field.Label); label != "" {
return label
}
return strings.TrimSpace(field.Name)
}
func configSourceList(sources []string) string {
if len(sources) == 0 {
return ""
}
parts := make([]string, 0, len(sources))
for _, source := range sources {
switch strings.TrimSpace(source) {
case "flag":
parts = append(parts, "fwcli.SourceFlag")
case "env":
parts = append(parts, "fwcli.SourceEnv")
case "config":
parts = append(parts, "fwcli.SourceConfig")
case "secret":
parts = append(parts, "fwcli.SourceSecret")
}
}
return strings.Join(parts, ", ")
}
func configSetupFieldType(fieldType string) string {
switch strings.TrimSpace(fieldType) {
case "url":
return "fwcli.SetupFieldURL"
case "secret":
return "fwcli.SetupFieldSecret"
case "bool":
return "fwcli.SetupFieldBool"
case "list":
return "fwcli.SetupFieldList"
default:
return "fwcli.SetupFieldString"
}
}
func formatGenerated(name, source string) (string, error) {
formatted, err := format.Source([]byte(source))
if err != nil {
return "", fmt.Errorf("format generated %s: %w", name, err)
}
return string(formatted), nil
}
func writeGeneratedFile(path, content string, mode os.FileMode) error {
current, err := os.ReadFile(path)
if err == nil && bytes.Equal(current, []byte(content)) {
return nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read generated file %q: %w", path, err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create generated directory %q: %w", dir, err)
}
if mode == 0 {
mode = 0o644
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return fmt.Errorf("write generated file %q: %w", path, err)
}
return nil
}

499
generate/generate_test.go Normal file
View file

@ -0,0 +1,499 @@
package generate
import (
"errors"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestGenerateCreatesManifestLoader(t *testing.T) {
projectDir := newProject(t, `
binary_name = "demo-mcp"
docs_url = "https://docs.example.com/demo"
[bootstrap]
description = "Demo MCP"
`)
result, err := Generate(Options{ProjectDir: projectDir})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !slices.Equal(result.Files, defaultGeneratedFiles("mcpgen")) {
t.Fatalf("result files = %v", result.Files)
}
generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go")
content, err := os.ReadFile(generatedPath)
if err != nil {
t.Fatalf("ReadFile generated manifest: %v", err)
}
for _, snippet := range []string{
"// Code generated by mcp-framework generate. DO NOT EDIT.",
"package mcpgen",
"import fwmanifest \"forge.lclr.dev/AI/mcp-framework/manifest\"",
"const embeddedManifest = ",
"func LoadManifest(startDir string) (fwmanifest.File, string, error) {",
"return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)",
`binary_name = \"demo-mcp\"`,
} {
if !strings.Contains(string(content), snippet) {
t.Fatalf("generated manifest.go missing snippet %q:\n%s", snippet, content)
}
}
}
func TestGenerateCreatesP1Helpers(t *testing.T) {
projectDir := newProject(t, `
binary_name = "demo-mcp"
docs_url = "https://docs.example.com/demo"
[update]
driver = "gitea"
repository = "org/demo-mcp"
base_url = "https://gitea.example.com"
asset_name_template = "{binary}-{os}-{arch}{ext}"
[secret_store]
backend_policy = "env-only"
[bootstrap]
description = "Demo MCP"
`)
result, err := Generate(Options{ProjectDir: projectDir})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
wantFiles := []string{
filepath.Join("mcpgen", "manifest.go"),
filepath.Join("mcpgen", "metadata.go"),
filepath.Join("mcpgen", "secretstore.go"),
filepath.Join("mcpgen", "update.go"),
}
if !slices.Equal(result.Files, wantFiles) {
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
}
metadata, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "metadata.go"))
if err != nil {
t.Fatalf("ReadFile metadata.go: %v", err)
}
for _, snippet := range []string{
`const BinaryName = "demo-mcp"`,
`const DefaultDescription = "Demo MCP"`,
`const DocsURL = "https://docs.example.com/demo"`,
"func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {",
"func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {",
} {
if !strings.Contains(string(metadata), snippet) {
t.Fatalf("metadata.go missing snippet %q:\n%s", snippet, metadata)
}
}
update, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "update.go"))
if err != nil {
t.Fatalf("ReadFile update.go: %v", err)
}
for _, snippet := range []string{
"func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {",
"func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {",
"func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {",
"ReleaseSource:",
} {
if !strings.Contains(string(update), snippet) {
t.Fatalf("update.go missing snippet %q:\n%s", snippet, update)
}
}
secretstore, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go"))
if err != nil {
t.Fatalf("ReadFile secretstore.go: %v", err)
}
for _, snippet := range []string{
"type SecretStoreOptions struct {",
"func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {",
"func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {",
"func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {",
"ManifestLoader:",
} {
if !strings.Contains(string(secretstore), snippet) {
t.Fatalf("secretstore.go missing snippet %q:\n%s", snippet, secretstore)
}
}
}
func TestGenerateCreatesConfigHelpersFromManifestFields(t *testing.T) {
projectDir := newProject(t, `
binary_name = "demo-mcp"
[[config.fields]]
name = "base_url"
flag = "base-url"
env = "BASE_URL"
config_key = "base_url"
type = "url"
label = "Graylog URL"
required = true
sources = ["flag", "env", "config"]
[[config.fields]]
name = "api_token"
flag = "api-token"
env = "API_TOKEN"
secret_key_template = "profile/{profile}/api-token"
type = "secret"
label = "API token"
required = true
sources = ["flag", "env", "secret"]
`)
result, err := Generate(Options{ProjectDir: projectDir})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
wantFiles := generatedFilesWithConfig("mcpgen")
if !slices.Equal(result.Files, wantFiles) {
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
}
config, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "config.go"))
if err != nil {
t.Fatalf("ReadFile config.go: %v", err)
}
for _, snippet := range []string{
"type ConfigFlags struct {",
"func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {",
"func ConfigFlagValues(flags ConfigFlags) map[string]string {",
"func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {",
"func SetupFields(existing map[string]string) []fwcli.SetupField {",
`fs.String("base-url", "", "Graylog URL")`,
`SecretKey: replaceProfile("profile/{profile}/api-token", profile)`,
"fwcli.SetupFieldURL",
"fwcli.SetupFieldSecret",
} {
if !strings.Contains(string(config), snippet) {
t.Fatalf("config.go missing snippet %q:\n%s", snippet, config)
}
}
}
func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) {
projectDir := newProject(t, `binary_name = "demo-mcp"`)
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
t.Fatalf("first Generate returned error: %v", err)
}
generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go")
first, err := os.ReadFile(generatedPath)
if err != nil {
t.Fatalf("ReadFile first generated file: %v", err)
}
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
t.Fatalf("second Generate returned error: %v", err)
}
second, err := os.ReadFile(generatedPath)
if err != nil {
t.Fatalf("ReadFile second generated file: %v", err)
}
if string(second) != string(first) {
t.Fatalf("second generation changed content")
}
if _, err := Generate(Options{ProjectDir: projectDir, Check: true}); err != nil {
t.Fatalf("check after generation returned error: %v", err)
}
if err := os.WriteFile(generatedPath, append(second, []byte("// drift\n")...), 0o600); err != nil {
t.Fatalf("WriteFile drift: %v", err)
}
_, err = Generate(Options{ProjectDir: projectDir, Check: true})
if !errors.Is(err, ErrGeneratedFilesOutdated) {
t.Fatalf("check error = %v, want ErrGeneratedFilesOutdated", err)
}
}
func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) {
projectDir := t.TempDir()
manifestPath := filepath.Join(projectDir, "config", "custom.toml")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll manifest dir: %v", err)
}
if err := os.WriteFile(manifestPath, []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
result, err := Generate(Options{
ProjectDir: projectDir,
ManifestPath: manifestPath,
PackageDir: "internal/generated",
PackageName: "generated",
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !slices.Equal(result.Files, defaultGeneratedFiles(filepath.Join("internal", "generated"))) {
t.Fatalf("result files = %v", result.Files)
}
content, err := os.ReadFile(filepath.Join(projectDir, "internal", "generated", "manifest.go"))
if err != nil {
t.Fatalf("ReadFile generated manifest: %v", err)
}
if !strings.Contains(string(content), "package generated") {
t.Fatalf("generated file should use package name: %s", content)
}
}
func TestGenerateRejectsInvalidManifest(t *testing.T) {
projectDir := newProject(t, "[bootstrap\n")
_, err := Generate(Options{ProjectDir: projectDir})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "parse manifest") {
t.Fatalf("error = %v", err)
}
}
func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) {
projectDir := newProject(t, `
binary_name = "embedded-demo"
docs_url = "https://docs.example.com/embedded"
[update]
driver = "gitea"
repository = "org/embedded-demo"
base_url = "https://gitea.example.com"
[secret_store]
backend_policy = "env-only"
[bootstrap]
description = "Embedded Demo"
[[config.fields]]
name = "base_url"
flag = "base-url"
env = "BASE_URL"
config_key = "base_url"
type = "url"
label = "Base URL"
required = true
sources = ["flag", "env", "config"]
[[config.fields]]
name = "api_token"
flag = "api-token"
env = "API_TOKEN"
secret_key_template = "profile/{profile}/api-token"
type = "secret"
label = "API token"
required = true
sources = ["flag", "env", "secret"]
`)
writeModule(t, projectDir)
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if err := os.Remove(filepath.Join(projectDir, "mcp.toml")); err != nil {
t.Fatalf("Remove runtime manifest: %v", err)
}
cmd := exec.Command("go", "test", "-mod=mod", "./...")
cmd.Dir = projectDir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go test generated project: %v\n%s", err, output)
}
}
func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) {
projectDir := newProject(t, `
binary_name = "demo-mcp"
[secret_store]
backend_policy = "bitwarden-cli"
`)
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
t.Fatalf("Generate returned error: %v", err)
}
content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go"))
if err != nil {
t.Fatalf("ReadFile generated secretstore: %v", err)
}
text := string(content)
for _, snippet := range []string{
"DisableBitwardenCache bool",
"DisableBitwardenCache: options.DisableBitwardenCache,",
} {
if !strings.Contains(text, snippet) {
t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text)
}
}
}
func newProject(t *testing.T, manifest string) string {
t.Helper()
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(manifest), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
return projectDir
}
func defaultGeneratedFiles(packageDir string) []string {
return []string{
filepath.Join(packageDir, "manifest.go"),
filepath.Join(packageDir, "metadata.go"),
filepath.Join(packageDir, "secretstore.go"),
filepath.Join(packageDir, "update.go"),
}
}
func generatedFilesWithConfig(packageDir string) []string {
return []string{
filepath.Join(packageDir, "config.go"),
filepath.Join(packageDir, "manifest.go"),
filepath.Join(packageDir, "metadata.go"),
filepath.Join(packageDir, "secretstore.go"),
filepath.Join(packageDir, "update.go"),
}
}
func writeModule(t *testing.T, projectDir string) {
t.Helper()
repoRoot, err := filepath.Abs("..")
if err != nil {
t.Fatalf("Abs repo root: %v", err)
}
goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tforge.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace forge.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n"
if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil {
t.Fatalf("WriteFile go.mod: %v", err)
}
goSum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum"))
if err != nil {
t.Fatalf("ReadFile go.sum: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "go.sum"), goSum, 0o600); err != nil {
t.Fatalf("WriteFile go.sum: %v", err)
}
testFile := `package main
import (
"flag"
"io"
"testing"
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
fwsecretstore "forge.lclr.dev/AI/mcp-framework/secretstore"
"example.com/generated-demo/mcpgen"
fwmanifest "forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {
file, source, err := mcpgen.LoadManifest(".")
if err != nil {
t.Fatalf("LoadManifest returned error: %v", err)
}
if source != fwmanifest.EmbeddedSource {
t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource)
}
if file.BinaryName != "embedded-demo" {
t.Fatalf("binary name = %q", file.BinaryName)
}
info, source, err := mcpgen.BootstrapInfo(".")
if err != nil {
t.Fatalf("BootstrapInfo returned error: %v", err)
}
if source != fwmanifest.EmbeddedSource {
t.Fatalf("bootstrap source = %q, want %q", source, fwmanifest.EmbeddedSource)
}
if info.Description != "Embedded Demo" {
t.Fatalf("description = %q", info.Description)
}
updateOptions, err := mcpgen.UpdateOptions("1.2.3", io.Discard)
if err != nil {
t.Fatalf("UpdateOptions returned error: %v", err)
}
if updateOptions.CurrentVersion != "1.2.3" {
t.Fatalf("current version = %q", updateOptions.CurrentVersion)
}
if updateOptions.BinaryName != "embedded-demo" {
t.Fatalf("update binary name = %q", updateOptions.BinaryName)
}
if updateOptions.ReleaseSource.Repository != "org/embedded-demo" {
t.Fatalf("release repository = %q", updateOptions.ReleaseSource.Repository)
}
store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
LookupEnv: func(name string) (string, bool) {
return "secret-from-env", true
},
})
if err != nil {
t.Fatalf("OpenSecretStore returned error: %v", err)
}
value, err := store.GetSecret("profile/default/api-token")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "secret-from-env" {
t.Fatalf("secret value = %q", value)
}
if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly {
t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store))
}
flags := mcpgen.AddConfigFlags(flag.NewFlagSet("test", flag.ContinueOnError))
if len(mcpgen.ConfigFlagValues(flags)) != 0 {
t.Fatalf("empty flags should not return values")
}
specs := mcpgen.ResolveFieldSpecs("default")
if len(specs) != 2 {
t.Fatalf("field specs = %d, want 2", len(specs))
}
if specs[1].SecretKey != "profile/default/api-token" {
t.Fatalf("secret key = %q", specs[1].SecretKey)
}
setupFields := mcpgen.SetupFields(map[string]string{"api_token": "stored"})
if len(setupFields) != 2 {
t.Fatalf("setup fields = %d, want 2", len(setupFields))
}
if setupFields[0].Type != fwcli.SetupFieldURL {
t.Fatalf("first setup field type = %q", setupFields[0].Type)
}
if setupFields[1].ExistingSecret != "stored" {
t.Fatalf("existing secret = %q", setupFields[1].ExistingSecret)
}
}
`
if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {
t.Fatalf("WriteFile main_test.go: %v", err)
}
}

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"
@ -23,6 +23,7 @@ type File struct {
SecretStore SecretStore `toml:"secret_store"` SecretStore SecretStore `toml:"secret_store"`
Profiles Profiles `toml:"profiles"` Profiles Profiles `toml:"profiles"`
Bootstrap Bootstrap `toml:"bootstrap"` Bootstrap Bootstrap `toml:"bootstrap"`
Config Config `toml:"config"`
} }
type Update struct { type Update struct {
@ -48,7 +49,8 @@ type Environment struct {
} }
type SecretStore struct { type SecretStore struct {
BackendPolicy string `toml:"backend_policy"` BackendPolicy string `toml:"backend_policy"`
BitwardenCache *bool `toml:"bitwarden_cache"`
} }
type Profiles struct { type Profiles struct {
@ -60,6 +62,23 @@ type Bootstrap struct {
Description string `toml:"description"` Description string `toml:"description"`
} }
type Config struct {
Fields []ConfigField `toml:"fields"`
}
type ConfigField struct {
Name string `toml:"name"`
Flag string `toml:"flag"`
Env string `toml:"env"`
ConfigKey string `toml:"config_key"`
SecretKeyTemplate string `toml:"secret_key_template"`
Type string `toml:"type"`
Label string `toml:"label"`
Default string `toml:"default"`
Required bool `toml:"required"`
Sources []string `toml:"sources"`
}
type BootstrapMetadata struct { type BootstrapMetadata struct {
BinaryName string BinaryName string
Description string Description string
@ -180,6 +199,7 @@ func (f *File) normalize() {
f.SecretStore.normalize() f.SecretStore.normalize()
f.Profiles.normalize() f.Profiles.normalize()
f.Bootstrap.normalize() f.Bootstrap.normalize()
f.Config.normalize()
} }
func (u *Update) normalize() { func (u *Update) normalize() {
@ -215,6 +235,24 @@ func (b *Bootstrap) normalize() {
b.Description = strings.TrimSpace(b.Description) b.Description = strings.TrimSpace(b.Description)
} }
func (c *Config) normalize() {
for i := range c.Fields {
c.Fields[i].normalize()
}
}
func (f *ConfigField) normalize() {
f.Name = strings.TrimSpace(f.Name)
f.Flag = strings.TrimSpace(f.Flag)
f.Env = strings.TrimSpace(f.Env)
f.ConfigKey = strings.TrimSpace(f.ConfigKey)
f.SecretKeyTemplate = strings.TrimSpace(f.SecretKeyTemplate)
f.Type = strings.ToLower(strings.TrimSpace(f.Type))
f.Label = strings.TrimSpace(f.Label)
f.Default = strings.TrimSpace(f.Default)
f.Sources = normalizeStringList(f.Sources)
}
func (u Update) ReleaseSource() update.ReleaseSource { func (u Update) ReleaseSource() update.ReleaseSource {
u.normalize() u.normalize()

View file

@ -235,6 +235,127 @@ description = " Client MCP interne "
} }
} }
func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
[secret_store]
backend_policy = "bitwarden-cli"
bitwarden_cache = false
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if file.SecretStore.BitwardenCache == nil {
t.Fatal("bitwarden cache option is nil, want explicit false pointer")
}
if *file.SecretStore.BitwardenCache {
t.Fatal("bitwarden cache option = true, want false")
}
}
func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
[secret_store]
backend_policy = "bitwarden-cli"
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if file.SecretStore.BitwardenCache != nil {
t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache)
}
}
func TestLoadParsesConfigFields(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
[[config.fields]]
name = " base_url "
flag = "base-url"
env = "BASE_URL"
config_key = "base_url"
type = " url "
label = " Graylog URL "
required = true
sources = [" flag ", "env", "config"]
[[config.fields]]
name = "api_token"
flag = "api-token"
env = "API_TOKEN"
secret_key_template = "profile/{profile}/api-token"
type = "secret"
required = true
sources = ["flag", "env", "secret"]
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(file.Config.Fields) != 2 {
t.Fatalf("config fields = %d, want 2", len(file.Config.Fields))
}
baseURL := file.Config.Fields[0]
if baseURL.Name != "base_url" {
t.Fatalf("base URL name = %q", baseURL.Name)
}
if baseURL.Flag != "base-url" {
t.Fatalf("base URL flag = %q", baseURL.Flag)
}
if baseURL.Env != "BASE_URL" {
t.Fatalf("base URL env = %q", baseURL.Env)
}
if baseURL.ConfigKey != "base_url" {
t.Fatalf("base URL config key = %q", baseURL.ConfigKey)
}
if baseURL.Type != "url" {
t.Fatalf("base URL type = %q", baseURL.Type)
}
if baseURL.Label != "Graylog URL" {
t.Fatalf("base URL label = %q", baseURL.Label)
}
if !baseURL.Required {
t.Fatal("base URL should be required")
}
if !slices.Equal(baseURL.Sources, []string{"flag", "env", "config"}) {
t.Fatalf("base URL sources = %v", baseURL.Sources)
}
token := file.Config.Fields[1]
if token.SecretKeyTemplate != "profile/{profile}/api-token" {
t.Fatalf("token secret key template = %q", token.SecretKeyTemplate)
}
if !slices.Equal(token.Sources, []string{"flag", "env", "secret"}) {
t.Fatalf("token sources = %v", token.Sources)
}
}
func TestLoadEmbeddedParsesContent(t *testing.T) { func TestLoadEmbeddedParsesContent(t *testing.T) {
file, source, err := LoadEmbedded(` file, source, err := LoadEmbedded(`
[update] [update]

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}}"
@ -1596,6 +1596,11 @@ type Runtime struct {
SecretName string SecretName string
} }
const (
ansiRedColor = "\033[31m"
ansiResetColor = "\033[0m"
)
func Run(ctx context.Context, args []string, version string) error { func Run(ctx context.Context, args []string, version string) error {
runtime, err := NewRuntime(version) runtime, err := NewRuntime(version)
if err != nil { if err != nil {
@ -1917,9 +1922,7 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error
func (r Runtime) openSecretStore() (secretstore.Store, error) { func (r Runtime) openSecretStore() (secretstore.Store, error) {
policy := r.activeBackendPolicy() policy := r.activeBackendPolicy()
if policy == secretstore.BackendBitwardenCLI { if policy == secretstore.BackendBitwardenCLI {
if _, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ if err := r.ensureBitwardenSession(); err != nil {
ServiceName: r.BinaryName,
}); err != nil {
return nil, err return nil, err
} }
} }
@ -1936,6 +1939,37 @@ func (r Runtime) openSecretStore() (secretstore.Store, error) {
}) })
} }
func (r Runtime) ensureBitwardenSession() error {
if hasBitwardenSessionInEnv() {
return nil
}
loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{
ServiceName: r.BinaryName,
})
if err != nil {
return err
}
if loaded || hasBitwardenSessionInEnv() {
return nil
}
return errors.New(colorizeRed(fmt.Sprintf(
"Session Bitwarden introuvable. Lance %s login puis relance la commande.",
r.BinaryName,
)))
}
func hasBitwardenSessionInEnv() bool {
session, ok := os.LookupEnv("BW_SESSION")
return ok && strings.TrimSpace(session) != ""
}
func colorizeRed(message string) string {
return ansiRedColor + strings.TrimSpace(message) + ansiResetColor
}
func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy { func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy {
policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy)) policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy))
if policy == "" { if policy == "" {

View file

@ -68,6 +68,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
"config.NewStore[Profile]", "config.NewStore[Profile]",
"secretstore.Open(secretstore.Options", "secretstore.Open(secretstore.Options",
"secretstore.EnsureBitwardenSessionEnv", "secretstore.EnsureBitwardenSessionEnv",
"func (r Runtime) ensureBitwardenSession() error {",
"\\033[31m",
"secretstore.LoginBitwarden", "secretstore.LoginBitwarden",
"update.Run", "update.Run",
"manifest.LoadDefaultOrEmbedded", "manifest.LoadDefaultOrEmbedded",

View file

@ -50,6 +50,9 @@ type bitwardenStore struct {
command string command string
serviceName string serviceName string
debug bool debug bool
lookupEnv func(string) (string, bool)
shell string
cache *bitwardenCache
} }
type bitwardenListItem struct { type bitwardenListItem struct {
@ -68,12 +71,6 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
} }
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
store := &bitwardenStore{
command: command,
serviceName: serviceName,
debug: debugEnabled,
}
if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{
ServiceName: serviceName, ServiceName: serviceName,
}); err != nil { }); err != nil {
@ -85,41 +82,60 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
) )
} }
if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { session, _ := os.LookupEnv(bitwardenSessionEnvName)
if errors.Is(err, exec.ErrNotFound) { cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()
return nil, fmt.Errorf( store := &bitwardenStore{
"secret backend policy %q requires bitwarden CLI command %q in PATH: %w", command: command,
policy, serviceName: serviceName,
command, debug: debugEnabled,
ErrBackendUnavailable, lookupEnv: options.LookupEnv,
) shell: options.Shell,
} cache: newBitwardenCache(bitwardenCacheOptions{
ServiceName: serviceName,
return nil, fmt.Errorf( Session: session,
"secret backend policy %q cannot verify bitwarden CLI command %q: %w", TTL: defaultBitwardenCacheTTL,
policy, CacheDir: resolveBitwardenCacheDir(serviceName),
command, Enabled: cacheEnabled,
errors.Join(ErrBackendUnavailable, err), }),
)
}
if err := EnsureBitwardenReady(Options{
BitwardenCommand: command,
BitwardenDebug: debugEnabled,
LookupEnv: options.LookupEnv,
Shell: options.Shell,
}); err != nil {
return nil, fmt.Errorf(
"secret backend policy %q cannot use bitwarden CLI command %q right now: %w",
policy,
command,
errors.Join(ErrBackendUnavailable, err),
)
} }
return store, nil return store, nil
} }
func verifyBitwardenCLIReady(options Options) error {
command := strings.TrimSpace(options.BitwardenCommand)
if command == "" {
command = defaultBitwardenCommand
}
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return fmt.Errorf(
"requires bitwarden CLI command %q in PATH: %w",
command,
errors.Join(ErrBackendUnavailable, err),
)
}
return fmt.Errorf(
"cannot verify bitwarden CLI command %q: %w",
command,
errors.Join(ErrBackendUnavailable, err),
)
}
if err := EnsureBitwardenReady(options); err != nil {
return fmt.Errorf(
"cannot use bitwarden CLI command %q right now: %w",
command,
errors.Join(ErrBackendUnavailable, err),
)
}
return nil
}
func EnsureBitwardenReady(options Options) error { func EnsureBitwardenReady(options Options) error {
command := strings.TrimSpace(options.BitwardenCommand) command := strings.TrimSpace(options.BitwardenCommand)
if command == "" { if command == "" {
@ -242,6 +258,10 @@ func detectShellFlavor(shellHint string) string {
func (s *bitwardenStore) SetSecret(name, label, secret string) error { func (s *bitwardenStore) SetSecret(name, label, secret string) error {
secretName := s.scopedName(name) secretName := s.scopedName(name)
if err := s.ensureReady(); err != nil {
return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err)
}
item, payload, err := s.findItem(secretName, name) item, payload, err := s.findItem(secretName, name)
switch { switch {
case errors.Is(err, ErrNotFound): case errors.Is(err, ErrNotFound):
@ -265,6 +285,10 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error {
); err != nil { ); err != nil {
return err return err
} }
if s.cache != nil {
s.cache.invalidate(name, secretName)
s.cache.store(name, secretName, secret)
}
return nil return nil
case err != nil: case err != nil:
return err return err
@ -287,11 +311,25 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error {
return err return err
} }
if s.cache != nil {
s.cache.invalidate(name, secretName)
s.cache.store(name, secretName, secret)
}
return nil return nil
} }
func (s *bitwardenStore) GetSecret(name string) (string, error) { func (s *bitwardenStore) GetSecret(name string) (string, error) {
secretName := s.scopedName(name) secretName := s.scopedName(name)
if s.cache != nil {
if secret, ok := s.cache.load(name, secretName); ok {
return secret, nil
}
}
if err := s.ensureReady(); err != nil {
return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err)
}
_, payload, err := s.findItem(secretName, name) _, payload, err := s.findItem(secretName, name)
if err != nil { if err != nil {
return "", err return "", err
@ -302,13 +340,23 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) {
return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName)
} }
if s.cache != nil {
s.cache.store(name, secretName, secret)
}
return secret, nil return secret, nil
} }
func (s *bitwardenStore) DeleteSecret(name string) error { func (s *bitwardenStore) DeleteSecret(name string) error {
secretName := s.scopedName(name) secretName := s.scopedName(name)
if err := s.ensureReady(); err != nil {
return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err)
}
item, _, err := s.findItem(secretName, name) item, _, err := s.findItem(secretName, name)
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
if s.cache != nil {
s.cache.invalidate(name, secretName)
}
return nil return nil
} }
if err != nil { if err != nil {
@ -325,6 +373,9 @@ func (s *bitwardenStore) DeleteSecret(name string) error {
return err return err
} }
if s.cache != nil {
s.cache.invalidate(name, secretName)
}
return nil return nil
} }
@ -332,6 +383,24 @@ func (s *bitwardenStore) scopedName(name string) string {
return fmt.Sprintf("%s/%s", s.serviceName, name) return fmt.Sprintf("%s/%s", s.serviceName, name)
} }
func (s *bitwardenStore) ensureReady() error {
s.refreshSessionEnv()
return verifyBitwardenCLIReady(Options{
BitwardenCommand: s.command,
BitwardenDebug: s.debug,
LookupEnv: s.lookupEnv,
Shell: s.shell,
})
}
func (s *bitwardenStore) refreshSessionEnv() {
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil || strings.TrimSpace(session) == "" {
return
}
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
}
type bitwardenResolvedItem struct { type bitwardenResolvedItem struct {
item bitwardenListItem item bitwardenListItem
payload map[string]any payload map[string]any

View file

@ -0,0 +1,378 @@
package secretstore
import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
bitwardenCacheEnvName = "MCP_FRAMEWORK_BITWARDEN_CACHE"
defaultBitwardenCacheTTL = 10 * time.Minute
bitwardenCacheFormatVersion = 1
bitwardenCacheAlgorithm = "AES-256-GCM"
bitwardenCacheDirName = "bitwarden-cache"
bitwardenCacheSalt = "mcp-framework bitwarden cache salt v1"
bitwardenCacheInfo = "mcp-framework bitwarden cache v1"
bitwardenCacheEncryptionInfo = "mcp-framework bitwarden cache encryption v1"
bitwardenCacheEntryIDInfo = "mcp-framework bitwarden cache entry id v1"
bitwardenCacheContextScope = "mcp-framework bitwarden cache backend scope v1"
)
var bitwardenUserCacheDir = os.UserCacheDir
type bitwardenCacheOptions struct {
ServiceName string
Session string
TTL time.Duration
Now func() time.Time
CacheDir string
Enabled bool
}
type bitwardenCache struct {
mu sync.Mutex
enabled bool
serviceName string
ttl time.Duration
now func() time.Time
cacheDir string
encryptionKey []byte
entryIDKey []byte
memory map[string]bitwardenCacheMemoryEntry
}
type bitwardenCacheMemoryEntry struct {
value string
expiresAt time.Time
}
type bitwardenCachePlaintext struct {
Version int `json:"version"`
ServiceName string `json:"service_name"`
SecretName string `json:"secret_name"`
ScopedName string `json:"scoped_name"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Value string `json:"value"`
}
type bitwardenCacheEnvelope struct {
Version int `json:"version"`
Algorithm string `json:"algorithm"`
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
}
func newBitwardenCache(options bitwardenCacheOptions) *bitwardenCache {
now := options.Now
if now == nil {
now = time.Now
}
ttl := options.TTL
if ttl <= 0 {
ttl = defaultBitwardenCacheTTL
}
cache := &bitwardenCache{
enabled: options.Enabled,
serviceName: strings.TrimSpace(options.ServiceName),
ttl: ttl,
now: now,
cacheDir: strings.TrimSpace(options.CacheDir),
memory: map[string]bitwardenCacheMemoryEntry{},
}
if !cache.enabled {
return cache
}
session := strings.TrimSpace(options.Session)
if session == "" {
return cache
}
masterKey, err := hkdf.Key(sha256.New, []byte(session), []byte(bitwardenCacheSalt), bitwardenCacheInfo, 32)
if err != nil {
return cache
}
cache.encryptionKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEncryptionInfo, 32)
if err != nil {
cache.encryptionKey = nil
return cache
}
cache.entryIDKey, err = hkdf.Key(sha256.New, masterKey, nil, bitwardenCacheEntryIDInfo, 32)
if err != nil {
cache.encryptionKey = nil
cache.entryIDKey = nil
return cache
}
return cache
}
func (c *bitwardenCache) load(secretName, scopedName string) (string, bool) {
if value, ok := c.loadMemory(secretName, scopedName); ok {
return value, true
}
return c.loadDisk(secretName, scopedName)
}
func (c *bitwardenCache) store(secretName, scopedName, value string) {
if c == nil || !c.enabled {
return
}
c.storeMemory(secretName, scopedName, value)
c.storeDisk(secretName, scopedName, value)
}
func (c *bitwardenCache) invalidate(secretName, scopedName string) {
if c == nil {
return
}
key := c.memoryKey(secretName, scopedName)
c.mu.Lock()
delete(c.memory, key)
c.mu.Unlock()
if path, ok := c.entryPath(secretName, scopedName); ok {
_ = os.Remove(path)
}
}
func (c *bitwardenCache) loadMemory(secretName, scopedName string) (string, bool) {
if c == nil || !c.enabled {
return "", false
}
key := c.memoryKey(secretName, scopedName)
now := c.now()
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.memory[key]
if !ok {
return "", false
}
if !entry.expiresAt.After(now) {
delete(c.memory, key)
return "", false
}
return entry.value, true
}
func (c *bitwardenCache) storeMemory(secretName, scopedName, value string) {
key := c.memoryKey(secretName, scopedName)
c.mu.Lock()
c.memory[key] = bitwardenCacheMemoryEntry{
value: value,
expiresAt: c.now().Add(c.ttl),
}
c.mu.Unlock()
}
func (c *bitwardenCache) loadDisk(secretName, scopedName string) (string, bool) {
path, ok := c.entryPath(secretName, scopedName)
if !ok {
return "", false
}
data, err := os.ReadFile(path)
if err != nil {
return "", false
}
var envelope bitwardenCacheEnvelope
if err := json.Unmarshal(data, &envelope); err != nil {
_ = os.Remove(path)
return "", false
}
plaintext, err := c.decryptEnvelope(secretName, scopedName, envelope)
if err != nil {
_ = os.Remove(path)
return "", false
}
if plaintext.Version != bitwardenCacheFormatVersion ||
plaintext.ServiceName != c.serviceName ||
plaintext.SecretName != strings.TrimSpace(secretName) ||
plaintext.ScopedName != strings.TrimSpace(scopedName) ||
!plaintext.ExpiresAt.After(c.now()) {
_ = os.Remove(path)
return "", false
}
c.storeMemory(secretName, scopedName, plaintext.Value)
return plaintext.Value, true
}
func (c *bitwardenCache) storeDisk(secretName, scopedName, value string) {
if c.cacheDir == "" || len(c.encryptionKey) == 0 || len(c.entryIDKey) == 0 {
return
}
path, ok := c.entryPath(secretName, scopedName)
if !ok {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return
}
_ = os.Chmod(filepath.Dir(path), 0o700)
now := c.now()
plaintext := bitwardenCachePlaintext{
Version: bitwardenCacheFormatVersion,
ServiceName: c.serviceName,
SecretName: strings.TrimSpace(secretName),
ScopedName: strings.TrimSpace(scopedName),
CreatedAt: now,
ExpiresAt: now.Add(c.ttl),
Value: value,
}
envelope, err := c.encryptPlaintext(secretName, scopedName, plaintext)
if err != nil {
return
}
data, err := json.Marshal(envelope)
if err != nil {
return
}
tmp, err := os.CreateTemp(filepath.Dir(path), "bitwarden-cache-*.tmp")
if err != nil {
return
}
tmpPath := tmp.Name()
cleanup := true
defer func() {
_ = tmp.Close()
if cleanup {
_ = os.Remove(tmpPath)
}
}()
_ = tmp.Chmod(0o600)
if _, err := tmp.Write(data); err != nil {
return
}
if err := tmp.Close(); err != nil {
return
}
if err := os.Rename(tmpPath, path); err != nil {
return
}
_ = os.Chmod(path, 0o600)
cleanup = false
}
func (c *bitwardenCache) encryptPlaintext(secretName, scopedName string, plaintext bitwardenCachePlaintext) (bitwardenCacheEnvelope, error) {
raw, err := json.Marshal(plaintext)
if err != nil {
return bitwardenCacheEnvelope{}, err
}
block, err := aes.NewCipher(c.encryptionKey)
if err != nil {
return bitwardenCacheEnvelope{}, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return bitwardenCacheEnvelope{}, err
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return bitwardenCacheEnvelope{}, err
}
ciphertext := aead.Seal(nil, nonce, raw, c.additionalData(secretName, scopedName))
return bitwardenCacheEnvelope{
Version: bitwardenCacheFormatVersion,
Algorithm: bitwardenCacheAlgorithm,
Nonce: base64.StdEncoding.EncodeToString(nonce),
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
}, nil
}
func (c *bitwardenCache) decryptEnvelope(secretName, scopedName string, envelope bitwardenCacheEnvelope) (bitwardenCachePlaintext, error) {
if envelope.Version != bitwardenCacheFormatVersion || envelope.Algorithm != bitwardenCacheAlgorithm {
return bitwardenCachePlaintext{}, errors.New("unsupported bitwarden cache envelope")
}
nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce)
if err != nil {
return bitwardenCachePlaintext{}, err
}
ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext)
if err != nil {
return bitwardenCachePlaintext{}, err
}
block, err := aes.NewCipher(c.encryptionKey)
if err != nil {
return bitwardenCachePlaintext{}, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return bitwardenCachePlaintext{}, err
}
raw, err := aead.Open(nil, nonce, ciphertext, c.additionalData(secretName, scopedName))
if err != nil {
return bitwardenCachePlaintext{}, err
}
var plaintext bitwardenCachePlaintext
if err := json.Unmarshal(raw, &plaintext); err != nil {
return bitwardenCachePlaintext{}, err
}
return plaintext, nil
}
func (c *bitwardenCache) entryPath(secretName, scopedName string) (string, bool) {
if c == nil || !c.enabled || c.cacheDir == "" || len(c.entryIDKey) == 0 {
return "", false
}
mac := hmac.New(sha256.New, c.entryIDKey)
_, _ = mac.Write([]byte(c.cacheContext(secretName, scopedName)))
return filepath.Join(c.cacheDir, hex.EncodeToString(mac.Sum(nil))+".json"), true
}
func (c *bitwardenCache) memoryKey(secretName, scopedName string) string {
return c.cacheContext(secretName, scopedName)
}
func (c *bitwardenCache) additionalData(secretName, scopedName string) []byte {
return []byte(fmt.Sprintf(
"mcp-framework bitwarden cache v1\nservice=%s\nsecret=%s\nscoped=%s",
c.serviceName,
strings.TrimSpace(secretName),
strings.TrimSpace(scopedName),
))
}
func (c *bitwardenCache) cacheContext(secretName, scopedName string) string {
return fmt.Sprintf(
"version=%d\nservice=%s\nsecret=%s\nscoped=%s\nscope=%s",
bitwardenCacheFormatVersion,
c.serviceName,
strings.TrimSpace(secretName),
strings.TrimSpace(scopedName),
bitwardenCacheContextScope,
)
}
func resolveBitwardenCacheDir(serviceName string) string {
cacheRoot, err := bitwardenUserCacheDir()
if err != nil {
return ""
}
return filepath.Join(cacheRoot, strings.TrimSpace(serviceName), bitwardenCacheDirName)
}
func bitwardenCacheDisabledByEnv() bool {
raw, ok := os.LookupEnv(bitwardenCacheEnvName)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "0", "false", "no", "off", "disabled":
return true
default:
return false
}
}

View file

@ -0,0 +1,141 @@
package secretstore
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBitwardenCacheMemoryHit(t *testing.T) {
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: 10 * time.Minute,
Now: func() time.Time { return time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC) },
CacheDir: t.TempDir(),
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-v1")
got, ok := cache.loadMemory("api-token", "email-mcp/api-token")
if !ok {
t.Fatal("memory cache miss, want hit")
}
if got != "secret-v1" {
t.Fatalf("memory cache value = %q, want secret-v1", got)
}
}
func TestBitwardenCacheDiskRoundTripIsEncrypted(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: 10 * time.Minute,
Now: func() time.Time { return now },
CacheDir: dir,
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-v1")
reopened := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: 10 * time.Minute,
Now: func() time.Time { return now.Add(time.Minute) },
CacheDir: dir,
Enabled: true,
})
got, ok := reopened.loadDisk("api-token", "email-mcp/api-token")
if !ok {
t.Fatal("disk cache miss, want hit")
}
if got != "secret-v1" {
t.Fatalf("disk cache value = %q, want secret-v1", got)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir cache dir: %v", err)
}
if len(entries) != 1 {
t.Fatalf("cache file count = %d, want 1", len(entries))
}
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatalf("ReadFile cache file: %v", err)
}
if bytes.Contains(data, []byte("secret-v1")) {
t.Fatalf("cache file contains plaintext secret: %s", data)
}
if strings.Contains(entries[0].Name(), "api-token") {
t.Fatalf("cache file name exposes secret name: %s", entries[0].Name())
}
}
func TestBitwardenCacheRejectsChangedSession(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: 10 * time.Minute,
Now: func() time.Time { return now },
CacheDir: dir,
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-v1")
changed := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v2",
TTL: 10 * time.Minute,
Now: func() time.Time { return now.Add(time.Minute) },
CacheDir: dir,
Enabled: true,
})
if got, ok := changed.loadDisk("api-token", "email-mcp/api-token"); ok {
t.Fatalf("disk cache hit with changed session = %q, want miss", got)
}
}
func TestBitwardenCacheExpiresEntries(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 2, 10, 0, 0, 0, time.UTC)
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: time.Minute,
Now: func() time.Time { return now },
CacheDir: dir,
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-v1")
expired := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: "session-v1",
TTL: time.Minute,
Now: func() time.Time { return now.Add(2 * time.Minute) },
CacheDir: dir,
Enabled: true,
})
if got, ok := expired.load("api-token", "email-mcp/api-token"); ok {
t.Fatalf("expired cache hit = %q, want miss", got)
}
}
func withBitwardenUserCacheDir(t *testing.T, resolver func() (string, error)) {
t.Helper()
previous := bitwardenUserCacheDir
bitwardenUserCacheDir = resolver
t.Cleanup(func() {
bitwardenUserCacheDir = previous
})
}

View file

@ -9,7 +9,10 @@ import (
"strings" "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) {
@ -34,23 +34,29 @@ func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
if _, ok := store.(*bitwardenStore); !ok { if _, ok := store.(*bitwardenStore); !ok {
t.Fatalf("store type = %T, want *bitwardenStore", store) t.Fatalf("store type = %T, want *bitwardenStore", store)
} }
if !fakeCLI.versionChecked { if fakeCLI.versionChecked {
t.Fatal("expected bitwarden CLI version check") t.Fatal("Open should not check bitwarden CLI version before a cache miss or write")
} }
if !fakeCLI.statusChecked { if fakeCLI.statusChecked {
t.Fatal("expected bitwarden CLI status check") t.Fatal("Open should not check bitwarden CLI status before a cache miss or write")
} }
} }
func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
withBitwardenSession(t)
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
}) })
_, err := Open(Options{ store, err := Open(Options{
ServiceName: "email-mcp", ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI, BackendPolicy: BackendBitwardenCLI,
}) })
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
_, err = store.GetSecret("api-token")
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
@ -59,26 +65,31 @@ func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
} }
} }
func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) { func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw") fakeCLI := newFakeBitwardenCLI("bw")
withBitwardenRunner(t, fakeCLI.run) withBitwardenRunner(t, fakeCLI.run)
_, err := Open(Options{ store, err := Open(Options{
ServiceName: "email-mcp", ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI, BackendPolicy: BackendBitwardenCLI,
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
return "", false return "", false
}, },
}) })
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
_, err = store.GetSecret("api-token")
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
if !errors.Is(err, ErrBWLocked) { if !errors.Is(err, ErrBWLocked) {
t.Fatalf("error = %v, want ErrBWLocked", err) t.Fatalf("error = %v, want ErrBWLocked", err)
} }
if !errors.Is(err, ErrBackendUnavailable) {
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
}
} }
func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) { func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) {
@ -370,7 +381,203 @@ func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) {
} }
} }
func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) {
withBitwardenSession(t)
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)
}
for i := 0; i < 2; i++ {
value, err := store.GetSecret("api-token")
if err != nil {
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
}
if value != "secret-v1" {
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
}
}
if fakeCLI.getItemCalls != 1 {
t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls)
}
}
func TestBitwardenStoreDiskCacheHitSkipsBitwardenCLI(t *testing.T) {
withBitwardenSession(t)
cacheRoot := t.TempDir()
withBitwardenUserCacheDir(t, func() (string, error) {
return cacheRoot, nil
})
cache := newBitwardenCache(bitwardenCacheOptions{
ServiceName: "email-mcp",
Session: os.Getenv("BW_SESSION"),
TTL: defaultBitwardenCacheTTL,
CacheDir: resolveBitwardenCacheDir("email-mcp"),
Enabled: true,
})
cache.store("api-token", "email-mcp/api-token", "secret-from-cache")
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
return nil, fmt.Errorf("unexpected bitwarden invocation: %v", args)
})
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
value, err := store.GetSecret("api-token")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "secret-from-cache" {
t.Fatalf("GetSecret = %q, want secret-from-cache", value)
}
}
func TestBitwardenStoreCacheMissChecksReadinessLazily(t *testing.T) {
withBitwardenSession(t)
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw")
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
ID: "item-1",
Name: "email-mcp/api-token",
Secret: "secret-v1",
MarkerService: "email-mcp",
MarkerSecretName: "api-token",
}
withBitwardenRunner(t, fakeCLI.run)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if fakeCLI.statusChecked {
t.Fatal("Open should not check bitwarden status")
}
value, err := store.GetSecret("api-token")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "secret-v1" {
t.Fatalf("GetSecret = %q, want secret-v1", value)
}
if !fakeCLI.statusChecked {
t.Fatal("cache miss should check bitwarden status before CLI lookup")
}
}
func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) {
withBitwardenSession(t)
t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0")
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)
}
for i := 0; i < 2; i++ {
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
}
}
if fakeCLI.getItemCalls != 2 {
t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls)
}
}
func TestBitwardenStoreSetSecretRefreshesCache(t *testing.T) {
withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw")
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 := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
t.Fatalf("SetSecret v1 returned error: %v", err)
}
if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v1" {
t.Fatalf("GetSecret after v1 = %q, %v; want secret-v1, nil", got, err)
}
if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil {
t.Fatalf("SetSecret v2 returned error: %v", err)
}
if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v2" {
t.Fatalf("GetSecret after v2 = %q, %v; want secret-v2, nil", got, err)
}
}
func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) {
withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw")
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 := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
t.Fatalf("SetSecret returned error: %v", err)
}
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret before delete returned error: %v", err)
}
if err := store.DeleteSecret("api-token"); err != nil {
t.Fatalf("DeleteSecret returned error: %v", err)
}
_, err = store.GetSecret("api-token")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err)
}
}
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" {
@ -404,20 +611,33 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) {
func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) { func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) {
withBitwardenSession(t) withBitwardenSession(t)
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw") 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) withBitwardenRunner(t, fakeCLI.run)
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1") t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1")
var logs bytes.Buffer var logs bytes.Buffer
withBitwardenDebugOutput(t, &logs) withBitwardenDebugOutput(t, &logs)
_, err := Open(Options{ store, err := Open(Options{
ServiceName: "email-mcp", ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI, BackendPolicy: BackendBitwardenCLI,
}) })
if err != nil { if err != nil {
t.Fatalf("Open returned error: %v", err) t.Fatalf("Open returned error: %v", err)
} }
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
text := logs.String() text := logs.String()
if !strings.Contains(text, "bw --version") { if !strings.Contains(text, "bw --version") {
@ -514,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")
@ -549,7 +847,14 @@ func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
func withBitwardenSession(t *testing.T) { func withBitwardenSession(t *testing.T) {
t.Helper() t.Helper()
t.Setenv("BW_SESSION", "test-session") sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
t.Setenv("BW_SESSION", "test-session-"+sessionName)
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
} }
func withBitwardenRunner( 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)
@ -15,15 +15,16 @@ type ManifestLoader func(startDir string) (manifest.File, string, error)
type ExecutableResolver func() (string, error) type ExecutableResolver func() (string, error)
type OpenFromManifestOptions struct { type OpenFromManifestOptions struct {
ServiceName string ServiceName string
LookupEnv func(string) (string, bool) LookupEnv func(string) (string, bool)
KWalletAppID string KWalletAppID string
KWalletFolder string KWalletFolder string
BitwardenCommand string BitwardenCommand string
BitwardenDebug bool BitwardenDebug bool
Shell string DisableBitwardenCache bool
ManifestLoader ManifestLoader Shell string
ExecutableResolver ExecutableResolver ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
} }
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) { func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
@ -33,14 +34,15 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
} }
return Open(Options{ return Open(Options{
ServiceName: options.ServiceName, ServiceName: options.ServiceName,
BackendPolicy: manifestPolicy.Policy, BackendPolicy: manifestPolicy.Policy,
LookupEnv: options.LookupEnv, LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID, KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder, KWalletFolder: options.KWalletFolder,
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand), BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
BitwardenDebug: options.BitwardenDebug, BitwardenDebug: options.BitwardenDebug,
Shell: strings.TrimSpace(options.Shell), DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache),
Shell: strings.TrimSpace(options.Shell),
}) })
} }
@ -53,8 +55,9 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic
} }
type manifestPolicyResolution struct { type manifestPolicyResolution struct {
Policy BackendPolicy Policy BackendPolicy
Source string Source string
BitwardenCache bool
} }
func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) { func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) {
@ -82,17 +85,24 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return manifestPolicyResolution{ return manifestPolicyResolution{
Policy: BackendAuto, Policy: BackendAuto,
Source: "", Source: "",
BitwardenCache: true,
}, nil }, nil
} }
return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err) return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
} }
bitwardenCache := true
if file.SecretStore.BitwardenCache != nil {
bitwardenCache = *file.SecretStore.BitwardenCache
}
if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" { if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" {
return manifestPolicyResolution{ return manifestPolicyResolution{
Policy: BackendAuto, Policy: BackendAuto,
Source: strings.TrimSpace(manifestPath), Source: strings.TrimSpace(manifestPath),
BitwardenCache: bitwardenCache,
}, nil }, nil
} }
@ -106,7 +116,12 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol
} }
return manifestPolicyResolution{ return manifestPolicyResolution{
Policy: policy, Policy: policy,
Source: strings.TrimSpace(manifestPath), Source: strings.TrimSpace(manifestPath),
BitwardenCache: bitwardenCache,
}, nil }, nil
} }
func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool {
return runtimeDisabled || !manifestEnabled
}

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) {
@ -111,6 +111,78 @@ func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing
} }
} }
func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) {
cacheDisabled := false
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
ServiceName: "email-mcp",
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
return manifest.File{
SecretStore: manifest.SecretStore{
BackendPolicy: string(BackendBitwardenCLI),
BitwardenCache: &cacheDisabled,
},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("resolveManifestPolicy returned error: %v", err)
}
if resolution.BitwardenCache {
t.Fatal("resolution BitwardenCache = true, want false")
}
if resolution.Policy != BackendBitwardenCLI {
t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI)
}
}
func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) {
withBitwardenSession(t)
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)
cacheDisabled := false
store, err := OpenFromManifest(OpenFromManifestOptions{
ServiceName: "email-mcp",
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
return manifest.File{
SecretStore: manifest.SecretStore{
BackendPolicy: string(BackendBitwardenCLI),
BitwardenCache: &cacheDisabled,
},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("OpenFromManifest returned error: %v", err)
}
for i := 0; i < 2; i++ {
value, err := store.GetSecret("api-token")
if err != nil {
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
}
if value != "secret-v1" {
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
}
}
if fakeCLI.getItemCalls != 2 {
t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls)
}
}
func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) { func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) {
execErr := errors.New("boom") execErr := errors.New("boom")
_, err := OpenFromManifest(OpenFromManifestOptions{ _, err := OpenFromManifest(OpenFromManifestOptions{

View file

@ -9,15 +9,17 @@ import (
const DefaultManifestSource = "default:auto (manifest not found)" const DefaultManifestSource = "default:auto (manifest not found)"
type DescribeRuntimeOptions struct { type DescribeRuntimeOptions struct {
ServiceName string ServiceName string
LookupEnv func(string) (string, bool) LookupEnv func(string) (string, bool)
KWalletAppID string KWalletAppID string
KWalletFolder string KWalletFolder string
BitwardenCommand string BitwardenCommand string
BitwardenDebug bool BitwardenDebug bool
Shell string DisableBitwardenCache bool
ManifestLoader ManifestLoader CheckReady bool
ExecutableResolver ExecutableResolver Shell string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
} }
type RuntimeDescription struct { type RuntimeDescription struct {
@ -47,14 +49,15 @@ type PreflightReport struct {
func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) { func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) {
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{ resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
ServiceName: options.ServiceName, ServiceName: options.ServiceName,
LookupEnv: options.LookupEnv, LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID, KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder, KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand, BitwardenCommand: options.BitwardenCommand,
Shell: options.Shell, DisableBitwardenCache: options.DisableBitwardenCache,
ManifestLoader: options.ManifestLoader, Shell: options.Shell,
ExecutableResolver: options.ExecutableResolver, ManifestLoader: options.ManifestLoader,
ExecutableResolver: options.ExecutableResolver,
}) })
if err != nil { if err != nil {
return RuntimeDescription{}, err return RuntimeDescription{}, err
@ -68,14 +71,15 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
} }
store, openErr := Open(Options{ store, openErr := Open(Options{
ServiceName: options.ServiceName, ServiceName: options.ServiceName,
BackendPolicy: resolution.Policy, BackendPolicy: resolution.Policy,
LookupEnv: options.LookupEnv, LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID, KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder, KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand, BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug, BitwardenDebug: options.BitwardenDebug,
Shell: options.Shell, DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, resolution.BitwardenCache),
Shell: options.Shell,
}) })
if openErr != nil { if openErr != nil {
desc.Ready = false desc.Ready = false
@ -88,11 +92,23 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
desc.EffectivePolicy = effective desc.EffectivePolicy = effective
desc.DisplayName = BackendDisplayName(effective) desc.DisplayName = BackendDisplayName(effective)
} }
if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI {
if err := verifyBitwardenCLIReady(Options{
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
LookupEnv: options.LookupEnv,
Shell: options.Shell,
}); err != nil {
desc.Ready = false
desc.ReadyError = err
}
}
return desc, nil return desc, nil
} }
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)
} }
} }

View file

@ -31,14 +31,15 @@ const (
) )
type Options struct { type Options struct {
ServiceName string ServiceName string
BackendPolicy BackendPolicy BackendPolicy BackendPolicy
LookupEnv func(string) (string, bool) LookupEnv func(string) (string, bool)
KWalletAppID string KWalletAppID string
KWalletFolder string KWalletFolder string
BitwardenCommand string BitwardenCommand string
BitwardenDebug bool BitwardenDebug bool
Shell string DisableBitwardenCache bool
Shell string
} }
type Store interface { type Store interface {