Compare commits

...

80 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
017005b0b1 fix(secretstore): disable loader during interactive bitwarden prompts 2026-04-20 14:24:43 +02:00
6e80d3418e feat: add bitwarden login flow with persisted BW_SESSION 2026-04-20 12:38:58 +02:00
2920f5980a perf: reduce redundant bitwarden CLI calls 2026-04-20 12:03:22 +02:00
98bac84ab8 feat: add --debug tracing for bitwarden calls 2026-04-20 11:36:07 +02:00
98f07f557d feat(secretstore): add animated bitwarden wait loader 2026-04-20 11:18:14 +02:00
7d159bfdbd feat: add runtime secretstore diagnostics and setup helpers 2026-04-20 10:56:15 +02:00
7072cb2038 feat(secretstore): harden bitwarden readiness and secret verification 2026-04-20 09:39:05 +02:00
bba7aacedf feat(secretstore): add bitwarden CLI backend support 2026-04-20 08:30:35 +02:00
973770ed78 feat(scaffold): install binaries from latest release in install script 2026-04-16 17:52:25 +02:00
a9378885f2 feat(manifest): add embedded runtime fallback for scaffolded apps 2026-04-16 16:56:00 +02:00
01c0c7e1bc fix(update): reject HTML artifacts during self-update 2026-04-15 14:23:15 +02:00
f0e2e9304b docs: reorganize README and split detailed documentation 2026-04-15 14:06:28 +02:00
fbff660bcc feat: ajouter la vérification Ed25519 des artefacts de release 2026-04-15 12:20:06 +02:00
0d266cd5cc fix: durcir le scaffold runtime et la sécurité des updates 2026-04-15 12:13:41 +02:00
3eeb2fe173 feat(scaffold): align generated install wizard with latest TUI flow 2026-04-15 11:29:45 +02:00
9d862c876f feat(scaffold): add TUI install wizard for Claude and Codex 2026-04-15 10:33:13 +02:00
b2eebf413e fix(scaffold): avoid prompt capture in install wizard 2026-04-15 10:21:53 +02:00
f80eebb575 feat(scaffold): inject install.sh wizard in generated projects 2026-04-15 10:13:45 +02:00
7c239a7e97 Revert "feat: add unified build command driven by mcp.toml"
This reverts commit 845d20541b.
2026-04-15 09:37:03 +02:00
5e115893cc Revert "feat: add CI build matrix planning from manifest targets"
This reverts commit f5e52463f2.
2026-04-15 09:37:01 +02:00
f5e52463f2 feat: add CI build matrix planning from manifest targets 2026-04-15 09:09:39 +02:00
845d20541b feat: add unified build command driven by mcp.toml 2026-04-14 18:02:07 +02:00
0e5bfb2d39 feat(bootstrap): expose doctor alias in help and options 2026-04-14 17:51:28 +02:00
3d8a7dc84d feat(cli): add reusable doctor check for resolved profile fields 2026-04-14 16:53:26 +02:00
ea1768982e feat(cli): add multi-source resolve lookup helper 2026-04-14 16:48:26 +02:00
26238eb31b feat(secretstore): add runtime manifest helper for backend opening 2026-04-14 16:40:50 +02:00
8c4f88ea93 feat(bootstrap): add command alias expansion 2026-04-14 16:31:26 +02:00
1b603f552c Merge pull request 'feat: add MCP scaffold generator' (#20) from feat/mcp-binary-scaffold into release/v1.3
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/20
2026-04-14 14:24:44 +00:00
d42a790bc0 feat(cli): add scaffold init command 2026-04-14 15:59:18 +02:00
c4c461105f feat(scaffold): add MCP binary scaffold generator 2026-04-14 15:39:21 +02:00
46f48cb1f6 Merge pull request 'feat(update): drivers forge, template d'asset et checksum' (#19) from feat/auto-update-release-drivers into release/v1.3
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/19
2026-04-14 13:26:41 +00:00
bf8e1285d8 feat(update): add forge drivers and checksum validation hooks 2026-04-14 15:05:52 +02:00
42e1345962 Merge pull request 'feat(manifest): étendre mcp.toml au-delà de [update]' (#18) from feat/manifest-extended-fields into release/v1.3
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/18
2026-04-14 11:55:43 +00:00
cae689d0e4 fix(bootstrap): include delete in config subcommand hint 2026-04-14 13:55:00 +02:00
17fe329ce9 feat(manifest): extend mcp.toml metadata sections 2026-04-14 12:30:56 +02:00
89246d1581 feat(bootstrap): standardize config show/test command structure 2026-04-14 10:52:36 +02:00
1c40546f3f fix(ci): build changelog from last stable release tag 2026-04-14 10:28:15 +02:00
fcf8d71349 Merge remote-tracking branch 'origin/main' into release/v1.2 2026-04-14 10:21:54 +02:00
3dab8000c1 Merge pull request 'feat(cli): setup interactif déclaratif avec champs typés' (#17) from feat/setup-declaratif-typed-fields into release/v1.2
Reviewed-on: https://gitea.lclr.dev/AI/mcp-framework/pulls/17
2026-04-14 08:20:03 +00:00
c0433d1cde docs(agents): enforce enhancement branch naming format 2026-04-14 08:17:58 +02:00
56 changed files with 15676 additions and 643 deletions

25
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,25 @@
name: CI
"on":
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run go test
run: go test ./...
- name: Run go vet
run: go vet ./...

View file

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

View file

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

240
CHANGELOG.md Normal file
View file

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

1
CLAUDE.md Symbolic link
View file

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

544
README.md
View file

@ -1,514 +1,90 @@
# mcp-framework
Bibliothèque Go pour construire des binaires MCP avec :
- résolution de profils CLI
- stockage JSON de configuration dans `os.UserConfigDir()`
- stockage de secrets dans le wallet natif selon l'OS
- lecture d'un manifeste `mcp.toml` à la racine du projet
- pipeline d'auto-update via endpoint de release configurable
Le framework est volontairement petit. Il fournit des briques réutilisables,
pas une application MCP complète.
`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.
## Installation
Dans un projet Go :
```bash
go get gitea.lclr.dev/AI/mcp-framework
go get forge.lclr.dev/AI/mcp-framework
```
## Packages
Pour utiliser le CLI :
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config`, `update`, `version`) et hooks métier explicites.
- `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()`.
- `manifest` : lecture de `mcp.toml` à la racine du projet et conversion vers `update.ReleaseSource`.
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
## Utilisation type
Le flux typique côté application est :
1. Déclarer les sous-commandes communes via `bootstrap` (optionnel).
2. Résoudre le profil actif avec `cli`.
3. Charger la config versionnée avec `config`.
4. Lire les secrets avec `secretstore`.
5. Charger `mcp.toml` avec `manifest`.
6. Exécuter l'auto-update avec `update` si nécessaire.
7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier.
## 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.
Exemple minimal :
```go
func main() {
err := bootstrap.Run(context.Background(), bootstrap.Options{
BinaryName: "my-mcp",
Description: "Client MCP",
Version: version,
Hooks: bootstrap.Hooks{
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
return runSetup(ctx, inv.Args)
},
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
return runMCP(ctx, inv.Args)
},
Config: func(ctx context.Context, inv bootstrap.Invocation) error {
return runConfig(ctx, inv.Args)
},
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
return runUpdate(ctx, inv.Args)
},
},
})
if err != nil {
log.Fatal(err)
}
}
```bash
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
```
Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche
automatiquement `Options.Version`.
## Créer un projet MCP
## Manifeste `mcp.toml`
```bash
mcp-framework scaffold init \
--target ./my-mcp \
--module example.com/my-mcp \
--binary my-mcp \
--profiles dev,prod
Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire
courant puis remonte les répertoires parents jusqu'à trouver le fichier.
Exemple minimal :
```toml
[update]
source_name = "Gitea releases"
base_url = "https://gitea.example.com"
latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest"
token_header = "Authorization"
token_env_names = ["GITEA_TOKEN"]
cd my-mcp
go mod tidy
go run ./cmd/my-mcp help
```
Champs supportés dans `[update]` :
Le scaffold crée une arborescence prête à adapter :
- `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur.
- `base_url` : base de la forge ou du service de release.
- `latest_release_url` : URL complète qui retourne la release la plus récente.
- `token_header` : header HTTP à utiliser pour l'authentification.
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
Exemple de chargement :
```go
file, path, err := manifest.LoadDefault(".")
if err != nil {
return err
}
fmt.Printf("manifest loaded from %s\n", path)
source := file.Update.ReleaseSource()
```text
cmd/<binary>/main.go
internal/app/app.go
mcp.toml
install.sh
README.md
```
## Config JSON
## Générer la glue depuis `mcp.toml`
Le package `config` stocke une structure générique par profil dans un JSON privé
pour l'utilisateur courant.
Dans un projet qui possède un `mcp.toml` à la racine :
Exemple :
```go
type Profile struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
store := config.NewStore[Profile]("my-mcp")
cfg, path, err := store.LoadDefault()
if err != nil {
return err
}
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
profile.BaseURL = "https://api.example.com"
cfg.CurrentProfile = profileName
cfg.Profiles[profileName] = profile
_, err = store.SaveDefault(cfg)
if err != nil {
return err
}
fmt.Printf("config saved to %s\n", path)
```bash
mcp-framework generate
```
Notes :
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é.
- le fichier est créé avec des permissions `0600`
- le répertoire parent est forcé en `0700`
- l'écriture est atomique via un fichier temporaire puis `rename`
- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut
- `NewStoreWithOptions` permet de définir une version cible, des migrations JSON (`from -> to`) et une validation explicite après chargement
- une config plus récente que la version supportée, ou sans chemin de migration complet, retourne une erreur explicite
En CI :
Exemple de store versionné :
```go
store := config.NewStoreWithOptions[Profile]("my-mcp", config.Options[Profile]{
Version: 2,
Migrations: map[int]config.Migration{
1: func(doc map[string]json.RawMessage) error {
return nil
},
},
Validator: func(cfg config.FileConfig[Profile]) []config.ValidationIssue {
if cfg.CurrentProfile == "" {
return []config.ValidationIssue{{
Path: "current_profile",
Message: "must not be empty",
}}
}
return nil
},
})
```bash
mcp-framework generate --check
```
## Secrets
## Utiliser les packages
Le package `secretstore` supporte plusieurs politiques de backend :
Les packages peuvent être utilisés séparément :
- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni
- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible
- `keyring-any` : impose l'utilisation d'un backend keyring disponible
- `env-only` : lecture seule depuis les variables d'environnement
- `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`.
Backends keyring typiques :
## Documentation
- macOS : Keychain
- Linux : Secret Service ou KWallet selon l'environnement
- Windows : Credential Manager
Exemple :
```go
store, err := secretstore.Open(secretstore.Options{
ServiceName: "my-mcp",
BackendPolicy: secretstore.BackendAuto,
})
if err != nil {
return err
}
if err := store.SetSecret("api-token", "My MCP API token", token); err != nil {
return err
}
token, err = store.GetSecret("api-token")
switch {
case err == nil:
// secret found
case errors.Is(err, secretstore.ErrNotFound):
// first run
default:
return err
}
```
Pour imposer KWallet sur Linux :
```go
store, err := secretstore.Open(secretstore.Options{
ServiceName: "email-mcp",
BackendPolicy: secretstore.BackendKWalletOnly,
})
```
Pour stocker un secret structuré en JSON :
```go
type Credentials struct {
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
}
err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{
Host: "imap.example.com",
Username: "alice",
Password: token,
})
if err != nil {
return err
}
creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials")
if err != nil {
return err
}
```
En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`.
Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`.
## Helpers CLI
`cli` fournit des helpers simples pour les assistants interactifs :
```go
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL)
if err != nil {
return err
}
if err := cli.ValidateBaseURL(baseURL); err != nil {
return err
}
token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken)
if err != nil {
return err
}
```
Pour décrire un setup complet sans réécrire la boucle interactive :
```go
result, err := cli.RunSetup(cli.SetupOptions{
Stdin: os.Stdin,
Stdout: os.Stdout,
Fields: []cli.SetupField{
{
Name: "base_url",
Label: "Base URL",
Type: cli.SetupFieldURL,
Required: true,
},
{
Name: "api_token",
Label: "API token",
Type: cli.SetupFieldSecret,
Required: true,
ExistingSecret: storedToken, // conserve la valeur existante si l'utilisateur laisse vide
},
{
Name: "enabled",
Label: "Enable integration",
Type: cli.SetupFieldBool,
Default: "true",
},
{
Name: "scopes",
Label: "Scopes",
Type: cli.SetupFieldList,
Default: "read,write",
Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) },
},
},
})
if err != nil {
return err
}
baseURL, _ := result.Get("base_url")
apiToken, _ := result.Get("api_token")
enabled, _ := result.Get("enabled")
scopes, _ := result.Get("scopes")
if apiToken.KeptStoredSecret {
fmt.Println("Stored token kept.")
}
```
Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`).
Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif.
Pour standardiser la résolution `flag > env > config > secret` avec provenance :
```go
lookup := func(source cli.ValueSource, key string) (string, bool, error) {
switch source {
case cli.SourceFlag:
value, ok := flagValues[key]
return value, ok, nil
case cli.SourceEnv:
value, ok := os.LookupEnv(key)
return value, ok, nil
case cli.SourceConfig:
value, ok := configValues[key]
return value, ok, nil
case cli.SourceSecret:
value, err := store.GetSecret(key)
switch {
case err == nil:
return value, true, nil
case errors.Is(err, secretstore.ErrNotFound):
return "", false, nil
default:
return "", false, err
}
default:
return "", false, nil
}
}
resolution, err := cli.ResolveFields(cli.ResolveOptions{
Fields: []cli.FieldSpec{
{Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"},
{Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"},
{Name: "timeout", DefaultValue: "30s"},
},
Lookup: lookup,
})
if err != nil {
return err
}
if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil {
return err
}
```
`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et
`FieldSpec.Sources` permet de définir un ordre spécifique pour un champ.
Le package fournit aussi un socle réutilisable pour une commande `doctor`.
L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.),
mais peut réutiliser les checks communs et ajouter ses propres hooks :
```go
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
ConfigCheck: cli.NewConfigCheck(store),
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
ServiceName: "my-mcp",
})
}),
RequiredSecrets: []cli.DoctorSecret{
{Name: "api-token", Label: "API token"},
},
SecretStoreFactory: func() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
ServiceName: "my-mcp",
})
},
ManifestDir: ".",
ConnectivityCheck: func(context.Context) cli.DoctorResult {
if err := pingBackend(); err != nil {
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusFail,
Summary: "backend is unreachable",
Detail: err.Error(),
}
}
return cli.DoctorResult{
Name: "connectivity",
Status: cli.DoctorStatusOK,
Summary: "backend is reachable",
}
},
})
if err := cli.RenderDoctorReport(os.Stdout, report); err != nil {
return err
}
if report.HasFailures() {
os.Exit(1)
}
```
## Auto-Update
Le package `update` ne déduit pas la forge ni l'authentification.
L'application cliente fournit l'URL de release, le header d'auth éventuel et,
si besoin, les variables d'environnement à consulter.
Le format attendu pour la réponse `latest release` est actuellement :
```json
{
"tag_name": "v1.2.3",
"assets": {
"links": [
{
"name": "my-mcp-linux-amd64",
"url": "https://example.com/downloads/my-mcp-linux-amd64"
}
]
}
}
```
Exemple :
```go
file, _, err := manifest.LoadDefault(".")
if err != nil {
return err
}
err = update.Run(ctx, update.Options{
CurrentVersion: version,
BinaryName: "my-mcp",
ReleaseSource: file.Update.ReleaseSource(),
Stdout: os.Stdout,
})
if err != nil {
return err
}
```
Contraintes actuelles :
- le `latest_release_url` doit être renseigné explicitement
- les assets supportés sont `darwin/amd64`, `darwin/arm64`, `linux/amd64` et `windows/amd64`
- le remplacement du binaire n'est pas supporté sur Windows
- le nom de l'asset est dérivé de `BinaryName`, `GOOS` et `GOARCH`
## Exemple Minimal
```go
type Profile struct {
BaseURL string `json:"base_url"`
}
func run(ctx context.Context, flagProfile string) error {
cfgStore := config.NewStore[Profile]("my-mcp")
cfg, _, err := cfgStore.LoadDefault()
if err != nil {
return err
}
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
manifestFile, _, err := manifest.LoadDefault(".")
if err != nil {
return err
}
err = update.Run(ctx, update.Options{
CurrentVersion: version,
BinaryName: "my-mcp",
ReleaseSource: manifestFile.Update.ReleaseSource(),
})
if err != nil {
return err
}
_ = profile
return nil
}
```
## Limites Actuelles
- le manifeste gère uniquement la section `[update]`
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
- [Vue d'ensemble](docs/README.md)
- [Installation et utilisation](docs/getting-started.md)
- [Packages](docs/packages.md)
- [Bootstrap CLI](docs/bootstrap-cli.md)
- [Manifeste `mcp.toml`](docs/manifest.md)
- [Génération depuis `mcp.toml`](docs/generate.md)
- [Scaffolding](docs/scaffolding.md)
- [Config JSON](docs/config.md)
- [Secrets](docs/secrets.md)
- [Helpers CLI](docs/cli-helpers.md)
- [Auto-update](docs/auto-update.md)
- [Exemple minimal](docs/minimal-example.md)
- [Limites](docs/limitations.md)

View file

@ -6,43 +6,62 @@ import (
"fmt"
"io"
"os"
"sort"
"strings"
)
const (
CommandSetup = "setup"
CommandLogin = "login"
CommandMCP = "mcp"
CommandConfig = "config"
CommandUpdate = "update"
CommandVersion = "version"
CommandDoctor = "doctor"
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
ConfigSubcommandShow = "show"
ConfigSubcommandTest = "test"
ConfigSubcommandDelete = "delete"
)
var (
ErrBinaryNameRequired = errors.New("binary name is required")
ErrUnknownCommand = errors.New("unknown command")
ErrUnknownSubcommand = errors.New("unknown subcommand")
ErrCommandNotConfigured = errors.New("command not configured")
ErrVersionRequired = errors.New("version is required when no version hook is configured")
ErrSubcommandRequired = errors.New("subcommand is required")
)
type Handler func(context.Context, Invocation) error
type Hooks struct {
Setup Handler
MCP Handler
Config Handler
Update Handler
Version Handler
Setup Handler
Login Handler
MCP Handler
Config Handler
ConfigShow Handler
ConfigTest Handler
ConfigDelete Handler
Update Handler
Version Handler
}
type Options struct {
BinaryName string
Description string
Version string
Args []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Hooks Hooks
BinaryName string
Description string
Version string
Aliases map[string][]string
AliasDescriptions map[string]string
EnableDoctorAlias bool
DisabledCommands []string
Args []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Hooks Hooks
}
type Invocation struct {
@ -67,6 +86,13 @@ var commands = []commandDef{
return h.Setup
},
},
{
Name: CommandLogin,
Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.",
Handler: func(h Hooks) Handler {
return h.Login
},
},
{
Name: CommandMCP,
Description: "Executer la logique MCP principale du binaire.",
@ -104,29 +130,45 @@ func Run(ctx context.Context, opts Options) error {
return ErrBinaryNameRequired
}
command, commandArgs, showHelp := parseArgs(normalized.Args)
resolvedArgs := expandAliases(normalized.Args, normalized.Aliases)
resolvedArgs, debugEnabled := extractGlobalDebugFlag(resolvedArgs)
if debugEnabled {
_ = os.Setenv(bitwardenDebugEnvName, "1")
}
command, commandArgs, showHelp := parseArgs(resolvedArgs)
if showHelp {
return printHelp(normalized, command)
return printHelp(normalized, command, commandArgs)
}
if command == "" {
return printHelp(normalized, "")
}
if isCommandDisabled(command, normalized.DisabledCommands) {
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
}
if command == CommandConfig {
return runConfigCommand(ctx, normalized, commandArgs)
}
handler, known := resolveHandler(command, normalized.Hooks)
if !known {
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
}
if handler == nil {
if command == CommandVersion {
switch command {
case CommandVersion:
if strings.TrimSpace(normalized.Version) == "" {
return ErrVersionRequired
}
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
return err
default:
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
}
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
}
return handler(ctx, Invocation{
@ -151,10 +193,69 @@ func normalize(opts Options) Options {
if opts.Args == nil {
opts.Args = os.Args[1:]
}
opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias)
opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias)
for _, cmd := range autoDisabledCommands(opts) {
if !isCommandDisabled(cmd, opts.DisabledCommands) {
opts.DisabledCommands = append(opts.DisabledCommands, cmd)
}
}
return opts
}
func normalizeAliases(aliases map[string][]string, enableDoctorAlias bool) map[string][]string {
normalized := make(map[string][]string, len(aliases)+1)
for name, target := range aliases {
trimmedName := strings.TrimSpace(name)
trimmedTarget := trimArgs(target)
if trimmedName == "" || len(trimmedTarget) == 0 {
continue
}
normalized[trimmedName] = trimmedTarget
}
if enableDoctorAlias {
if _, ok := normalized[CommandDoctor]; !ok {
normalized[CommandDoctor] = []string{CommandConfig, ConfigSubcommandTest}
}
}
if len(normalized) == 0 {
return nil
}
return normalized
}
func normalizeAliasDescriptions(descriptions map[string]string, aliases map[string][]string, enableDoctorAlias bool) map[string]string {
normalized := make(map[string]string, len(descriptions)+1)
for name, description := range descriptions {
trimmedName := strings.TrimSpace(name)
trimmedDescription := strings.TrimSpace(description)
if trimmedName == "" || trimmedDescription == "" {
continue
}
if _, ok := aliases[trimmedName]; !ok {
continue
}
normalized[trimmedName] = trimmedDescription
}
if enableDoctorAlias {
if _, ok := aliases[CommandDoctor]; ok {
if _, defined := normalized[CommandDoctor]; !defined {
normalized[CommandDoctor] = "Diagnostiquer la configuration locale."
}
}
}
if len(normalized) == 0 {
return nil
}
return normalized
}
func parseArgs(args []string) (command string, commandArgs []string, showHelp bool) {
args = trimArgs(args)
if len(args) == 0 {
return "", nil, false
}
@ -163,23 +264,179 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo
switch first {
case "help", "-h", "--help":
if len(args) > 1 {
return strings.TrimSpace(args[1]), nil, true
return strings.TrimSpace(args[1]), trimArgs(args[2:]), true
}
return "", nil, true
}
command = first
commandArgs = args[1:]
if len(commandArgs) == 1 {
helpArg := strings.TrimSpace(commandArgs[0])
if helpArg == "-h" || helpArg == "--help" {
return command, nil, true
commandArgs = trimArgs(args[1:])
if len(commandArgs) > 0 {
last := strings.TrimSpace(commandArgs[len(commandArgs)-1])
if last == "-h" || last == "--help" {
return command, commandArgs[:len(commandArgs)-1], true
}
}
return command, commandArgs, false
}
func extractGlobalDebugFlag(args []string) ([]string, bool) {
args = trimArgs(args)
if len(args) == 0 {
return nil, false
}
filtered := make([]string, 0, len(args))
debugEnabled := false
for _, arg := range args {
if strings.TrimSpace(arg) == "--debug" {
debugEnabled = true
continue
}
filtered = append(filtered, arg)
}
return filtered, debugEnabled
}
func expandAliases(args []string, aliases map[string][]string) []string {
args = trimArgs(args)
if len(args) == 0 || len(aliases) == 0 {
return args
}
if args[0] == "help" && len(args) > 1 {
expanded, ok := resolveAlias(aliases, args[1])
if !ok {
return args
}
withHelp := make([]string, 0, 1+len(expanded)+len(args[2:]))
withHelp = append(withHelp, "help")
withHelp = append(withHelp, expanded...)
withHelp = append(withHelp, args[2:]...)
return withHelp
}
expanded, ok := resolveAlias(aliases, args[0])
if !ok {
return args
}
withCommand := make([]string, 0, len(expanded)+len(args[1:]))
withCommand = append(withCommand, expanded...)
withCommand = append(withCommand, args[1:]...)
if len(withCommand) == 0 {
return args
}
last := strings.TrimSpace(withCommand[len(withCommand)-1])
if last != "-h" && last != "--help" {
return withCommand
}
helpArgs := make([]string, 0, len(withCommand))
helpArgs = append(helpArgs, "help")
helpArgs = append(helpArgs, withCommand[:len(withCommand)-1]...)
return helpArgs
}
func resolveAlias(aliases map[string][]string, command string) ([]string, bool) {
target, ok := aliases[strings.TrimSpace(command)]
if !ok {
return nil, false
}
expanded := trimArgs(target)
if len(expanded) == 0 {
return nil, false
}
return expanded, true
}
func trimArgs(args []string) []string {
if len(args) == 0 {
return nil
}
result := make([]string, 0, len(args))
for _, arg := range args {
trimmed := strings.TrimSpace(arg)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}
func runConfigCommand(ctx context.Context, opts Options, args []string) error {
if len(args) == 0 {
return fmt.Errorf(
"%w: %s requires one of: %s, %s, %s",
ErrSubcommandRequired,
CommandConfig,
ConfigSubcommandShow,
ConfigSubcommandTest,
ConfigSubcommandDelete,
)
}
subcommand := strings.TrimSpace(args[0])
subcommandArgs := args[1:]
handler, known := resolveConfigHandler(opts.Hooks, subcommand)
if !known {
if opts.Hooks.Config != nil {
return opts.Hooks.Config(ctx, Invocation{
Command: CommandConfig,
Args: args,
Stdin: opts.Stdin,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
})
}
return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, subcommand)
}
if handler == nil {
if opts.Hooks.Config != nil {
return opts.Hooks.Config(ctx, Invocation{
Command: fmt.Sprintf("%s %s", CommandConfig, subcommand),
Args: subcommandArgs,
Stdin: opts.Stdin,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
})
}
return fmt.Errorf("%w: %s %s", ErrCommandNotConfigured, CommandConfig, subcommand)
}
return handler(ctx, Invocation{
Command: fmt.Sprintf("%s %s", CommandConfig, subcommand),
Args: subcommandArgs,
Stdin: opts.Stdin,
Stdout: opts.Stdout,
Stderr: opts.Stderr,
})
}
func resolveConfigHandler(hooks Hooks, subcommand string) (Handler, bool) {
switch subcommand {
case ConfigSubcommandShow:
return hooks.ConfigShow, true
case ConfigSubcommandTest:
return hooks.ConfigTest, true
case ConfigSubcommandDelete:
return hooks.ConfigDelete, true
default:
return nil, false
}
}
func resolveHandler(command string, hooks Hooks) (Handler, bool) {
for _, def := range commands {
if def.Name == command {
@ -189,11 +446,24 @@ func resolveHandler(command string, hooks Hooks) (Handler, bool) {
return nil, false
}
func printHelp(opts Options, command string) error {
func printHelp(opts Options, command string, args ...[]string) error {
var commandArgs []string
if len(args) > 0 {
commandArgs = args[0]
}
if command == "" {
return printGlobalHelp(opts)
}
if isCommandDisabled(command, opts.DisabledCommands) {
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
}
if command == CommandConfig {
return printConfigHelp(opts, commandArgs)
}
for _, def := range commands {
if def.Name != command {
continue
@ -211,6 +481,49 @@ func printHelp(opts Options, command string) error {
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
}
func printConfigHelp(opts Options, args []string) error {
if len(args) == 0 {
_, err := fmt.Fprintf(
opts.Stdout,
"Usage:\n %s config <subcommand> [args]\n\nSubcommandes:\n %-7s Afficher la configuration résolue et la provenance des valeurs.\n %-7s Vérifier la configuration et la connectivité.\n %-7s Supprimer un profil local (optionnel).\n",
opts.BinaryName,
ConfigSubcommandShow,
ConfigSubcommandTest,
ConfigSubcommandDelete,
)
return err
}
switch args[0] {
case ConfigSubcommandShow:
_, err := fmt.Fprintf(
opts.Stdout,
"Usage:\n %s config %s [args]\n\nAfficher la configuration résolue et l'origine des valeurs.\n",
opts.BinaryName,
ConfigSubcommandShow,
)
return err
case ConfigSubcommandTest:
_, err := fmt.Fprintf(
opts.Stdout,
"Usage:\n %s config %s [args]\n\nTester la configuration résolue et la connectivité associée.\n",
opts.BinaryName,
ConfigSubcommandTest,
)
return err
case ConfigSubcommandDelete:
_, err := fmt.Fprintf(
opts.Stdout,
"Usage:\n %s config %s [args]\n\nSupprimer un profil local de configuration.\n",
opts.BinaryName,
ConfigSubcommandDelete,
)
return err
default:
return fmt.Errorf("%w: %s %s", ErrUnknownSubcommand, CommandConfig, args[0])
}
}
func printGlobalHelp(opts Options) error {
if strings.TrimSpace(opts.Description) != "" {
if _, err := fmt.Fprintf(opts.Stdout, "%s\n\n", opts.Description); err != nil {
@ -226,11 +539,82 @@ func printGlobalHelp(opts Options) error {
}
for _, def := range commands {
if isCommandDisabled(def.Name, opts.DisabledCommands) {
continue
}
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil {
return err
}
}
if len(opts.Aliases) > 0 {
if _, err := fmt.Fprintln(opts.Stdout, "\nAlias:"); err != nil {
return err
}
names := make([]string, 0, len(opts.Aliases))
for name := range opts.Aliases {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
target := strings.Join(opts.Aliases[name], " ")
description := aliasDescription(opts.AliasDescriptions, name, target)
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", name, description); err != nil {
return err
}
}
}
if _, err := fmt.Fprintln(opts.Stdout, "\nOptions globales:"); err != nil {
return err
}
if _, err := fmt.Fprintln(opts.Stdout, " --debug Active le debug des appels Bitwarden."); err != nil {
return err
}
_, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help <command>\n", opts.BinaryName)
return err
}
func autoDisabledCommands(opts Options) []string {
h := opts.Hooks
var disabled []string
if h.Setup == nil {
disabled = append(disabled, CommandSetup)
}
if h.Login == nil {
disabled = append(disabled, CommandLogin)
}
if h.MCP == nil {
disabled = append(disabled, CommandMCP)
}
if h.Config == nil && h.ConfigShow == nil && h.ConfigTest == nil && h.ConfigDelete == nil {
disabled = append(disabled, CommandConfig)
}
if h.Update == nil {
disabled = append(disabled, CommandUpdate)
}
if h.Version == nil && strings.TrimSpace(opts.Version) == "" {
disabled = append(disabled, CommandVersion)
}
return disabled
}
func isCommandDisabled(command string, disabled []string) bool {
for _, d := range disabled {
if d == command {
return true
}
}
return false
}
func aliasDescription(descriptions map[string]string, name, target string) string {
description := strings.TrimSpace(descriptions[name])
if description == "" {
return fmt.Sprintf("Alias de %q.", target)
}
return fmt.Sprintf("%s (alias de %q).", description, target)
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"os"
"slices"
"strings"
"testing"
@ -40,6 +41,37 @@ func TestRunRoutesSetupHook(t *testing.T) {
}
}
func TestRunRoutesLoginHook(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Version: "v1.2.3",
Args: []string{"login", "--force"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
Login: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != CommandLogin {
t.Fatalf("invocation command = %q, want %q", got.Command, CommandLogin)
}
wantArgs := []string{"--force"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
}
func TestRunReturnsUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@ -59,14 +91,19 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
})
if !errors.Is(err, ErrCommandNotConfigured) {
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
if !errors.Is(err, ErrSubcommandRequired) {
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err)
}
if !strings.Contains(err.Error(), "show, test, delete") {
t.Fatalf("Run error = %q, want mention of show, test, delete", err)
}
}
@ -113,7 +150,7 @@ func TestRunVersionHookOverridesDefault(t *testing.T) {
}
}
func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@ -123,8 +160,8 @@ func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
Stdout: &stdout,
Stderr: &stderr,
})
if !errors.Is(err, ErrVersionRequired) {
t.Fatalf("Run error = %v, want ErrVersionRequired", err)
if !errors.Is(err, ErrUnknownCommand) {
t.Fatalf("Run error = %v, want ErrUnknownCommand", err)
}
}
@ -132,12 +169,48 @@ func TestRunPrintsGlobalHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Description: "Binaire MCP de test.",
Version: "v1.2.3",
Args: []string{"help"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
for _, snippet := range []string{
"Usage:",
"setup",
"mcp",
"config",
"update",
"version",
} {
if !strings.Contains(text, snippet) {
t.Fatalf("help output missing %q: %s", snippet, text)
}
}
}
func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Description: "Binaire MCP de test.",
Version: "v1.2.3",
Args: []string{},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -162,11 +235,13 @@ func TestRunPrintsCommandHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "update"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{Update: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
@ -180,3 +255,434 @@ func TestRunPrintsCommandHelp(t *testing.T) {
t.Fatalf("command help output missing update description: %q", text)
}
}
func TestRunPrintsLoginCommandHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "login"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{Login: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
if !strings.Contains(text, "my-mcp login [args]") {
t.Fatalf("command help output = %q", text)
}
if !strings.Contains(strings.ToLower(text), "bitwarden") {
t.Fatalf("command help output missing bitwarden description: %q", text)
}
}
func TestRunPrintsConfigHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "config"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
for _, snippet := range []string{
"my-mcp config <subcommand>",
"show",
"test",
} {
if !strings.Contains(text, snippet) {
t.Fatalf("config help output missing %q: %q", snippet, text)
}
}
}
func TestRunPrintsConfigSubcommandHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "config", "show"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
if !strings.Contains(text, "my-mcp config show [args]") {
t.Fatalf("config subcommand help output = %q", text)
}
}
func TestRunRoutesConfigShowHook(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config", "show", "--profile", "prod"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
ConfigShow: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != "config show" {
t.Fatalf("invocation command = %q, want %q", got.Command, "config show")
}
wantArgs := []string{"--profile", "prod"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
}
func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config", "show"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
})
if !errors.Is(err, ErrCommandNotConfigured) {
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
}
}
func TestRunConfigReturnsUnknownSubcommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"config", "sync"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigShow: noop},
})
if !errors.Is(err, ErrUnknownSubcommand) {
t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err)
}
}
func TestRunRoutesAliasToConfigSubcommandHook(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Aliases: map[string][]string{
"doctor": {CommandConfig, ConfigSubcommandTest},
},
Args: []string{"doctor", "--profile", "work"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
ConfigTest: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != "config test" {
t.Fatalf("invocation command = %q, want %q", got.Command, "config test")
}
wantArgs := []string{"--profile", "work"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
}
func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Aliases: map[string][]string{
"doctor": {CommandConfig, ConfigSubcommandTest},
},
Args: []string{"help", "doctor"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
if !strings.Contains(text, "my-mcp config test [args]") {
t.Fatalf("command help output = %q", text)
}
}
func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
noop := func(_ context.Context, _ Invocation) error { return nil }
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Aliases: map[string][]string{
"doctor": {CommandConfig, ConfigSubcommandTest},
},
Args: []string{"doctor", "--help"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{ConfigTest: noop},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
if !strings.Contains(text, "my-mcp config test [args]") {
t.Fatalf("command help output = %q", text)
}
}
func TestRunPrintsAliasesInGlobalHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Aliases: map[string][]string{
"doctor": {CommandConfig, ConfigSubcommandTest},
},
AliasDescriptions: map[string]string{
"doctor": "Diagnostiquer la configuration locale.",
},
Args: []string{"help"},
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
if !strings.Contains(text, "Alias:") {
t.Fatalf("global help output missing alias section: %q", text)
}
if !strings.Contains(text, `doctor Diagnostiquer la configuration locale. (alias de "config test").`) {
t.Fatalf("global help output missing doctor alias details: %q", text)
}
}
func TestRunRoutesDoctorAliasWhenEnabled(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
EnableDoctorAlias: true,
Args: []string{"doctor", "--profile", "prod"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
ConfigTest: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != "config test" {
t.Fatalf("invocation command = %q, want %q", got.Command, "config test")
}
wantArgs := []string{"--profile", "prod"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
}
func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
EnableDoctorAlias: true,
Args: []string{"help"},
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
if !strings.Contains(text, "Alias:") {
t.Fatalf("global help output missing alias section: %q", text)
}
if !strings.Contains(text, `doctor Diagnostiquer la configuration locale. (alias de "config test").`) {
t.Fatalf("global help output missing default doctor alias details: %q", text)
}
}
func TestRunAcceptsGlobalDebugFlagAndRoutesCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "")
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"--debug", "setup", "--profile", "prod"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
Setup: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != CommandSetup {
t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup)
}
wantArgs := []string{"--profile", "prod"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" {
t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1")
}
}
func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "")
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"setup", "--debug", "--profile", "prod"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
Setup: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != CommandSetup {
t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup)
}
wantArgs := []string{"--profile", "prod"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
if 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"
"strings"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
type DoctorStatus string
@ -49,16 +49,29 @@ type DoctorSecret struct {
type DoctorManifestValidator func(manifest.File, string) []string
type DoctorOptions struct {
ConfigCheck DoctorCheck
SecretStoreCheck DoctorCheck
RequiredSecrets []DoctorSecret
SecretStoreFactory func() (secretstore.Store, error)
ManifestDir string
ManifestValidator DoctorManifestValidator
ConnectivityCheck DoctorCheck
ExtraChecks []DoctorCheck
ConfigCheck DoctorCheck
SecretStoreCheck DoctorCheck
SecretBackendPolicy secretstore.BackendPolicy
RequiredSecrets []DoctorSecret
SecretStoreFactory func() (secretstore.Store, error)
ManifestDir string
ManifestValidator DoctorManifestValidator
ManifestCheck DoctorCheck
ConnectivityCheck DoctorCheck
BitwardenOptions BitwardenDoctorOptions
DisableAutoBitwardenCheck bool
ExtraChecks []DoctorCheck
}
type BitwardenDoctorOptions struct {
Command string
Debug bool
Shell string
LookupEnv func(string) (string, bool)
}
var checkBitwardenReady = secretstore.EnsureBitwardenReady
func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
checks := make([]DoctorCheck, 0, 5+len(options.ExtraChecks))
@ -71,10 +84,17 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil {
checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets))
}
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
if options.ManifestCheck != nil {
checks = append(checks, options.ManifestCheck)
} else if strings.TrimSpace(options.ManifestDir) != "" {
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
}
if options.ConnectivityCheck != nil {
checks = append(checks, options.ConnectivityCheck)
}
if shouldAutoIncludeBitwardenCheck(options) {
checks = append(checks, BitwardenReadyCheck(options.BitwardenOptions))
}
checks = append(checks, options.ExtraChecks...)
results := make([]DoctorResult, 0, len(checks))
@ -215,6 +235,82 @@ func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) Doc
}
}
func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck {
return func(context.Context) DoctorResult {
err := checkBitwardenReady(secretstore.Options{
BitwardenCommand: strings.TrimSpace(options.Command),
BitwardenDebug: options.Debug,
Shell: strings.TrimSpace(options.Shell),
LookupEnv: options.LookupEnv,
})
if err == nil {
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusOK,
Summary: "bitwarden CLI is ready",
}
}
switch {
case errors.Is(err, secretstore.ErrBWNotLoggedIn):
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusFail,
Summary: "bitwarden login is required",
Detail: err.Error(),
}
case errors.Is(err, secretstore.ErrBWLocked):
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusFail,
Summary: "bitwarden vault is locked or BW_SESSION is missing",
Detail: err.Error(),
}
case errors.Is(err, secretstore.ErrBWUnavailable):
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusFail,
Summary: "bitwarden CLI is unavailable",
Detail: err.Error(),
}
default:
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusFail,
Summary: "bitwarden readiness check failed",
Detail: err.Error(),
}
}
}
}
func shouldAutoIncludeBitwardenCheck(options DoctorOptions) bool {
if options.DisableAutoBitwardenCheck {
return false
}
if options.SecretBackendPolicy == secretstore.BackendBitwardenCLI {
return true
}
if options.SecretStoreFactory == nil {
return false
}
store, err := options.SecretStoreFactory()
if err == nil {
return secretstore.EffectiveBackendPolicy(store) == secretstore.BackendBitwardenCLI
}
if errors.Is(err, secretstore.ErrBWNotLoggedIn) ||
errors.Is(err, secretstore.ErrBWLocked) ||
errors.Is(err, secretstore.ErrBWUnavailable) {
return true
}
return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "bitwarden")
}
func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck {
return func(context.Context) DoctorResult {
store, err := factory()
@ -270,6 +366,82 @@ func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []
}
}
func RequiredResolvedFieldsCheck(options ResolveOptions) DoctorCheck {
required := requiredFieldNames(options.Fields)
return func(context.Context) DoctorResult {
if len(required) == 0 {
return DoctorResult{
Name: "required-profile-fields",
Status: DoctorStatusWarn,
Summary: "no required profile field is configured",
}
}
resolution, err := ResolveFields(options)
if err != nil {
return DoctorResult{
Name: "required-profile-fields",
Status: DoctorStatusFail,
Summary: resolveFieldsErrorSummary(err),
Detail: FormatResolveFieldsError(err),
}
}
sources := make([]string, 0, len(required))
for _, name := range required {
field, ok := resolution.Get(name)
if !ok || !field.Found {
return DoctorResult{
Name: "required-profile-fields",
Status: DoctorStatusFail,
Summary: "required profile values are missing",
Detail: name,
}
}
sources = append(sources, fmt.Sprintf("%s=%s", name, field.Source))
}
return DoctorResult{
Name: "required-profile-fields",
Status: DoctorStatusOK,
Summary: "required profile values are resolved",
Detail: strings.Join(sources, ", "),
}
}
}
func FormatResolveFieldsError(err error) string {
if err == nil {
return ""
}
var missing *MissingRequiredValuesError
if errors.As(err, &missing) {
if len(missing.Fields) == 0 {
return "missing required configuration values"
}
return strings.Join(missing.Fields, ", ")
}
var lookupErr *SourceLookupError
if errors.As(err, &lookupErr) {
key := strings.TrimSpace(lookupErr.Key)
if key == "" {
key = lookupErr.Field
}
return fmt.Sprintf(
"field %q via source %q (key %q): %v",
lookupErr.Field,
lookupErr.Source,
key,
lookupErr.Err,
)
}
return err.Error()
}
func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck {
return func(context.Context) DoctorResult {
file, path, err := manifest.LoadDefault(startDir)
@ -334,3 +506,41 @@ func doctorLabel(status DoctorStatus) string {
return "FAIL"
}
}
func requiredFieldNames(specs []FieldSpec) []string {
required := make([]string, 0, len(specs))
seen := make(map[string]struct{}, len(specs))
for _, spec := range specs {
if !spec.Required {
continue
}
name := strings.TrimSpace(spec.Name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
required = append(required, name)
}
return required
}
func resolveFieldsErrorSummary(err error) string {
var missing *MissingRequiredValuesError
if errors.As(err, &missing) {
return "required profile values are missing"
}
var lookupErr *SourceLookupError
if errors.As(err, &lookupErr) {
return fmt.Sprintf("cannot resolve profile value %q", lookupErr.Field)
}
return "profile resolution failed"
}

View file

@ -4,14 +4,15 @@ import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.lclr.dev/AI/mcp-framework/config"
"gitea.lclr.dev/AI/mcp-framework/manifest"
"gitea.lclr.dev/AI/mcp-framework/secretstore"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
type doctorProfile struct {
@ -132,6 +133,31 @@ func TestManifestCheckUsesValidator(t *testing.T) {
}
}
func TestRunDoctorUsesCustomManifestCheckWhenProvided(t *testing.T) {
report := RunDoctor(context.Background(), DoctorOptions{
ManifestDir: t.TempDir(),
ManifestCheck: func(context.Context) DoctorResult {
return DoctorResult{
Name: "manifest",
Status: DoctorStatusOK,
Summary: "manifest is embedded",
Detail: "embedded:mcp.toml",
}
},
})
if len(report.Results) != 1 {
t.Fatalf("result count = %d, want 1", len(report.Results))
}
result := report.Results[0]
if result.Summary != "manifest is embedded" {
t.Fatalf("summary = %q", result.Summary)
}
if result.Detail != "embedded:mcp.toml" {
t.Fatalf("detail = %q", result.Detail)
}
}
func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) {
var out bytes.Buffer
report := DoctorReport{
@ -171,3 +197,270 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) {
t.Fatalf("detail = %q", result.Detail)
}
}
func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) {
report := RunDoctor(context.Background(), DoctorOptions{
ConnectivityCheck: func(context.Context) DoctorResult {
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"}
},
// ManifestDir intentionally empty, ManifestCheck intentionally nil.
})
for _, r := range report.Results {
if r.Name == "manifest" {
t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r)
}
}
if len(report.Results) != 1 {
t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results))
}
}
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
prev := checkBitwardenReady
t.Cleanup(func() {
checkBitwardenReady = prev
})
t.Run("not logged in", func(t *testing.T) {
checkBitwardenReady = func(options secretstore.Options) error {
return fmt.Errorf("%w: run `bw login`", secretstore.ErrBWNotLoggedIn)
}
result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background())
if result.Status != DoctorStatusFail {
t.Fatalf("status = %q, want fail", result.Status)
}
if result.Summary != "bitwarden login is required" {
t.Fatalf("summary = %q", result.Summary)
}
if !strings.Contains(result.Detail, "bw login") {
t.Fatalf("detail = %q, want login remediation", result.Detail)
}
})
t.Run("locked", func(t *testing.T) {
checkBitwardenReady = func(options secretstore.Options) error {
return fmt.Errorf("%w: run `bw unlock --raw`", secretstore.ErrBWLocked)
}
result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background())
if result.Status != DoctorStatusFail {
t.Fatalf("status = %q, want fail", result.Status)
}
if result.Summary != "bitwarden vault is locked or BW_SESSION is missing" {
t.Fatalf("summary = %q", result.Summary)
}
if !strings.Contains(result.Detail, "bw unlock --raw") {
t.Fatalf("detail = %q, want unlock remediation", result.Detail)
}
})
t.Run("ready", func(t *testing.T) {
checkBitwardenReady = func(options secretstore.Options) error {
return nil
}
result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background())
if result.Status != DoctorStatusOK {
t.Fatalf("status = %q, want ok", result.Status)
}
if result.Summary != "bitwarden CLI is ready" {
t.Fatalf("summary = %q", result.Summary)
}
})
}
func TestRunDoctorAutoInjectsBitwardenCheckForBitwardenPolicy(t *testing.T) {
prev := checkBitwardenReady
t.Cleanup(func() {
checkBitwardenReady = prev
})
lookupCalled := false
checkBitwardenReady = func(options secretstore.Options) error {
if options.Shell != "fish" {
t.Fatalf("Shell = %q, want fish", options.Shell)
}
if options.BitwardenCommand != "bw" {
t.Fatalf("BitwardenCommand = %q, want bw", options.BitwardenCommand)
}
if options.LookupEnv == nil {
t.Fatal("LookupEnv should be forwarded")
}
_, _ = options.LookupEnv("BW_SESSION")
return fmt.Errorf("%w: run unlock", secretstore.ErrBWLocked)
}
report := RunDoctor(context.Background(), DoctorOptions{
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
BitwardenOptions: BitwardenDoctorOptions{
Command: "bw",
Shell: "fish",
LookupEnv: func(name string) (string, bool) {
lookupCalled = true
return "", false
},
},
ManifestCheck: func(context.Context) DoctorResult {
return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"}
},
})
if !lookupCalled {
t.Fatal("LookupEnv should be used by auto bitwarden check")
}
var found *DoctorResult
for i := range report.Results {
if report.Results[i].Name == "bitwarden" {
found = &report.Results[i]
break
}
}
if found == nil {
t.Fatalf("report results = %#v, want auto bitwarden check", report.Results)
}
if found.Status != DoctorStatusFail {
t.Fatalf("bitwarden status = %q, want fail", found.Status)
}
}
func TestRunDoctorCanDisableAutoBitwardenCheck(t *testing.T) {
prev := checkBitwardenReady
t.Cleanup(func() {
checkBitwardenReady = prev
})
checkBitwardenReady = func(options secretstore.Options) error {
t.Fatal("checkBitwardenReady should not be called when auto check is disabled")
return nil
}
report := RunDoctor(context.Background(), DoctorOptions{
DisableAutoBitwardenCheck: true,
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
ManifestCheck: func(context.Context) DoctorResult {
return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"}
},
})
if len(report.Results) != 1 {
t.Fatalf("result count = %d, want 1", len(report.Results))
}
if report.Results[0].Name != "manifest" {
t.Fatalf("result name = %q, want manifest", report.Results[0].Name)
}
}
func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) {
check := RequiredResolvedFieldsCheck(ResolveOptions{
Fields: []FieldSpec{
{Name: "base_url", Required: true, ConfigKey: "base_url"},
{
Name: "api_token",
Required: true,
EnvKey: "MY_API_TOKEN",
SecretKey: "my-api-token",
Sources: []ValueSource{SourceEnv, SourceSecret},
},
},
Lookup: ResolveLookup(ResolveLookupOptions{
Env: MapLookup(map[string]string{"MY_API_TOKEN": "env-token"}),
Config: ConfigMap(map[string]string{"base_url": "https://api.example.com"}),
Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}),
}),
})
result := check(context.Background())
if result.Status != DoctorStatusOK {
t.Fatalf("status = %q, want ok", result.Status)
}
for _, needle := range []string{"base_url=config", "api_token=env"} {
if !strings.Contains(result.Detail, needle) {
t.Fatalf("detail = %q, want substring %q", result.Detail, needle)
}
}
}
func TestRequiredResolvedFieldsCheckUsesSecretAsFallback(t *testing.T) {
check := RequiredResolvedFieldsCheck(ResolveOptions{
Fields: []FieldSpec{
{
Name: "api_token",
Required: true,
EnvKey: "MY_API_TOKEN",
SecretKey: "my-api-token",
Sources: []ValueSource{SourceEnv, SourceSecret},
},
},
Lookup: ResolveLookup(ResolveLookupOptions{
Env: MapLookup(map[string]string{}),
Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}),
}),
})
result := check(context.Background())
if result.Status != DoctorStatusOK {
t.Fatalf("status = %q, want ok", result.Status)
}
if !strings.Contains(result.Detail, "api_token=secret") {
t.Fatalf("detail = %q, want secret provenance", result.Detail)
}
}
func TestRequiredResolvedFieldsCheckFailsWithMissingRequiredField(t *testing.T) {
check := RequiredResolvedFieldsCheck(ResolveOptions{
Fields: []FieldSpec{
{Name: "base_url", Required: true},
},
Lookup: ResolveLookup(ResolveLookupOptions{
Env: MapLookup(map[string]string{}),
}),
})
result := check(context.Background())
if result.Status != DoctorStatusFail {
t.Fatalf("status = %q, want fail", result.Status)
}
if result.Summary != "required profile values are missing" {
t.Fatalf("summary = %q", result.Summary)
}
if result.Detail != "base_url" {
t.Fatalf("detail = %q, want base_url", result.Detail)
}
}
func TestRequiredResolvedFieldsCheckFormatsLookupErrors(t *testing.T) {
lookupErr := errors.New("vault unavailable")
check := RequiredResolvedFieldsCheck(ResolveOptions{
Fields: []FieldSpec{
{Name: "api_token", Required: true, Sources: []ValueSource{SourceSecret}},
},
Lookup: func(source ValueSource, key string) (string, bool, error) {
if source == SourceSecret {
return "", false, lookupErr
}
return "", false, nil
},
})
result := check(context.Background())
if result.Status != DoctorStatusFail {
t.Fatalf("status = %q, want fail", result.Status)
}
if result.Summary != "cannot resolve profile value \"api_token\"" {
t.Fatalf("summary = %q", result.Summary)
}
for _, needle := range []string{"api_token", "source \"secret\"", "key \"api_token\"", "vault unavailable"} {
if !strings.Contains(result.Detail, needle) {
t.Fatalf("detail = %q, want substring %q", result.Detail, needle)
}
}
}
func TestFormatResolveFieldsErrorWithNilError(t *testing.T) {
if got := FormatResolveFieldsError(nil); got != "" {
t.Fatalf("FormatResolveFieldsError(nil) = %q, want empty string", got)
}
}

87
cli/resolve_lookup.go Normal file
View file

@ -0,0 +1,87 @@
package cli
import (
"errors"
"os"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
type KeyLookupFunc func(key string) (string, bool, error)
type ResolveLookupOptions struct {
Flag KeyLookupFunc
Env KeyLookupFunc
Config KeyLookupFunc
Secret KeyLookupFunc
}
func ResolveLookup(options ResolveLookupOptions) LookupFunc {
return func(source ValueSource, key string) (string, bool, error) {
switch source {
case SourceFlag:
return runKeyLookup(options.Flag, key)
case SourceEnv:
return runKeyLookup(options.Env, key)
case SourceConfig:
return runKeyLookup(options.Config, key)
case SourceSecret:
return runKeyLookup(options.Secret, key)
case SourceDefault:
return "", false, nil
default:
return "", false, nil
}
}
}
func runKeyLookup(lookup KeyLookupFunc, key string) (string, bool, error) {
if lookup == nil {
return "", false, nil
}
if key == "" {
return "", false, nil
}
return lookup(key)
}
func MapLookup(values map[string]string) KeyLookupFunc {
return func(key string) (string, bool, error) {
value, ok := values[key]
return value, ok, nil
}
}
func EnvLookup(lookup func(string) (string, bool)) KeyLookupFunc {
reader := lookup
if reader == nil {
reader = os.LookupEnv
}
return func(key string) (string, bool, error) {
value, ok := reader(key)
return value, ok, nil
}
}
func ConfigMap(values map[string]string) KeyLookupFunc {
return MapLookup(values)
}
func SecretStore(store secretstore.Store) KeyLookupFunc {
if store == nil {
return nil
}
return func(key string) (string, bool, error) {
value, err := store.GetSecret(key)
switch {
case err == nil:
return value, true, nil
case errors.Is(err, secretstore.ErrNotFound):
return "", false, nil
default:
return "", false, err
}
}
}

145
cli/resolve_lookup_test.go Normal file
View file

@ -0,0 +1,145 @@
package cli
import (
"errors"
"testing"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
type testSecretStore struct {
values map[string]string
errs map[string]error
}
func (s testSecretStore) SetSecret(name, label, secret string) error {
return nil
}
func (s testSecretStore) GetSecret(name string) (string, error) {
if err, ok := s.errs[name]; ok {
return "", err
}
value, ok := s.values[name]
if !ok {
return "", secretstore.ErrNotFound
}
return value, nil
}
func (s testSecretStore) DeleteSecret(name string) error {
return nil
}
func TestResolveLookupWithStandardProviders(t *testing.T) {
t.Setenv("MCP_PASSWORD", "")
lookup := ResolveLookup(ResolveLookupOptions{
Flag: MapLookup(map[string]string{"host": "https://flag.example.com"}),
Env: EnvLookup(nil),
Config: ConfigMap(map[string]string{"username": "config-user"}),
Secret: SecretStore(testSecretStore{
values: map[string]string{"smtp-password": "secret-password"},
}),
})
resolution, err := ResolveFields(ResolveOptions{
Fields: []FieldSpec{
{
Name: "host",
Required: true,
FlagKey: "host",
},
{
Name: "username",
Required: true,
EnvKey: "MCP_USERNAME",
ConfigKey: "username",
},
{
Name: "password",
Required: true,
EnvKey: "MCP_PASSWORD",
SecretKey: "smtp-password",
},
},
Lookup: lookup,
})
if err != nil {
t.Fatalf("ResolveFields returned error: %v", err)
}
host, _ := resolution.Get("host")
if host.Source != SourceFlag || host.Value != "https://flag.example.com" {
t.Fatalf("host = %+v", host)
}
username, _ := resolution.Get("username")
if username.Source != SourceConfig || username.Value != "config-user" {
t.Fatalf("username = %+v", username)
}
password, _ := resolution.Get("password")
if password.Source != SourceSecret || password.Value != "secret-password" {
t.Fatalf("password = %+v", password)
}
}
func TestSecretStoreProviderTreatsErrNotFoundAsMissing(t *testing.T) {
lookup := ResolveLookup(ResolveLookupOptions{
Secret: SecretStore(testSecretStore{
errs: map[string]error{"smtp-password": secretstore.ErrNotFound},
}),
})
resolution, err := ResolveFields(ResolveOptions{
Fields: []FieldSpec{
{
Name: "password",
Required: true,
SecretKey: "smtp-password",
DefaultValue: "fallback-password",
},
},
Lookup: lookup,
})
if err != nil {
t.Fatalf("ResolveFields returned error: %v", err)
}
password, _ := resolution.Get("password")
if password.Source != SourceDefault || password.Value != "fallback-password" {
t.Fatalf("password = %+v", password)
}
}
func TestSecretStoreProviderPropagatesBackendErrors(t *testing.T) {
backendErr := errors.New("backend unavailable")
lookup := ResolveLookup(ResolveLookupOptions{
Secret: SecretStore(testSecretStore{
errs: map[string]error{"smtp-password": backendErr},
}),
})
_, err := ResolveFields(ResolveOptions{
Fields: []FieldSpec{
{
Name: "password",
Required: true,
SecretKey: "smtp-password",
},
},
Lookup: lookup,
})
var sourceErr *SourceLookupError
if !errors.As(err, &sourceErr) {
t.Fatalf("ResolveFields error = %v, want SourceLookupError", err)
}
if sourceErr.Source != SourceSecret {
t.Fatalf("sourceErr.Source = %q, want %q", sourceErr.Source, SourceSecret)
}
if !errors.Is(err, backendErr) {
t.Fatalf("ResolveFields error should wrap backend error")
}
}

76
cli/setup_secret.go Normal file
View file

@ -0,0 +1,76 @@
package cli
import (
"errors"
"fmt"
"strings"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
type SetupSecretWriteOptions struct {
Store secretstore.Store
SecretName string
SecretLabel string
TokenEnv string
Value SetupValue
}
func WriteSetupSecretVerified(options SetupSecretWriteOptions) error {
if options.Store == nil {
return errors.New("secret store must not be nil")
}
secretName := strings.TrimSpace(options.SecretName)
if secretName == "" {
return errors.New("secret name must not be empty")
}
secretLabel := strings.TrimSpace(options.SecretLabel)
if secretLabel == "" {
secretLabel = secretName
}
if options.Value.KeptStoredSecret {
return verifyStoredSetupSecret(options.Store, secretName, options.TokenEnv)
}
if !options.Value.Set {
return nil
}
if err := secretstore.SetSecretVerified(options.Store, secretName, secretLabel, options.Value.String); err != nil {
if errors.Is(err, secretstore.ErrReadOnly) {
tokenEnv := strings.TrimSpace(options.TokenEnv)
if tokenEnv != "" {
return fmt.Errorf("secret store is read-only, export %s and retry setup: %w", tokenEnv, err)
}
}
return fmt.Errorf("save secret %q during setup: %w", secretName, err)
}
return nil
}
func verifyStoredSetupSecret(store secretstore.Store, secretName, tokenEnv string) error {
secret, err := store.GetSecret(secretName)
if err != nil {
if errors.Is(err, secretstore.ErrNotFound) {
tokenEnv = strings.TrimSpace(tokenEnv)
if tokenEnv != "" {
return fmt.Errorf(
"secret %q is not readable after setup, export %s and retry: %w",
secretName,
tokenEnv,
err,
)
}
}
return fmt.Errorf("verify secret %q after setup: %w", secretName, err)
}
if strings.TrimSpace(secret) == "" {
return fmt.Errorf("secret %q is empty after setup", secretName)
}
return nil
}

116
cli/setup_secret_test.go Normal file
View file

@ -0,0 +1,116 @@
package cli
import (
"errors"
"strings"
"testing"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) {
store := &setupSecretStore{secrets: map[string]string{}}
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
Store: store,
SecretName: "api-token",
SecretLabel: "API token",
Value: SetupValue{
Type: SetupFieldSecret,
String: "secret-v1",
Set: true,
},
})
if err != nil {
t.Fatalf("WriteSetupSecretVerified returned error: %v", err)
}
if got := store.secrets["api-token"]; got != "secret-v1" {
t.Fatalf("stored secret = %q, want secret-v1", got)
}
}
func TestWriteSetupSecretVerifiedReturnsContextForReadOnlyStores(t *testing.T) {
store := &setupSecretStore{
secrets: map[string]string{},
setErr: secretstore.ErrReadOnly,
}
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
Store: store,
SecretName: "api-token",
TokenEnv: "GRAYLOG_MCP_API_TOKEN",
Value: SetupValue{
Type: SetupFieldSecret,
String: "secret-v1",
Set: true,
},
})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, secretstore.ErrReadOnly) {
t.Fatalf("error = %v, want ErrReadOnly", err)
}
if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") {
t.Fatalf("error = %v, want token env remediation", err)
}
}
func TestWriteSetupSecretVerifiedValidatesKeptStoredSecret(t *testing.T) {
store := &setupSecretStore{
secrets: map[string]string{},
getErr: secretstore.ErrNotFound,
}
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
Store: store,
SecretName: "api-token",
TokenEnv: "GRAYLOG_MCP_API_TOKEN",
Value: SetupValue{
Type: SetupFieldSecret,
String: "stored-token",
Set: true,
KeptStoredSecret: true,
},
})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, secretstore.ErrNotFound) {
t.Fatalf("error = %v, want ErrNotFound", err)
}
if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") {
t.Fatalf("error = %v, want token env remediation", err)
}
}
type setupSecretStore struct {
secrets map[string]string
setErr error
getErr error
}
func (s *setupSecretStore) SetSecret(name, label, secret string) error {
if s.setErr != nil {
return s.setErr
}
s.secrets[name] = secret
return nil
}
func (s *setupSecretStore) GetSecret(name string) (string, error) {
if s.getErr != nil {
return "", s.getErr
}
value, ok := s.secrets[name]
if !ok {
return "", secretstore.ErrNotFound
}
return value, nil
}
func (s *setupSecretStore) DeleteSecret(name string) error {
delete(s.secrets, name)
return nil
}

269
cmd/mcp-framework/main.go Normal file
View file

@ -0,0 +1,269 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
generatepkg "forge.lclr.dev/AI/mcp-framework/generate"
scaffoldpkg "forge.lclr.dev/AI/mcp-framework/scaffold"
)
const toolName = "mcp-framework"
func main() {
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func run(args []string, stdout, stderr io.Writer) error {
if stdout == nil {
stdout = io.Discard
}
if stderr == nil {
stderr = io.Discard
}
if len(args) == 0 || isHelpArg(args[0]) {
printGlobalHelp(stdout)
return nil
}
switch args[0] {
case "generate":
return runGenerate(args[1:], stdout, stderr)
case "scaffold":
return runScaffold(args[1:], stdout, stderr)
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
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 {
if len(args) == 0 || isHelpArg(args[0]) {
printScaffoldHelp(stdout)
return nil
}
switch args[0] {
case "init":
return runScaffoldInit(args[1:], stdout, stderr)
default:
return fmt.Errorf("unknown scaffold subcommand %q", args[0])
}
}
func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
if shouldShowHelp(args) {
printScaffoldInitHelp(stdout)
return nil
}
fs := flag.NewFlagSet("scaffold init", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var target string
var modulePath string
var binaryName string
var description string
var docsURL string
var defaultProfile string
var profiles string
var knownEnv string
var secretStorePolicy string
var releaseDriver string
var releaseBaseURL string
var releaseRepository string
var releaseTokenEnv string
var releasePublicKeyEnv string
var overwrite bool
fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)")
fs.StringVar(&modulePath, "module", "", "Chemin de module Go du projet généré")
fs.StringVar(&binaryName, "binary", "", "Nom du binaire généré")
fs.StringVar(&description, "description", "", "Description bootstrap du binaire")
fs.StringVar(&docsURL, "docs-url", "", "URL de documentation du projet")
fs.StringVar(&defaultProfile, "default-profile", "", "Profil par défaut")
fs.StringVar(&profiles, "profiles", "", "Liste CSV de profils connus")
fs.StringVar(&knownEnv, "known-env", "", "Liste CSV de variables d'environnement connues")
fs.StringVar(&secretStorePolicy, "secret-store-policy", "", "Politique secret store (auto, keyring-any, kwallet-only, env-only)")
fs.StringVar(&releaseDriver, "release-driver", "", "Driver de release (gitea, gitlab, github)")
fs.StringVar(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release")
fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)")
fs.StringVar(&releaseTokenEnv, "release-token-env", "", "Nom de variable d'environnement pour le token release")
fs.StringVar(&releasePublicKeyEnv, "release-pubkey-env", "", "Nom de variable d'environnement pour la cle publique Ed25519 de signature")
fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants")
if err := fs.Parse(args); err != nil {
_ = stderr
return fmt.Errorf("parse scaffold init flags: %w", err)
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", "))
}
if strings.TrimSpace(target) == "" {
return errors.New("--target is required")
}
result, err := scaffoldpkg.Generate(scaffoldpkg.Options{
TargetDir: target,
ModulePath: modulePath,
BinaryName: binaryName,
Description: description,
DocsURL: docsURL,
DefaultProfile: defaultProfile,
Profiles: parseCSV(profiles),
KnownEnvironmentVariables: parseCSV(knownEnv),
SecretStorePolicy: secretStorePolicy,
ReleaseDriver: releaseDriver,
ReleaseBaseURL: releaseBaseURL,
ReleaseRepository: releaseRepository,
ReleaseTokenEnv: releaseTokenEnv,
ReleasePublicKeyEnv: releasePublicKeyEnv,
Overwrite: overwrite,
})
if err != nil {
return err
}
if _, err := fmt.Fprintf(stdout, "Scaffold generated in %s\n", result.Root); err != nil {
return err
}
for _, file := range result.Files {
if _, err := fmt.Fprintf(stdout, "- %s\n", file); err != nil {
return err
}
}
return nil
}
func printGlobalHelp(w io.Writer) {
fmt.Fprintf(
w,
"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,
)
}
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) {
fmt.Fprintf(
w,
"Usage:\n %s scaffold init [flags]\n\nSubcommands:\n init Génère un nouveau squelette MCP\n",
toolName,
)
}
func printScaffoldInitHelp(w io.Writer) {
fmt.Fprintf(
w,
"Usage:\n %s scaffold init --target <dir> [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --release-pubkey-env Variable cle publique Ed25519 release\n --overwrite Écraser les fichiers existants\n",
toolName,
)
}
func shouldShowHelp(args []string) bool {
for _, arg := range args {
if isHelpArg(arg) {
return true
}
}
return false
}
func isHelpArg(arg string) bool {
switch strings.TrimSpace(arg) {
case "-h", "--help", "help":
return true
default:
return false
}
}
func parseCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}

View file

@ -0,0 +1,144 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunPrintsGlobalHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := run(nil, &stdout, &stderr); err != nil {
t.Fatalf("run returned error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "mcp-framework <command>") {
t.Fatalf("global help should mention command usage: %q", output)
}
if !strings.Contains(output, "scaffold init") {
t.Fatalf("global help should mention scaffold init: %q", output)
}
}
func TestRunScaffoldInitCreatesProject(t *testing.T) {
target := filepath.Join(t.TempDir(), "demo-mcp")
args := []string{
"scaffold", "init",
"--target", target,
"--module", "example.com/demo-mcp",
"--binary", "demo-mcp",
"--profiles", "dev,prod",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := run(args, &stdout, &stderr); err != nil {
t.Fatalf("run returned error: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "cmd", "demo-mcp", "main.go")); err != nil {
t.Fatalf("generated main.go missing: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "internal", "app", "app.go")); err != nil {
t.Fatalf("generated app.go missing: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "mcp.toml")); err != nil {
t.Fatalf("generated mcp.toml missing: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "install.sh")); err != nil {
t.Fatalf("generated install.sh missing: %v", err)
}
if !strings.Contains(stdout.String(), "Scaffold generated in") {
t.Fatalf("stdout should include generation summary: %q", stdout.String())
}
}
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) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"scaffold", "init"}, &stdout, &stderr)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--target is required") {
t.Fatalf("error = %v", err)
}
}
func TestRunUnknownCommandReturnsError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"boom"}, &stdout, &stderr)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "unknown command") {
t.Fatalf("error = %v", err)
}
}
func TestScaffoldInitHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := run([]string{"scaffold", "init", "--help"}, &stdout, &stderr); err != nil {
t.Fatalf("run returned error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "--target") {
t.Fatalf("init help should mention --target: %q", output)
}
if !strings.Contains(output, "--overwrite") {
t.Fatalf("init help should mention --overwrite: %q", output)
}
}

20
docs/README.md Normal file
View file

@ -0,0 +1,20 @@
# Documentation mcp-framework
`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
- [Installation et utilisation type](getting-started.md)
- [Packages](packages.md)
- [Bootstrap CLI](bootstrap-cli.md)
- [Manifeste `mcp.toml`](manifest.md)
- [Génération depuis `mcp.toml`](generate.md)
- [Scaffolding](scaffolding.md)
- [Config JSON](config.md)
- [Secrets](secrets.md)
- [Helpers CLI](cli-helpers.md)
- [Auto-update](auto-update.md)
- [Exemple minimal](minimal-example.md)
- [Limites](limitations.md)

51
docs/auto-update.md Normal file
View file

@ -0,0 +1,51 @@
# Auto-update
Le package `update` supporte les drivers `gitea`, `gitlab` et `github`.
Si `latest_release_url` est vide, l'URL latest est déduite depuis `driver + repository (+ base_url)`.
Le parseur de release supporte :
- format `assets.links` (Gitea/GitLab)
- format `assets[]` avec `browser_download_url` (GitHub et Gitea API)
Le format attendu pour la réponse `latest release` est :
```json
{
"tag_name": "v1.2.3",
"assets": {
"links": [
{
"name": "my-mcp-linux-amd64",
"url": "https://example.com/downloads/my-mcp-linux-amd64"
}
]
}
}
```
Exemple :
```go
file, _, err := manifest.LoadDefault(".")
if err != nil {
return err
}
err = update.Run(ctx, update.Options{
CurrentVersion: version,
BinaryName: "my-mcp",
ReleaseSource: file.Update.ReleaseSource(),
Stdout: os.Stdout,
})
if err != nil {
return err
}
```
Comportement :
- le nom de l'asset est configurable (`asset_name_template`) et supporte tout couple `GOOS/GOARCH`
- si un asset `<asset>.sha256` (ou `checksum_asset_name`) existe, le binaire téléchargé est vérifié avant remplacement
- un hook `ValidateDownloaded` permet d'ajouter une validation custom (signature, scan, etc.)
- sur Windows, le remplacement in-place n'est pas fait par défaut ; fournir `Options.ReplaceExecutable` pour une stratégie dédiée

152
docs/bootstrap-cli.md Normal file
View file

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

208
docs/cli-helpers.md Normal file
View file

@ -0,0 +1,208 @@
# Helpers CLI
`cli` fournit des helpers simples pour les assistants interactifs :
```go
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
baseURL, err := cli.PromptLine(reader, os.Stdout, "Base URL", profile.BaseURL)
if err != nil {
return err
}
if err := cli.ValidateBaseURL(baseURL); err != nil {
return err
}
token, err := cli.PromptSecret(os.Stdin, os.Stdout, "API token", hasStoredSecret, storedToken)
if err != nil {
return err
}
_ = token
```
Pour décrire un setup complet sans réécrire la boucle interactive :
```go
result, err := cli.RunSetup(cli.SetupOptions{
Stdin: os.Stdin,
Stdout: os.Stdout,
Fields: []cli.SetupField{
{
Name: "base_url",
Label: "Base URL",
Type: cli.SetupFieldURL,
Required: true,
},
{
Name: "api_token",
Label: "API token",
Type: cli.SetupFieldSecret,
Required: true,
ExistingSecret: storedToken,
},
{
Name: "enabled",
Label: "Enable integration",
Type: cli.SetupFieldBool,
Default: "true",
},
{
Name: "scopes",
Label: "Scopes",
Type: cli.SetupFieldList,
Default: "read,write",
Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) },
},
},
})
if err != nil {
return err
}
baseURL, _ := result.Get("base_url")
apiToken, _ := result.Get("api_token")
enabled, _ := result.Get("enabled")
scopes, _ := result.Get("scopes")
if apiToken.KeptStoredSecret {
fmt.Println("Stored token kept.")
}
_ = baseURL
_ = enabled
_ = scopes
```
Pour persister un secret de setup avec write+read-back et messages homogènes :
```go
if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{
Store: store,
SecretName: "api-token",
SecretLabel: "API token",
TokenEnv: "MY_MCP_API_TOKEN",
Value: apiToken,
}); err != nil {
return err
}
```
Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`).
Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif.
Pour standardiser la résolution `flag > env > config > secret` avec provenance :
```go
store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
})
if err != nil {
return err
}
lookup := cli.ResolveLookup(cli.ResolveLookupOptions{
Flag: cli.MapLookup(flagValues),
Env: cli.EnvLookup(os.LookupEnv),
Config: cli.ConfigMap(configValues),
Secret: cli.SecretStore(store),
})
resolution, err := cli.ResolveFields(cli.ResolveOptions{
Fields: []cli.FieldSpec{
{Name: "base_url", Required: true, FlagKey: "base-url", EnvKey: "MY_MCP_BASE_URL"},
{Name: "api_token", Required: true, EnvKey: "MY_MCP_API_TOKEN", SecretKey: "my-mcp-api-token"},
{Name: "timeout", DefaultValue: "30s"},
},
Lookup: lookup,
})
if err != nil {
return err
}
if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil {
return err
}
```
`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ.
Le package fournit un socle réutilisable pour une commande `doctor`.
Pour les cas standards (config, secret store, connectivité), préférer
`bootstrap.StandardConfigTestHandler` qui câble `RunDoctor` sans boilerplate.
Pour un contrôle fin ou un config test impératif, utiliser `RunDoctor` directement :
```go
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
ConfigCheck: cli.NewConfigCheck(store),
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
})
}),
ConnectivityCheck: func(context.Context) cli.DoctorResult {
if err := pingBackend(); 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",
}
},
})
if err := cli.RenderDoctorReport(os.Stdout, report); err != nil {
return err
}
if report.HasFailures() {
os.Exit(1)
}
```
`ManifestDir` est optionnel. Quand il est fourni, `RunDoctor` inclut un
`ManifestCheck` qui vérifie la présence et la validité de `mcp.toml` dans ce
répertoire. Ne l'inclure que si ce check est pertinent pour l'application.
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
Pour le désactiver explicitement :
```go
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
DisableAutoBitwardenCheck: true,
})
```
Pour éviter des checks custom répétitifs sur les profils, `doctor` expose aussi un helper basé sur `FieldSpec` :
```go
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
ExtraChecks: []cli.DoctorCheck{
cli.RequiredResolvedFieldsCheck(cli.ResolveOptions{
Fields: []cli.FieldSpec{
{Name: "base_url", Required: true, EnvKey: "MY_MCP_BASE_URL"},
{
Name: "api_token",
Required: true,
EnvKey: "MY_MCP_API_TOKEN",
SecretKey: "my-mcp-api-token",
Sources: []cli.ValueSource{cli.SourceEnv, cli.SourceSecret},
},
},
Lookup: cli.ResolveLookup(cli.ResolveLookupOptions{
Env: cli.EnvLookup(os.LookupEnv),
Config: cli.ConfigMap(configValues),
Secret: cli.SecretStore(secretStore),
}),
}),
},
})
```
En cas d'échec de résolution, tu peux aussi réutiliser le formatteur `cli.FormatResolveFieldsError(err)` dans un check custom pour garder des messages homogènes.

64
docs/config.md Normal file
View file

@ -0,0 +1,64 @@
# Config JSON
Le package `config` stocke une structure générique par profil dans un JSON privé pour l'utilisateur courant.
Exemple :
```go
type Profile struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
store := config.NewStore[Profile]("my-mcp")
cfg, path, err := store.LoadDefault()
if err != nil {
return err
}
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
profile := cfg.Profiles[profileName]
profile.BaseURL = "https://api.example.com"
cfg.CurrentProfile = profileName
cfg.Profiles[profileName] = profile
_, err = store.SaveDefault(cfg)
if err != nil {
return err
}
fmt.Printf("config saved to %s\n", path)
```
Notes :
- le fichier est créé avec des permissions `0600`
- le répertoire parent est forcé en `0700`
- l'écriture est atomique via un fichier temporaire puis `rename`
- si le fichier n'existe pas, `Load` et `LoadDefault` retournent une config vide par défaut
- `NewStoreWithOptions` permet de définir une version cible, des migrations JSON (`from -> to`) et une validation explicite après chargement
- une config plus récente que la version supportée, ou sans chemin de migration complet, retourne une erreur explicite
Exemple de store versionné :
```go
store := config.NewStoreWithOptions[Profile]("my-mcp", config.Options[Profile]{
Version: 2,
Migrations: map[int]config.Migration{
1: func(doc map[string]json.RawMessage) error {
return nil
},
},
Validator: func(cfg config.FileConfig[Profile]) []config.ValidationIssue {
if cfg.CurrentProfile == "" {
return []config.ValidationIssue{{
Path: "current_profile",
Message: "must not be empty",
}}
}
return nil
},
})
```

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

48
docs/getting-started.md Normal file
View file

@ -0,0 +1,48 @@
# Installation et utilisation type
## Installation
```bash
go get forge.lclr.dev/AI/mcp-framework
```
## CLI de scaffold
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
```bash
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
mcp-framework scaffold init \
--target ./my-mcp \
--module example.com/my-mcp \
--binary my-mcp \
--profiles dev,prod
```
Puis dans le projet généré :
```bash
cd my-mcp
go mod tidy
go run ./cmd/my-mcp help
```
## Utilisation type
Un flux complet côté application :
1. Déclarer `mcp.toml` à la racine du module.
2. Lancer `mcp-framework generate` pour produire le package `mcpgen`.
3. Déclarer les sous-commandes communes via `bootstrap` si l'application utilise le bootstrap CLI.
4. Résoudre le profil actif avec `cli`.
5. Charger la config versionnée avec `config`.
6. Lire les secrets avec `secretstore` ou `mcpgen.OpenSecretStore`.
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
```

3
docs/limitations.md Normal file
View file

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

133
docs/manifest.md Normal file
View file

@ -0,0 +1,133 @@
# Manifeste `mcp.toml`
Le package `manifest` cherche automatiquement `mcp.toml` dans le répertoire courant puis remonte les répertoires parents jusqu'à trouver le fichier.
Pour un binaire installé (par exemple dans `~/.local/bin`), il peut aussi charger un fallback embarqué via `LoadDefaultOrEmbedded`.
Exemple minimal :
```toml
binary_name = "my-mcp"
docs_url = "https://docs.example.com/my-mcp"
[update]
source_name = "Gitea releases"
driver = "gitea"
repository = "org/repo"
base_url = "https://gitea.example.com"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
signature_asset_name = "{asset}.sig"
signature_required = false
signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"]
token_header = "Authorization"
token_prefix = "token"
token_env_names = ["GITEA_TOKEN"]
[environment]
known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"]
[secret_store]
backend_policy = "auto"
# Optionnel : mettre false pour désactiver le cache Bitwarden.
bitwarden_cache = true
[profiles]
default = "prod"
known = ["dev", "staging", "prod"]
[bootstrap]
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 :
- `binary_name` : nom du binaire (utilisable par le bootstrap/scaffolding).
- `docs_url` : URL de documentation projet.
- `[update]` : source de release consommée par `update`.
- `source_name` : nom humain de la source de release, utilisé dans certains messages d'erreur.
- `driver` : driver de forge (`gitea`, `gitlab`, `github`) pour déduire automatiquement l'endpoint latest.
- `repository` : dépôt cible (`org/repo` ou `group/subgroup/repo`).
- `base_url` : base de la forge ou du service de release.
- `latest_release_url` : URL complète qui retourne la release la plus récente (prioritaire sur le driver).
- `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`).
- `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`.
- `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent.
- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`.
- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide.
- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature.
- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519.
- `token_header` : header HTTP à utiliser pour l'authentification.
- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...).
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
- `[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].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].known` : profils connus du projet.
- `[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.
Exemple de chargement :
```go
file, path, err := manifest.LoadDefault(".")
if err != nil {
return err
}
fmt.Printf("manifest loaded from %s\n", path)
source := file.Update.ReleaseSource()
bootstrapInfo := file.BootstrapInfo()
scaffoldInfo := file.ScaffoldInfo()
_ = bootstrapInfo
_ = scaffoldInfo
_ = source
```
Fallback fichier puis embarqué :
```go
file, source, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest)
if err != nil {
return err
}
fmt.Printf("manifest source: %s\n", source) // chemin du fichier ou "embedded:mcp.toml"
```

63
docs/minimal-example.md Normal file
View file

@ -0,0 +1,63 @@
# 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
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 {
BaseURL string `json:"base_url"`
}
func main() {
if err := run(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
cfgStore := config.NewStore[Profile](mcpgen.BinaryName)
cfg, _, err := cfgStore.LoadDefault()
if err != nil {
return err
}
_, source, err := mcpgen.LoadManifest(".")
if err != nil {
return err
}
fmt.Println("manifest:", source)
updateOptions, err := mcpgen.UpdateOptions(version, os.Stdout)
if err != nil {
return err
}
if err := update.Run(ctx, updateOptions); err != nil {
return err
}
_ = cfg
return nil
}
```

10
docs/packages.md Normal file
View file

@ -0,0 +1,10 @@
# Packages
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `login`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites.
- `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()`.
- `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).
- `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.

26
docs/scaffolding.md Normal file
View file

@ -0,0 +1,26 @@
# Scaffolding
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`)
- 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`
- `README.md` de démarrage
Exemple :
```go
result, err := scaffold.Generate(scaffold.Options{
TargetDir: "./my-mcp",
ModulePath: "forge.lclr.dev/AI/my-mcp",
BinaryName: "my-mcp",
Description: "Client MCP interne",
DefaultProfile: "prod",
Profiles: []string{"dev", "prod"},
})
if err != nil {
return err
}
fmt.Printf("Scaffold generated in %s (%d files)\n", result.Root, len(result.Files))
```

273
docs/secrets.md Normal file
View file

@ -0,0 +1,273 @@
# Secrets
Le package `secretstore` supporte plusieurs politiques de backend :
- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni
- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible
- `keyring-any` : impose l'utilisation d'un backend keyring disponible
- `env-only` : lecture seule depuis les variables d'environnement
- `bitwarden-cli` : utilise le CLI Bitwarden (`bw`) comme backend de vault
Backends keyring typiques :
- macOS : Keychain
- Linux : Secret Service ou KWallet selon l'environnement
- Windows : Credential Manager
Ouverture recommandée depuis la policy du manifeste runtime (`mcp.toml` résolu près de l'exécutable) :
```go
store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
LookupEnv: os.LookupEnv,
})
if err != nil {
return err
}
```
Exemple bas niveau :
```go
store, err := secretstore.Open(secretstore.Options{
ServiceName: "my-mcp",
BackendPolicy: secretstore.BackendAuto,
})
if err != nil {
return err
}
if err := store.SetSecret("api-token", "My MCP API token", token); err != nil {
return err
}
token, err = store.GetSecret("api-token")
switch {
case err == nil:
// secret found
case errors.Is(err, secretstore.ErrNotFound):
// first run
default:
return err
}
```
Pour imposer KWallet sur Linux :
```go
store, err := secretstore.Open(secretstore.Options{
ServiceName: "my-mcp",
BackendPolicy: secretstore.BackendKWalletOnly,
})
```
Pour imposer Bitwarden via son CLI :
```go
store, err := secretstore.Open(secretstore.Options{
ServiceName: "my-mcp",
BackendPolicy: secretstore.BackendBitwardenCLI,
// Optionnel si `bw` n'est pas dans le PATH :
// 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`) :
```go
if err := secretstore.EnsureBitwardenReady(secretstore.Options{
BitwardenCommand: "bw",
LookupEnv: os.LookupEnv,
}); err != nil {
switch {
case errors.Is(err, secretstore.ErrBWNotLoggedIn):
// guider vers `bw login`
case errors.Is(err, secretstore.ErrBWLocked):
// guider vers `bw unlock --raw` puis export BW_SESSION
default:
// indisponibilité CLI/réseau
}
return err
}
```
Pour lancer un flux interactif `bw login` / `bw unlock --raw`, récupérer `BW_SESSION`
et le persister localement (fichier `0600` sous le répertoire de config utilisateur) :
```go
session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
ServiceName: "my-mcp",
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
})
if err != nil {
return err
}
fmt.Println("session chargée:", len(session) > 0)
```
Pour réinjecter automatiquement une session persistée dans l'environnement courant :
```go
loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{
ServiceName: "my-mcp",
})
if err != nil {
return err
}
fmt.Println("session restaurée depuis disque:", loaded)
```
Pour stocker un secret structuré en JSON :
```go
type Credentials struct {
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
}
err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{
Host: "imap.example.com",
Username: "alice",
Password: token,
})
if err != nil {
return err
}
creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials")
if err != nil {
return err
}
_ = creds
```
Pour écrire puis confirmer immédiatement une relecture :
```go
if err := secretstore.SetSecretVerified(store, "api-token", "My MCP API token", token); err != nil {
return err
}
```
Pour connaître le backend effectif utilisé :
```go
effective := secretstore.EffectiveBackendPolicy(store)
fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any...
```
Pour obtenir en un seul appel une description runtime légère (source manifeste,
policy déclarée/effective, backend affiché) :
```go
desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{
ServiceName: "my-mcp",
LookupEnv: os.LookupEnv,
})
if err != nil {
return err
}
fmt.Println(secretstore.FormatBackendStatus(desc))
// declared=... effective=... display=... ready=... source=...
```
`DescribeRuntime` ne contacte pas Bitwarden par défaut. Pour vérifier réellement
la disponibilité du backend, utiliser le préflight :
```go
report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{
ServiceName: "my-mcp",
LookupEnv: os.LookupEnv,
})
if err != nil {
return err
}
fmt.Println(report.Status) // ready | fail
fmt.Println(report.Summary) // message court
fmt.Println(report.Remediation) // action recommandée
```
## Debug Bitwarden en 60 secondes
Tu peux activer les traces d'appels Bitwarden avec le flag CLI global `--debug`
(via `bootstrap`) ou en exportant `MCP_FRAMEWORK_BITWARDEN_DEBUG=1`.
Les commandes `bw` exécutées seront affichées (avec redaction des payloads sensibles).
1. Vérifier l'état de session :
```bash
bw status
```
2. Déverrouiller le vault et exporter `BW_SESSION` (ou utiliser `LoginBitwarden`) :
- Bash/Zsh :
```bash
export BW_SESSION="$(bw unlock --raw)"
```
- Fish :
```fish
set -x BW_SESSION (bw unlock --raw)
```
- PowerShell :
```powershell
$env:BW_SESSION = (bw unlock --raw)
```
3. Vérifier lecture/écriture rapide :
```go
if err := store.SetSecret("debug-token", "Debug token", "ok"); err != nil {
return err
}
_, err := store.GetSecret("debug-token")
return err
```
4. Interpréter les erreurs typées :
- `secretstore.ErrBWNotLoggedIn` : `bw login` requis.
- `secretstore.ErrBWLocked` : vault verrouillé ou `BW_SESSION` absent.
- `secretstore.ErrBWUnavailable` : CLI/réseau indisponible.
- `secretstore.ErrBackendUnavailable` : policy non satisfiable dans le contexte courant.
En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`.
Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`.

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

View file

@ -9,21 +9,91 @@ import (
"github.com/BurntSushi/toml"
"gitea.lclr.dev/AI/mcp-framework/update"
"forge.lclr.dev/AI/mcp-framework/update"
)
const DefaultFile = "mcp.toml"
const EmbeddedSource = "embedded:mcp.toml"
type File struct {
Update Update `toml:"update"`
BinaryName string `toml:"binary_name"`
DocsURL string `toml:"docs_url"`
Update Update `toml:"update"`
Environment Environment `toml:"environment"`
SecretStore SecretStore `toml:"secret_store"`
Profiles Profiles `toml:"profiles"`
Bootstrap Bootstrap `toml:"bootstrap"`
Config Config `toml:"config"`
}
type Update struct {
SourceName string `toml:"source_name"`
BaseURL string `toml:"base_url"`
LatestReleaseURL string `toml:"latest_release_url"`
TokenHeader string `toml:"token_header"`
TokenEnvNames []string `toml:"token_env_names"`
SourceName string `toml:"source_name"`
Driver string `toml:"driver"`
Repository string `toml:"repository"`
BaseURL string `toml:"base_url"`
LatestReleaseURL string `toml:"latest_release_url"`
AssetNameTemplate string `toml:"asset_name_template"`
ChecksumAssetName string `toml:"checksum_asset_name"`
ChecksumRequired bool `toml:"checksum_required"`
SignatureAssetName string `toml:"signature_asset_name"`
SignatureRequired bool `toml:"signature_required"`
SignaturePublicKey string `toml:"signature_public_key"`
SignaturePublicKeyEnvNames []string `toml:"signature_public_key_env_names"`
TokenHeader string `toml:"token_header"`
TokenPrefix string `toml:"token_prefix"`
TokenEnvNames []string `toml:"token_env_names"`
}
type Environment struct {
Known []string `toml:"known"`
}
type SecretStore struct {
BackendPolicy string `toml:"backend_policy"`
BitwardenCache *bool `toml:"bitwarden_cache"`
}
type Profiles struct {
Default string `toml:"default"`
Known []string `toml:"known"`
}
type Bootstrap struct {
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 {
BinaryName string
Description string
DocsURL string
DefaultProfile string
Profiles []string
}
type ScaffoldMetadata struct {
BinaryName string
DocsURL string
KnownEnvironmentVariables []string
SecretStorePolicy string
DefaultProfile string
Profiles []string
}
func Find(startDir string) (string, error) {
@ -68,9 +138,39 @@ func Load(path string) (File, error) {
return File{}, fmt.Errorf("read manifest %s: %w", path, err)
}
return parse(data, path)
}
func LoadEmbedded(content string) (File, string, error) {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return File{}, "", os.ErrNotExist
}
file, err := parse([]byte(trimmed), EmbeddedSource)
if err != nil {
return File{}, "", err
}
return file, EmbeddedSource, nil
}
func LoadDefaultOrEmbedded(startDir, embeddedContent string) (File, string, error) {
file, path, err := LoadDefault(startDir)
if err == nil {
return file, path, nil
}
if !errors.Is(err, os.ErrNotExist) {
return File{}, "", err
}
return LoadEmbedded(embeddedContent)
}
func parse(data []byte, source string) (File, error) {
var file File
if err := toml.Unmarshal(data, &file); err != nil {
return File{}, fmt.Errorf("parse manifest %s: %w", path, err)
return File{}, fmt.Errorf("parse manifest %s: %w", source, err)
}
file.normalize()
@ -92,32 +192,120 @@ func LoadDefault(startDir string) (File, string, error) {
}
func (f *File) normalize() {
f.BinaryName = strings.TrimSpace(f.BinaryName)
f.DocsURL = strings.TrimSpace(f.DocsURL)
f.Update.normalize()
f.Environment.normalize()
f.SecretStore.normalize()
f.Profiles.normalize()
f.Bootstrap.normalize()
f.Config.normalize()
}
func (u *Update) normalize() {
u.SourceName = strings.TrimSpace(u.SourceName)
u.Driver = strings.ToLower(strings.TrimSpace(u.Driver))
u.Repository = strings.Trim(strings.TrimSpace(u.Repository), "/")
u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/")
u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL)
u.AssetNameTemplate = strings.TrimSpace(u.AssetNameTemplate)
u.ChecksumAssetName = strings.TrimSpace(u.ChecksumAssetName)
u.SignatureAssetName = strings.TrimSpace(u.SignatureAssetName)
u.SignaturePublicKey = strings.TrimSpace(u.SignaturePublicKey)
u.SignaturePublicKeyEnvNames = normalizeStringList(u.SignaturePublicKeyEnvNames)
u.TokenHeader = strings.TrimSpace(u.TokenHeader)
u.TokenPrefix = strings.TrimSpace(u.TokenPrefix)
u.TokenEnvNames = normalizeStringList(u.TokenEnvNames)
}
envNames := u.TokenEnvNames[:0]
for _, envName := range u.TokenEnvNames {
if trimmed := strings.TrimSpace(envName); trimmed != "" {
envNames = append(envNames, trimmed)
}
func (e *Environment) normalize() {
e.Known = normalizeStringList(e.Known)
}
func (s *SecretStore) normalize() {
s.BackendPolicy = strings.TrimSpace(s.BackendPolicy)
}
func (p *Profiles) normalize() {
p.Default = strings.TrimSpace(p.Default)
p.Known = normalizeStringList(p.Known)
}
func (b *Bootstrap) normalize() {
b.Description = strings.TrimSpace(b.Description)
}
func (c *Config) normalize() {
for i := range c.Fields {
c.Fields[i].normalize()
}
u.TokenEnvNames = envNames
}
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 {
u.normalize()
return update.ReleaseSource{
Name: u.SourceName,
BaseURL: u.BaseURL,
LatestReleaseURL: u.LatestReleaseURL,
TokenHeader: u.TokenHeader,
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
Name: u.SourceName,
Driver: u.Driver,
Repository: u.Repository,
BaseURL: u.BaseURL,
LatestReleaseURL: u.LatestReleaseURL,
AssetNameTemplate: u.AssetNameTemplate,
ChecksumAssetName: u.ChecksumAssetName,
ChecksumRequired: u.ChecksumRequired,
SignatureAssetName: u.SignatureAssetName,
SignatureRequired: u.SignatureRequired,
SignaturePublicKey: u.SignaturePublicKey,
SignaturePublicKeyEnvNames: append([]string(nil), u.SignaturePublicKeyEnvNames...),
TokenHeader: u.TokenHeader,
TokenPrefix: u.TokenPrefix,
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
}
}
func (f File) BootstrapInfo() BootstrapMetadata {
f.normalize()
return BootstrapMetadata{
BinaryName: f.BinaryName,
Description: f.Bootstrap.Description,
DocsURL: f.DocsURL,
DefaultProfile: f.Profiles.Default,
Profiles: append([]string(nil), f.Profiles.Known...),
}
}
func (f File) ScaffoldInfo() ScaffoldMetadata {
f.normalize()
return ScaffoldMetadata{
BinaryName: f.BinaryName,
DocsURL: f.DocsURL,
KnownEnvironmentVariables: append([]string(nil), f.Environment.Known...),
SecretStorePolicy: f.SecretStore.BackendPolicy,
DefaultProfile: f.Profiles.Default,
Profiles: append([]string(nil), f.Profiles.Known...),
}
}
func normalizeStringList(values []string) []string {
normalized := values[:0]
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
normalized = append(normalized, trimmed)
}
}
return normalized
}

View file

@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"slices"
"testing"
)
@ -45,9 +46,19 @@ func TestLoadParsesUpdateConfig(t *testing.T) {
const content = `
[update]
source_name = " Gitea releases "
driver = " Gitea "
repository = " org/repo "
base_url = "https://gitea.example.com/"
latest_release_url = "https://gitea.example.com/api/releases/latest"
asset_name_template = "{binary}_{os}_{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
signature_asset_name = "{asset}.sig"
signature_required = true
signature_public_key = " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef "
signature_public_key_env_names = [" MCP_PUBKEY ", "", "MCP_RELEASE_PUBKEY"]
token_header = " Authorization "
token_prefix = " token "
token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
`
@ -64,15 +75,45 @@ token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
if source.Name != "Gitea releases" {
t.Fatalf("source name = %q", source.Name)
}
if source.Driver != "gitea" {
t.Fatalf("driver = %q", source.Driver)
}
if source.Repository != "org/repo" {
t.Fatalf("repository = %q", source.Repository)
}
if source.BaseURL != "https://gitea.example.com" {
t.Fatalf("base URL = %q", source.BaseURL)
}
if source.LatestReleaseURL != "https://gitea.example.com/api/releases/latest" {
t.Fatalf("latest release URL = %q", source.LatestReleaseURL)
}
if source.AssetNameTemplate != "{binary}_{os}_{arch}{ext}" {
t.Fatalf("asset name template = %q", source.AssetNameTemplate)
}
if source.ChecksumAssetName != "{asset}.sha256" {
t.Fatalf("checksum asset name = %q", source.ChecksumAssetName)
}
if !source.ChecksumRequired {
t.Fatal("checksum required should be true")
}
if source.SignatureAssetName != "{asset}.sig" {
t.Fatalf("signature asset name = %q", source.SignatureAssetName)
}
if !source.SignatureRequired {
t.Fatal("signature required should be true")
}
if source.SignaturePublicKey == "" {
t.Fatal("signature public key should be set")
}
if len(source.SignaturePublicKeyEnvNames) != 2 {
t.Fatalf("signature env names = %v", source.SignaturePublicKeyEnvNames)
}
if source.TokenHeader != "Authorization" {
t.Fatalf("token header = %q", source.TokenHeader)
}
if source.TokenPrefix != "token" {
t.Fatalf("token prefix = %q", source.TokenPrefix)
}
if len(source.TokenEnvNames) != 2 {
t.Fatalf("token env names = %v", source.TokenEnvNames)
}
@ -114,3 +155,264 @@ func TestLoadReturnsParseError(t *testing.T) {
t.Fatal("expected error")
}
}
func TestLoadParsesExtendedManifestMetadata(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
binary_name = " my-mcp "
docs_url = " https://docs.example.com/mcp "
[update]
latest_release_url = "https://example.com/latest"
[environment]
known = [" MCP_PROFILE ", "", "MCP_TOKEN"]
[secret_store]
backend_policy = " auto "
[profiles]
default = " prod "
known = [" default ", "", "prod"]
[bootstrap]
description = " Client MCP interne "
`
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.BinaryName != "my-mcp" {
t.Fatalf("binary name = %q", file.BinaryName)
}
if file.DocsURL != "https://docs.example.com/mcp" {
t.Fatalf("docs URL = %q", file.DocsURL)
}
if !slices.Equal(file.Environment.Known, []string{"MCP_PROFILE", "MCP_TOKEN"}) {
t.Fatalf("environment known = %v", file.Environment.Known)
}
if file.SecretStore.BackendPolicy != "auto" {
t.Fatalf("secret store policy = %q", file.SecretStore.BackendPolicy)
}
if file.Profiles.Default != "prod" {
t.Fatalf("default profile = %q", file.Profiles.Default)
}
if !slices.Equal(file.Profiles.Known, []string{"default", "prod"}) {
t.Fatalf("profiles known = %v", file.Profiles.Known)
}
if file.Bootstrap.Description != "Client MCP interne" {
t.Fatalf("bootstrap description = %q", file.Bootstrap.Description)
}
bootstrap := file.BootstrapInfo()
if bootstrap.BinaryName != "my-mcp" {
t.Fatalf("bootstrap binary name = %q", bootstrap.BinaryName)
}
if bootstrap.DocsURL != "https://docs.example.com/mcp" {
t.Fatalf("bootstrap docs URL = %q", bootstrap.DocsURL)
}
if bootstrap.DefaultProfile != "prod" {
t.Fatalf("bootstrap default profile = %q", bootstrap.DefaultProfile)
}
if !slices.Equal(bootstrap.Profiles, []string{"default", "prod"}) {
t.Fatalf("bootstrap profiles = %v", bootstrap.Profiles)
}
scaffold := file.ScaffoldInfo()
if scaffold.SecretStorePolicy != "auto" {
t.Fatalf("scaffold secret store policy = %q", scaffold.SecretStorePolicy)
}
if !slices.Equal(scaffold.KnownEnvironmentVariables, []string{"MCP_PROFILE", "MCP_TOKEN"}) {
t.Fatalf("scaffold known environment variables = %v", scaffold.KnownEnvironmentVariables)
}
}
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) {
file, source, err := LoadEmbedded(`
[update]
latest_release_url = "https://example.com/latest"
`)
if err != nil {
t.Fatalf("LoadEmbedded returned error: %v", err)
}
if source != EmbeddedSource {
t.Fatalf("source = %q, want %q", source, EmbeddedSource)
}
if file.Update.LatestReleaseURL != "https://example.com/latest" {
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
}
}
func TestLoadEmbeddedReturnsNotExistWhenEmpty(t *testing.T) {
_, _, err := LoadEmbedded(" ")
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("error = %v, want os.ErrNotExist", err)
}
}
func TestLoadDefaultOrEmbeddedPrefersManifestFile(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, DefaultFile)
if err := os.WriteFile(path, []byte("[update]\nlatest_release_url = \"https://example.com/from-file\"\n"), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
file, source, err := LoadDefaultOrEmbedded(root, `
[update]
latest_release_url = "https://example.com/from-embedded"
`)
if err != nil {
t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err)
}
if source != path {
t.Fatalf("source = %q, want %q", source, path)
}
if file.Update.LatestReleaseURL != "https://example.com/from-file" {
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
}
}
func TestLoadDefaultOrEmbeddedUsesEmbeddedWhenFileMissing(t *testing.T) {
file, source, err := LoadDefaultOrEmbedded(t.TempDir(), `
[update]
latest_release_url = "https://example.com/from-embedded"
`)
if err != nil {
t.Fatalf("LoadDefaultOrEmbedded returned error: %v", err)
}
if source != EmbeddedSource {
t.Fatalf("source = %q, want %q", source, EmbeddedSource)
}
if file.Update.LatestReleaseURL != "https://example.com/from-embedded" {
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
}
}

2136
scaffold/scaffold.go Normal file

File diff suppressed because it is too large Load diff

243
scaffold/scaffold_test.go Normal file
View file

@ -0,0 +1,243 @@
package scaffold
import (
"errors"
"go/parser"
"go/token"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
target := filepath.Join(t.TempDir(), "my-mcp")
result, err := Generate(Options{
TargetDir: target,
ModulePath: "example.com/acme/my-mcp",
BinaryName: "my-mcp",
Description: "Client MCP interne",
DefaultProfile: "prod",
Profiles: []string{"dev", "prod"},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if result.Root != target {
t.Fatalf("result root = %q, want %q", result.Root, target)
}
wantFiles := []string{
".gitignore",
"README.md",
"cmd/my-mcp/main.go",
"go.mod",
"install.sh",
"internal/app/app.go",
"mcp.toml",
}
if !slices.Equal(result.Files, wantFiles) {
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
}
for _, path := range wantFiles {
if _, err := os.Stat(filepath.Join(target, filepath.FromSlash(path))); err != nil {
t.Fatalf("generated file %q missing: %v", path, err)
}
}
mainGo, err := os.ReadFile(filepath.Join(target, "cmd", "my-mcp", "main.go"))
if err != nil {
t.Fatalf("ReadFile main.go: %v", err)
}
if !strings.Contains(string(mainGo), "\"example.com/acme/my-mcp/internal/app\"") {
t.Fatalf("main.go does not import internal app package")
}
if _, err := parser.ParseFile(token.NewFileSet(), "main.go", mainGo, parser.AllErrors); err != nil {
t.Fatalf("generated main.go is invalid Go: %v", err)
}
appGo, err := os.ReadFile(filepath.Join(target, "internal", "app", "app.go"))
if err != nil {
t.Fatalf("ReadFile app.go: %v", err)
}
for _, snippet := range []string{
"config.NewStore[Profile]",
"secretstore.Open(secretstore.Options",
"secretstore.EnsureBitwardenSessionEnv",
"func (r Runtime) ensureBitwardenSession() error {",
"\\033[31m",
"secretstore.LoginBitwarden",
"update.Run",
"manifest.LoadDefaultOrEmbedded",
"bootstrap.Run",
"os.Executable()",
"errors.Is(err, os.ErrNotExist)",
`var embeddedManifest = `,
"ManifestSource",
"ManifestCheck: r.manifestDoctorCheck()",
"SecretBackendPolicy: r.activeBackendPolicy()",
"Login: r.runLogin,",
"func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {",
"secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)",
"func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {",
"cli.WriteSetupSecretVerified",
} {
if !strings.Contains(string(appGo), snippet) {
t.Fatalf("app.go missing snippet %q", snippet)
}
}
if _, err := parser.ParseFile(token.NewFileSet(), "app.go", appGo, parser.AllErrors); err != nil {
t.Fatalf("generated app.go is invalid Go: %v", err)
}
manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml"))
if err != nil {
t.Fatalf("ReadFile mcp.toml: %v", err)
}
for _, snippet := range []string{
"binary_name = \"my-mcp\"",
"[update]",
"checksum_required = true",
"signature_asset_name = \"{asset}.sig\"",
"signature_required = false",
"[secret_store]",
"[environment]",
"[profiles]",
"backend_policy = \"auto\"",
} {
if !strings.Contains(string(manifestContent), snippet) {
t.Fatalf("mcp.toml missing snippet %q", snippet)
}
}
readme, err := os.ReadFile(filepath.Join(target, "README.md"))
if err != nil {
t.Fatalf("ReadFile README.md: %v", err)
}
for _, snippet := range []string{
"Arborescence générée",
"go run ./cmd/my-mcp setup",
"curl -fsSL https://<forge>/<org>/<repo>/raw/branch/main/install.sh | bash",
"internal/app/app.go",
} {
if !strings.Contains(string(readme), snippet) {
t.Fatalf("README missing snippet %q", snippet)
}
}
installScriptPath := filepath.Join(target, "install.sh")
installScript, err := os.ReadFile(installScriptPath)
if err != nil {
t.Fatalf("ReadFile install.sh: %v", err)
}
for _, snippet := range []string{
"#!/usr/bin/env bash",
`MODULE_PATH="example.com/acme/my-mcp"`,
`DEFAULT_RELEASE_REPOSITORY="org/my-mcp"`,
`load_release_config_from_manifest`,
`resolve_latest_release_url()`,
`curl_download "$release_url" "$release_json" "json"`,
`asset_name="$(resolve_asset_name "$goos" "$goarch")"`,
`Reinstaller depuis la derniere release ? (y/N)`,
"MCP Install Wizard",
`menu_select() {`,
`Utilise ↑/↓ puis Entrée.`,
`Configurer le MCP maintenant ?`,
`claude mcp add \`,
`--transport stdio \`,
`--scope "$claude_scope" \`,
`--env "${PROFILE_ENV}=${PROFILE_VALUE}" \`,
`codex mcp add \`,
`Dossier projet cible pour .codex/config.toml`,
`[mcp_servers.%s]`,
`"${PROFILE_ENV}=${PROFILE_VALUE}"`,
} {
if !strings.Contains(string(installScript), snippet) {
t.Fatalf("install.sh missing snippet %q", snippet)
}
}
info, err := os.Stat(installScriptPath)
if err != nil {
t.Fatalf("Stat install.sh: %v", err)
}
if info.Mode().Perm() != 0o755 {
t.Fatalf("install.sh mode = %o, want 755", info.Mode().Perm())
}
}
func TestGenerateUsesDefaultsFromTargetDirectory(t *testing.T) {
target := filepath.Join(t.TempDir(), "super-agent-mcp")
_, err := Generate(Options{TargetDir: target})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
goModContent, err := os.ReadFile(filepath.Join(target, "go.mod"))
if err != nil {
t.Fatalf("ReadFile go.mod: %v", err)
}
if !strings.Contains(string(goModContent), "module example.com/super-agent-mcp") {
t.Fatalf("go.mod should contain default module path")
}
manifestContent, err := os.ReadFile(filepath.Join(target, "mcp.toml"))
if err != nil {
t.Fatalf("ReadFile mcp.toml: %v", err)
}
for _, snippet := range []string{
"binary_name = \"super-agent-mcp\"",
"SUPER_AGENT_MCP_PROFILE",
"SUPER_AGENT_MCP_API_TOKEN",
} {
if !strings.Contains(string(manifestContent), snippet) {
t.Fatalf("mcp.toml missing snippet %q", snippet)
}
}
}
func TestGenerateFailsWhenFileAlreadyExistsWithoutOverwrite(t *testing.T) {
target := t.TempDir()
readmePath := filepath.Join(target, "README.md")
if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("WriteFile README.md: %v", err)
}
_, err := Generate(Options{TargetDir: target})
if !errors.Is(err, ErrFileExists) {
t.Fatalf("Generate error = %v, want ErrFileExists", err)
}
}
func TestGenerateOverwritesExistingFilesWhenRequested(t *testing.T) {
target := t.TempDir()
readmePath := filepath.Join(target, "README.md")
if err := os.WriteFile(readmePath, []byte("pre-existing"), 0o644); err != nil {
t.Fatalf("WriteFile README.md: %v", err)
}
_, err := Generate(Options{TargetDir: target, Overwrite: true})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
readmeContent, err := os.ReadFile(readmePath)
if err != nil {
t.Fatalf("ReadFile README.md: %v", err)
}
if !strings.Contains(string(readmeContent), "Démarrage rapide") {
t.Fatalf("README should be overwritten with scaffold content")
}
}
func TestGenerateRequiresTargetDirectory(t *testing.T) {
_, err := Generate(Options{})
if !errors.Is(err, ErrTargetDirRequired) {
t.Fatalf("Generate error = %v, want ErrTargetDirRequired", err)
}
}

936
secretstore/bitwarden.go Normal file
View file

@ -0,0 +1,936 @@
package secretstore
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
defaultBitwardenCommand = "bw"
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
bitwardenSessionEnvName = "BW_SESSION"
bitwardenSecretFieldName = "mcp-secret"
bitwardenServiceFieldName = "mcp-service"
bitwardenSecretNameFieldName = "mcp-secret-name"
bitwardenLoaderMessage = "Waiting BitWarden..."
bitwardenLoaderInterval = 90 * time.Millisecond
bitwardenLoaderColorBase = "\033[38;5;39m"
bitwardenLoaderColorWave = "\033[38;5;81m"
bitwardenLoaderColorFocus = "\033[38;5;117m"
bitwardenLoaderResetColor = "\033[0m"
bitwardenLoaderClearLine = "\r\033[2K"
)
type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error)
type bitwardenInteractiveRunner func(
command string,
stdin io.Reader,
stdout, stderr io.Writer,
args ...string,
) ([]byte, error)
var runBitwardenCLI bitwardenRunner = executeBitwardenCLI
var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive
var startBitwardenLoaderFunc = startBitwardenLoader
var bitwardenLoaderActive atomic.Bool
var bitwardenDebugOutput io.Writer = os.Stderr
type bitwardenStore struct {
command string
serviceName string
debug bool
lookupEnv func(string) (string, bool)
shell string
cache *bitwardenCache
}
type bitwardenListItem struct {
ID string `json:"id"`
Name string `json:"name"`
}
type bitwardenStatusOutput struct {
Status string `json:"status"`
}
func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) {
command := strings.TrimSpace(options.BitwardenCommand)
if command == "" {
command = defaultBitwardenCommand
}
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{
ServiceName: serviceName,
}); err != nil {
return nil, fmt.Errorf(
"secret backend policy %q cannot load persisted bitwarden session for service %q: %w",
policy,
serviceName,
errors.Join(ErrBackendUnavailable, err),
)
}
session, _ := os.LookupEnv(bitwardenSessionEnvName)
cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()
store := &bitwardenStore{
command: command,
serviceName: serviceName,
debug: debugEnabled,
lookupEnv: options.LookupEnv,
shell: options.Shell,
cache: newBitwardenCache(bitwardenCacheOptions{
ServiceName: serviceName,
Session: session,
TTL: defaultBitwardenCacheTTL,
CacheDir: resolveBitwardenCacheDir(serviceName),
Enabled: cacheEnabled,
}),
}
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 {
command := strings.TrimSpace(options.BitwardenCommand)
if command == "" {
command = defaultBitwardenCommand
}
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
status, err := readBitwardenStatus(command, debugEnabled)
if err != nil {
return err
}
switch status {
case "unauthenticated":
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
case "locked":
return fmt.Errorf(
"%w: run `%s` then retry",
ErrBWLocked,
unlockCommand,
)
case "unlocked":
lookupEnv := options.LookupEnv
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
session, ok := lookupEnv(bitwardenSessionEnvName)
if !ok || strings.TrimSpace(session) == "" {
session, ok = os.LookupEnv(bitwardenSessionEnvName)
}
if !ok || strings.TrimSpace(session) == "" {
return fmt.Errorf(
"%w: environment variable %q is missing; run `%s` then retry",
ErrBWLocked,
bitwardenSessionEnvName,
unlockCommand,
)
}
return nil
default:
return fmt.Errorf(
"%w: unsupported bitwarden status %q",
ErrBWUnavailable,
status,
)
}
}
func readBitwardenStatus(command string, debug bool) (string, error) {
output, err := runBitwardenCommand(command, debug, nil, "status")
if err != nil {
return "", fmt.Errorf("check bitwarden CLI status: %w", err)
}
trimmed := strings.TrimSpace(string(output))
if trimmed == "" {
return "", fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable)
}
var status bitwardenStatusOutput
if err := json.Unmarshal([]byte(trimmed), &status); err != nil {
return "", fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err))
}
return strings.ToLower(strings.TrimSpace(status.Status)), nil
}
func bitwardenUnlockRemediation(command, shellHint string) string {
unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command))
switch detectShellFlavor(shellHint) {
case "fish":
return fmt.Sprintf("set -x %s (%s)", bitwardenSessionEnvName, unlockCommand)
case "powershell":
return fmt.Sprintf("$env:%s = (%s)", bitwardenSessionEnvName, unlockCommand)
case "cmd":
return fmt.Sprintf(
"for /f \"usebackq delims=\" %%i in (`%s`) do set %s=%%i",
unlockCommand,
bitwardenSessionEnvName,
)
default:
return fmt.Sprintf("export %s=\"$(%s)\"", bitwardenSessionEnvName, unlockCommand)
}
}
func detectShellFlavor(shellHint string) string {
raw := strings.TrimSpace(shellHint)
if raw == "" {
raw = strings.TrimSpace(os.Getenv("SHELL"))
}
if raw == "" {
raw = strings.TrimSpace(os.Getenv("COMSPEC"))
}
if raw == "" && runtime.GOOS == "windows" {
return "powershell"
}
lower := strings.ToLower(strings.TrimSpace(raw))
base := strings.ToLower(filepath.Base(lower))
switch {
case strings.Contains(lower, "powershell"),
strings.Contains(lower, "pwsh"),
base == "powershell",
base == "powershell.exe",
base == "pwsh",
base == "pwsh.exe":
return "powershell"
case strings.Contains(lower, "fish"), base == "fish":
return "fish"
case strings.Contains(lower, "cmd.exe"), base == "cmd", base == "cmd.exe":
return "cmd"
default:
return "posix"
}
}
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
secretName := s.scopedName(name)
if err := s.ensureReady(); err != nil {
return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err)
}
item, payload, err := s.findItem(secretName, name)
switch {
case errors.Is(err, ErrNotFound):
template, err := s.itemTemplate()
if err != nil {
return err
}
setBitwardenSecretPayload(template, s.serviceName, name, secretName, label, secret)
encoded, err := s.encodePayload(template)
if err != nil {
return err
}
if _, err := s.execute(
fmt.Sprintf("create bitwarden item for secret %q", name),
nil,
"create",
"item",
encoded,
); err != nil {
return err
}
if s.cache != nil {
s.cache.invalidate(name, secretName)
s.cache.store(name, secretName, secret)
}
return nil
case err != nil:
return err
}
setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret)
encoded, err := s.encodePayload(payload)
if err != nil {
return err
}
if _, err := s.execute(
fmt.Sprintf("update bitwarden item for secret %q", name),
nil,
"edit",
"item",
item.ID,
encoded,
); err != nil {
return err
}
if s.cache != nil {
s.cache.invalidate(name, secretName)
s.cache.store(name, secretName, secret)
}
return nil
}
func (s *bitwardenStore) GetSecret(name string) (string, error) {
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)
if err != nil {
return "", err
}
secret, ok := readBitwardenSecret(payload)
if !ok {
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
}
func (s *bitwardenStore) DeleteSecret(name string) error {
secretName := s.scopedName(name)
if err := s.ensureReady(); err != nil {
return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err)
}
item, _, err := s.findItem(secretName, name)
if errors.Is(err, ErrNotFound) {
if s.cache != nil {
s.cache.invalidate(name, secretName)
}
return nil
}
if err != nil {
return err
}
if _, err := s.execute(
fmt.Sprintf("delete bitwarden item for secret %q", name),
nil,
"delete",
"item",
item.ID,
); err != nil {
return err
}
if s.cache != nil {
s.cache.invalidate(name, secretName)
}
return nil
}
func (s *bitwardenStore) scopedName(name string) string {
return fmt.Sprintf("%s/%s", s.serviceName, name)
}
func (s *bitwardenStore) ensureReady() error {
s.refreshSessionEnv()
return verifyBitwardenCLIReady(Options{
BitwardenCommand: s.command,
BitwardenDebug: s.debug,
LookupEnv: s.lookupEnv,
Shell: s.shell,
})
}
func (s *bitwardenStore) refreshSessionEnv() {
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil || strings.TrimSpace(session) == "" {
return
}
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
}
type bitwardenResolvedItem struct {
item bitwardenListItem
payload map[string]any
}
func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, map[string]any, error) {
output, err := s.execute(
fmt.Sprintf("list bitwarden items for secret %q", secretName),
nil,
"list",
"items",
"--search",
secretName,
)
if err != nil {
return bitwardenListItem{}, nil, err
}
if strings.TrimSpace(string(output)) == "" {
return bitwardenListItem{}, nil, ErrNotFound
}
var items []bitwardenListItem
if err := json.Unmarshal(output, &items); err != nil {
return bitwardenListItem{}, nil, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err)
}
matches := make([]bitwardenListItem, 0, len(items))
for _, item := range items {
if strings.TrimSpace(item.Name) != secretName {
continue
}
if strings.TrimSpace(item.ID) == "" {
continue
}
matches = append(matches, item)
}
if len(matches) == 0 {
return bitwardenListItem{}, nil, ErrNotFound
}
markedMatches := make([]bitwardenResolvedItem, 0, len(matches))
legacyMatches := make([]bitwardenResolvedItem, 0, len(matches))
for _, item := range matches {
payload, err := s.itemByID(item.ID)
if err != nil {
return bitwardenListItem{}, nil, err
}
resolved := bitwardenResolvedItem{
item: item,
payload: payload,
}
if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) {
markedMatches = append(markedMatches, resolved)
continue
}
legacyMatches = append(legacyMatches, resolved)
}
switch len(markedMatches) {
case 0:
switch len(legacyMatches) {
case 0:
return bitwardenListItem{}, nil, ErrNotFound
case 1:
return legacyMatches[0].item, legacyMatches[0].payload, nil
default:
return bitwardenListItem{}, nil, fmt.Errorf(
"multiple legacy bitwarden items match secret %q for service %q",
secretName,
s.serviceName,
)
}
case 1:
return markedMatches[0].item, markedMatches[0].payload, nil
default:
return bitwardenListItem{}, nil, fmt.Errorf(
"multiple bitwarden items share marker for secret %q and service %q",
secretName,
s.serviceName,
)
}
}
func (s *bitwardenStore) itemTemplate() (map[string]any, error) {
output, err := s.execute("load bitwarden item template", nil, "get", "template", "item")
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal(output, &payload); err != nil {
return nil, fmt.Errorf("decode bitwarden item template: %w", err)
}
return payload, nil
}
func (s *bitwardenStore) itemByID(id string) (map[string]any, error) {
trimmedID := strings.TrimSpace(id)
if trimmedID == "" {
return nil, errors.New("bitwarden item id must not be empty")
}
output, err := s.execute(
fmt.Sprintf("read bitwarden item %q", trimmedID),
nil,
"get",
"item",
trimmedID,
)
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal(output, &payload); err != nil {
return nil, fmt.Errorf("decode bitwarden item %q: %w", trimmedID, err)
}
return payload, nil
}
func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) {
raw, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("encode bitwarden payload: %w", err)
}
output, err := s.execute("encode bitwarden payload", raw, "encode")
if err != nil {
return "", err
}
encoded := strings.TrimSpace(string(output))
if encoded == "" {
return "", errors.New("bitwarden CLI returned an empty encoded payload")
}
return encoded, nil
}
func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) {
output, err := runBitwardenCommand(s.command, s.debug, stdin, args...)
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
return output, nil
}
func setBitwardenSecretPayload(payload map[string]any, serviceName, rawSecretName, secretName, label, secret string) {
payload["type"] = 2
payload["name"] = secretName
payload["notes"] = strings.TrimSpace(label)
payload["secureNote"] = map[string]any{"type": 0}
payload["fields"] = []map[string]any{
{
"name": bitwardenSecretFieldName,
"value": secret,
"type": 1,
},
{
"name": bitwardenServiceFieldName,
"value": strings.TrimSpace(serviceName),
"type": 0,
},
{
"name": bitwardenSecretNameFieldName,
"value": strings.TrimSpace(rawSecretName),
"type": 0,
},
}
}
func readBitwardenSecret(payload map[string]any) (string, bool) {
return readBitwardenField(payload, bitwardenSecretFieldName)
}
func readBitwardenField(payload map[string]any, fieldName string) (string, bool) {
rawFields, ok := payload["fields"]
if !ok {
return "", false
}
fields, ok := rawFields.([]any)
if !ok {
return "", false
}
for _, rawField := range fields {
field, ok := rawField.(map[string]any)
if !ok {
continue
}
name, _ := field["name"].(string)
if strings.TrimSpace(name) != strings.TrimSpace(fieldName) {
continue
}
value, ok := field["value"].(string)
if !ok {
return "", false
}
return value, true
}
return "", false
}
func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName string) bool {
markedService, ok := readBitwardenField(payload, bitwardenServiceFieldName)
if !ok {
return false
}
markedSecretName, ok := readBitwardenField(payload, bitwardenSecretNameFieldName)
if !ok {
return false
}
return strings.TrimSpace(markedService) == strings.TrimSpace(serviceName) &&
strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName)
}
func runBitwardenCommand(command string, debug bool, stdin []byte, args ...string) ([]byte, error) {
if debug {
logBitwardenCommand(command, args...)
}
return runBitwardenCLI(command, stdin, args...)
}
func runBitwardenInteractiveCommand(
command string,
debug bool,
stdin io.Reader,
stdout, stderr io.Writer,
args ...string,
) ([]byte, error) {
if debug {
logBitwardenCommand(command, args...)
}
return runBitwardenInteractiveCLI(command, stdin, stdout, stderr, args...)
}
func isBitwardenDebugEnabled(explicit bool) bool {
if explicit {
return true
}
raw, ok := os.LookupEnv(bitwardenDebugEnvName)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}
func logBitwardenCommand(command string, args ...string) {
writer := bitwardenDebugOutput
if writer == nil {
return
}
renderedArgs := sanitizeBitwardenDebugArgs(args)
if len(renderedArgs) == 0 {
_, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command))
return
}
_, _ = fmt.Fprintf(
writer,
"[bitwarden debug] %s %s\n",
strings.TrimSpace(command),
strings.Join(renderedArgs, " "),
)
}
func sanitizeBitwardenDebugArgs(args []string) []string {
if len(args) == 0 {
return nil
}
rendered := make([]string, len(args))
for idx, arg := range args {
rendered[idx] = strings.TrimSpace(arg)
}
if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" {
rendered[2] = "<redacted>"
}
if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" {
rendered[3] = "<redacted>"
}
return rendered
}
func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) {
stopLoader := startBitwardenLoaderFunc()
defer stopLoader()
cmd := exec.Command(command, args...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, normalizeBitwardenExecutionError(err, stderr.String(), stdout.String())
}
return stdout.Bytes(), nil
}
func executeBitwardenCLIInteractive(
command string,
stdin io.Reader,
stdout, stderr io.Writer,
args ...string,
) ([]byte, error) {
cmd := exec.Command(command, args...)
if stdin != nil {
cmd.Stdin = stdin
}
var stdoutBuffer bytes.Buffer
if stdout == nil {
cmd.Stdout = &stdoutBuffer
} else {
cmd.Stdout = io.MultiWriter(stdout, &stdoutBuffer)
}
var stderrBuffer bytes.Buffer
if stderr == nil {
cmd.Stderr = &stderrBuffer
} else {
cmd.Stderr = io.MultiWriter(stderr, &stderrBuffer)
}
if err := cmd.Run(); err != nil {
return nil, normalizeBitwardenExecutionError(err, stderrBuffer.String(), stdoutBuffer.String())
}
return stdoutBuffer.Bytes(), nil
}
func startBitwardenLoader() func() {
if !shouldShowBitwardenLoader() {
return func() {}
}
if !bitwardenLoaderActive.CompareAndSwap(false, true) {
return func() {}
}
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
ticker := time.NewTicker(bitwardenLoaderInterval)
defer ticker.Stop()
phase := 0
for {
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderFrame(phase))
phase++
select {
case <-done:
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderClearLine)
return
case <-ticker.C:
}
}
}()
var stopOnce sync.Once
return func() {
stopOnce.Do(func() {
close(done)
<-stopped
bitwardenLoaderActive.Store(false)
})
}
}
func shouldShowBitwardenLoader() bool {
term := strings.TrimSpace(os.Getenv("TERM"))
if term == "" || strings.EqualFold(term, "dumb") {
return false
}
info, err := os.Stdout.Stat()
if err != nil {
return false
}
return (info.Mode() & os.ModeCharDevice) != 0
}
func bitwardenLoaderFrame(phase int) string {
chars := []rune(bitwardenLoaderMessage)
if len(chars) == 0 {
return bitwardenLoaderClearLine
}
waveIndex := phase % len(chars)
if waveIndex < 0 {
waveIndex += len(chars)
}
var frame strings.Builder
frame.Grow(len(chars)*14 + len(bitwardenLoaderClearLine) + len(bitwardenLoaderResetColor))
frame.WriteString(bitwardenLoaderClearLine)
for idx, char := range chars {
frame.WriteString(bitwardenLoaderColorForIndex(idx, waveIndex, len(chars)))
frame.WriteRune(char)
}
frame.WriteString(bitwardenLoaderResetColor)
return frame.String()
}
func bitwardenLoaderColorForIndex(idx, waveIndex, length int) string {
if length <= 1 {
return bitwardenLoaderColorFocus
}
distance := idx - waveIndex
if distance < 0 {
distance = -distance
}
if wrapped := length - distance; wrapped < distance {
distance = wrapped
}
switch distance {
case 0:
return bitwardenLoaderColorFocus
case 1:
return bitwardenLoaderColorWave
default:
return bitwardenLoaderColorBase
}
}
func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error {
detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText)
classification := classifyBitwardenError(detail)
if classification == nil {
classification = ErrBWUnavailable
}
wrapped := errors.Join(classification, err)
if strings.TrimSpace(detail) == "" {
return wrapped
}
return fmt.Errorf("%w: %s", wrapped, detail)
}
func sanitizeBitwardenErrorDetail(stderrText, stdoutText string) string {
raw := strings.TrimSpace(stderrText)
if raw == "" {
raw = strings.TrimSpace(stdoutText)
}
if raw == "" {
return ""
}
lines := strings.Split(raw, "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
lower := strings.ToLower(trimmed)
if strings.HasPrefix(trimmed, "at ") ||
strings.HasPrefix(lower, "node:internal") ||
strings.HasPrefix(lower, "internal/") ||
strings.HasPrefix(lower, "npm ") {
continue
}
cleaned = append(cleaned, trimmed)
}
if len(cleaned) == 0 {
return ""
}
if len(cleaned) == 1 {
return cleaned[0]
}
return cleaned[0] + " | " + cleaned[1]
}
func classifyBitwardenError(detail string) error {
lower := strings.ToLower(strings.TrimSpace(detail))
switch {
case strings.Contains(lower, "not logged in"), strings.Contains(lower, "unauthenticated"):
return ErrBWNotLoggedIn
case strings.Contains(lower, "vault is locked"), strings.Contains(lower, "is locked"):
return ErrBWLocked
case strings.Contains(lower, "failed to fetch"),
strings.Contains(lower, "econnrefused"),
strings.Contains(lower, "etimedout"),
strings.Contains(lower, "unable to connect"),
strings.Contains(lower, "network"):
return ErrBWUnavailable
default:
return nil
}
}

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

@ -0,0 +1,229 @@
package secretstore
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const (
bitwardenSessionFileName = "bw-session"
bitwardenSharedSessionName = "mcp-framework"
)
var bitwardenUserConfigDir = os.UserConfigDir
type BitwardenSessionOptions struct {
ServiceName string
}
type BitwardenLoginOptions struct {
ServiceName string
BitwardenCommand string
BitwardenDebug bool
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
func SaveBitwardenSession(options BitwardenSessionOptions, session string) (string, error) {
trimmedSession := strings.TrimSpace(session)
if trimmedSession == "" {
return "", errors.New("bitwarden session must not be empty")
}
path, err := resolveBitwardenSessionPath(options)
if err != nil {
return "", err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create bitwarden session dir %q: %w", dir, err)
}
if err := os.Chmod(dir, 0o700); err != nil {
return "", fmt.Errorf("set bitwarden session dir permissions %q: %w", dir, err)
}
tmpFile, err := os.CreateTemp(dir, "bw-session-*.tmp")
if err != nil {
return "", fmt.Errorf("create temp bitwarden session in %q: %w", dir, err)
}
tmpPath := tmpFile.Name()
cleanup := true
defer func() {
_ = tmpFile.Close()
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if err := tmpFile.Chmod(0o600); err != nil {
return "", fmt.Errorf("set bitwarden session temp file permissions %q: %w", tmpPath, err)
}
if _, err := tmpFile.WriteString(trimmedSession + "\n"); err != nil {
return "", fmt.Errorf("write bitwarden session temp file %q: %w", tmpPath, err)
}
if err := tmpFile.Close(); err != nil {
return "", fmt.Errorf("close bitwarden session temp file %q: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
return "", fmt.Errorf("replace bitwarden session file %q: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
return "", fmt.Errorf("set bitwarden session file permissions %q: %w", path, err)
}
cleanup = false
return path, nil
}
func LoadBitwardenSession(options BitwardenSessionOptions) (string, error) {
path, err := resolveBitwardenSessionPath(options)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", ErrNotFound
}
return "", fmt.Errorf("read bitwarden session file %q: %w", path, err)
}
session := strings.TrimSpace(string(data))
if session == "" {
return "", ErrNotFound
}
return session, nil
}
func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
return false, nil
}
session, err := LoadBitwardenSession(options)
if err != nil {
if !errors.Is(err, ErrNotFound) {
return false, err
}
// Service-specific file not found; try the shared file written by any MCP login.
session, err = LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
return false, nil
}
}
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
return false, fmt.Errorf("set %s from persisted bitwarden session: %w", bitwardenSessionEnvName, err)
}
return true, nil
}
func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName == "" {
return "", errors.New("service name must not be empty")
}
command := strings.TrimSpace(options.BitwardenCommand)
if command == "" {
command = defaultBitwardenCommand
}
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
stdin := options.Stdin
if stdin == nil {
stdin = os.Stdin
}
stdout := options.Stdout
if stdout == nil {
stdout = os.Stdout
}
stderr := options.Stderr
if stderr == nil {
stderr = os.Stderr
}
status, err := readBitwardenStatus(command, debugEnabled)
if err != nil {
return "", err
}
switch status {
case "unauthenticated":
if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil {
return "", fmt.Errorf("login to bitwarden CLI: %w", err)
}
case "unlocked":
// Vault is already unlocked. Reuse an existing session to avoid calling
// bw unlock again, which would generate a new token and invalidate the
// tokens held by other running MCP processes.
if existing := loadAnyBitwardenSession(); existing != "" {
if err := os.Setenv(bitwardenSessionEnvName, existing); err != nil {
return "", fmt.Errorf("set %s from existing session: %w", bitwardenSessionEnvName, err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, existing); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err)
}
return existing, nil
}
case "locked":
default:
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
}
unlockOutput, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, nil, stderr, "unlock", "--raw")
if err != nil {
return "", fmt.Errorf("unlock bitwarden vault: %w", err)
}
session := strings.TrimSpace(string(unlockOutput))
if session == "" {
return "", errors.New("bitwarden CLI returned an empty session after unlock")
}
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err)
}
return session, nil
}
// loadAnyBitwardenSession looks for an existing valid session in: the process
// environment, then the shared file written by any MCP login.
func loadAnyBitwardenSession() string {
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
return strings.TrimSpace(session)
}
if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}); err == nil {
return session
}
return ""
}
func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) {
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName == "" {
return "", errors.New("service name must not be empty")
}
userConfigDir, err := bitwardenUserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config dir for bitwarden session: %w", err)
}
return filepath.Join(userConfigDir, serviceName, bitwardenSessionFileName), nil
}

View file

@ -0,0 +1,337 @@
package secretstore
import (
"errors"
"fmt"
"io"
"os"
"slices"
"testing"
)
func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(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":"unauthenticated"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
var calls [][]string
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
calls = append(calls, slices.Clone(args))
switch {
case len(args) == 1 && args[0] == "login":
return nil, nil
case len(args) == 2 && args[0] == "unlock" && args[1] == "--raw":
return []byte("persisted-session\n"), nil
default:
return nil, fmt.Errorf("unexpected interactive args: %v", args)
}
})
session, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "email-mcp",
BitwardenCommand: "bw",
})
if err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
if session != "persisted-session" {
t.Fatalf("session = %q, want persisted-session", session)
}
if got := os.Getenv("BW_SESSION"); got != "persisted-session" {
t.Fatalf("BW_SESSION = %q, want persisted-session", got)
}
if len(calls) != 2 {
t.Fatalf("interactive call count = %d, want 2", len(calls))
}
if !slices.Equal(calls[0], []string{"login"}) {
t.Fatalf("interactive call #1 args = %v, want [login]", calls[0])
}
if !slices.Equal(calls[1], []string{"unlock", "--raw"}) {
t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1])
}
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
}
if persisted != "persisted-session" {
t.Fatalf("shared persisted session = %q, want persisted-session", persisted)
}
}
func TestLoginBitwardenSkipsInteractiveLoginWhenAlreadyAuthenticated(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)
})
var calls [][]string
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
calls = append(calls, slices.Clone(args))
if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" {
return []byte("session-locked\n"), nil
}
return nil, fmt.Errorf("unexpected interactive args: %v", args)
})
session, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "email-mcp",
BitwardenCommand: "bw",
})
if err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
if session != "session-locked" {
t.Fatalf("session = %q, want session-locked", session)
}
if len(calls) != 1 {
t.Fatalf("interactive call count = %d, want 1", len(calls))
}
if !slices.Equal(calls[0], []string{"unlock", "--raw"}) {
t.Fatalf("interactive call args = %v, want [unlock --raw]", calls[0])
}
}
func TestBitwardenSessionEnvLoadsFromPersistedSession(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
path, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session")
if err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat persisted session file: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("session file mode = %o, want 600", got)
}
loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"})
if err != nil {
t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err)
}
if !loaded {
t.Fatal("expected session to be loaded from persisted file")
}
if got := os.Getenv("BW_SESSION"); got != "persisted-session" {
t.Fatalf("BW_SESSION = %q, want persisted-session", got)
}
}
func TestBitwardenSessionEnvDoesNotOverrideExistingValue(t *testing.T) {
t.Setenv("BW_SESSION", "from-env")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"})
if err != nil {
t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err)
}
if loaded {
t.Fatal("expected existing BW_SESSION to be kept")
}
if got := os.Getenv("BW_SESSION"); got != "from-env" {
t.Fatalf("BW_SESSION = %q, want from-env", got)
}
}
func TestOpenBitwardenCLILoadsPersistedSessionWhenEnvironmentIsMissing(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-open-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
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 _, ok := store.(*bitwardenStore); !ok {
t.Fatalf("store type = %T, want *bitwardenStore", store)
}
if got := os.Getenv("BW_SESSION"); got != "persisted-open-session" {
t.Fatalf("BW_SESSION = %q, want persisted-open-session", got)
}
}
func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) {
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
_, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
if !errors.Is(err, ErrNotFound) {
t.Fatalf("error = %v, want ErrNotFound", err)
}
}
func TestLoginBitwardenPersistsToSharedFileAfterUnlock(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"locked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" {
return []byte("shared-session\n"), nil
}
return nil, fmt.Errorf("unexpected interactive args: %v", args)
})
if _, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "graylog-mcp",
BitwardenCommand: "bw",
}); err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
shared, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
}
if shared != "shared-session" {
t.Fatalf("shared session = %q, want shared-session", shared)
}
}
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithEnvSession(t *testing.T) {
t.Setenv("BW_SESSION", "existing-session")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"unlocked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
return nil, fmt.Errorf("bw unlock must not be called when vault is already unlocked with a session: %v", args)
})
session, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "email-mcp",
BitwardenCommand: "bw",
})
if err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
if session != "existing-session" {
t.Fatalf("session = %q, want existing-session", session)
}
}
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithSharedSession(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
// graylog-mcp logged in earlier and wrote to the shared file
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "graylog-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"unlocked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
return nil, fmt.Errorf("bw unlock must not be called when shared session exists: %v", args)
})
session, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "email-mcp",
BitwardenCommand: "bw",
})
if err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
if session != "graylog-session" {
t.Fatalf("session = %q, want graylog-session (from shared file)", session)
}
// The shared session must also be persisted to the service-specific file
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
if err != nil {
t.Fatalf("LoadBitwardenSession returned error: %v", err)
}
if persisted != "graylog-session" {
t.Fatalf("email-mcp persisted session = %q, want graylog-session", persisted)
}
}
func withBitwardenInteractiveRunner(
t *testing.T,
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),
) {
t.Helper()
previous := runBitwardenInteractiveCLI
runBitwardenInteractiveCLI = runner
t.Cleanup(func() {
runBitwardenInteractiveCLI = previous
})
}
func withBitwardenUserConfigDir(t *testing.T, resolver func() (string, error)) {
t.Helper()
previous := bitwardenUserConfigDir
bitwardenUserConfigDir = resolver
t.Cleanup(func() {
bitwardenUserConfigDir = previous
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,127 @@
package secretstore
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"forge.lclr.dev/AI/mcp-framework/manifest"
)
type ManifestLoader func(startDir string) (manifest.File, string, error)
type ExecutableResolver func() (string, error)
type OpenFromManifestOptions struct {
ServiceName string
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
Shell string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
}
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
manifestPolicy, err := resolveManifestPolicy(options)
if err != nil {
return nil, err
}
return Open(Options{
ServiceName: options.ServiceName,
BackendPolicy: manifestPolicy.Policy,
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
BitwardenDebug: options.BitwardenDebug,
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache),
Shell: strings.TrimSpace(options.Shell),
})
}
func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, error) {
resolution, err := resolveManifestPolicy(options)
if err != nil {
return "", err
}
return resolution.Policy, nil
}
type manifestPolicyResolution struct {
Policy BackendPolicy
Source string
BitwardenCache bool
}
func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) {
manifestLoader := options.ManifestLoader
if manifestLoader == nil {
manifestLoader = manifest.LoadDefault
}
executableResolver := options.ExecutableResolver
if executableResolver == nil {
executableResolver = os.Executable
}
executablePath, err := executableResolver()
if err != nil {
return manifestPolicyResolution{}, fmt.Errorf("resolve executable path for manifest lookup: %w", err)
}
startDir := filepath.Dir(strings.TrimSpace(executablePath))
if strings.TrimSpace(startDir) == "" {
startDir = "."
}
file, manifestPath, err := manifestLoader(startDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return manifestPolicyResolution{
Policy: BackendAuto,
Source: "",
BitwardenCache: true,
}, nil
}
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) == "" {
return manifestPolicyResolution{
Policy: BackendAuto,
Source: strings.TrimSpace(manifestPath),
BitwardenCache: bitwardenCache,
}, nil
}
policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy))
if err != nil {
return manifestPolicyResolution{}, fmt.Errorf(
"invalid secret_store.backend_policy in manifest %q: %w",
strings.TrimSpace(manifestPath),
err,
)
}
return manifestPolicyResolution{
Policy: policy,
Source: strings.TrimSpace(manifestPath),
BitwardenCache: bitwardenCache,
}, nil
}
func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool {
return runtimeDisabled || !manifestEnabled
}

View file

@ -0,0 +1,211 @@
package secretstore
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/99designs/keyring"
"forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {
var gotStartDir string
store, err := OpenFromManifest(OpenFromManifestOptions{
ServiceName: "email-mcp",
LookupEnv: func(name string) (string, bool) {
if name == "EMAIL_TOKEN" {
return "from-env", true
}
return "", false
},
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) {
gotStartDir = startDir
return manifest.File{
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("OpenFromManifest returned error: %v", err)
}
wantDir := filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin")
if gotStartDir != wantDir {
t.Fatalf("manifest loader startDir = %q, want %q", gotStartDir, wantDir)
}
value, err := store.GetSecret("EMAIL_TOKEN")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "from-env" {
t.Fatalf("GetSecret = %q, want from-env", value)
}
}
func TestOpenFromManifestFallsBackToAutoWhenManifestIsMissing(t *testing.T) {
withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) {
t.Fatal("unexpected keyring open call")
return nil, nil
})
store, err := OpenFromManifest(OpenFromManifestOptions{
ServiceName: "email-mcp",
LookupEnv: func(name string) (string, bool) {
if name == "EMAIL_TOKEN" {
return "env-token", true
}
return "", false
},
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(string) (manifest.File, string, error) {
return manifest.File{}, "", os.ErrNotExist
},
})
if err != nil {
t.Fatalf("OpenFromManifest returned error: %v", err)
}
value, err := store.GetSecret("EMAIL_TOKEN")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "env-token" {
t.Fatalf("GetSecret = %q, want env-token", value)
}
}
func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing.T) {
_, 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: "totally-invalid"},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrInvalidBackendPolicy) {
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
}
if !strings.Contains(err.Error(), "secret_store.backend_policy") {
t.Fatalf("error = %v, want manifest policy context", err)
}
if !strings.Contains(err.Error(), manifest.DefaultFile) {
t.Fatalf("error = %v, want manifest path", err)
}
}
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) {
execErr := errors.New("boom")
_, err := OpenFromManifest(OpenFromManifestOptions{
ExecutableResolver: func() (string, error) {
return "", execErr
},
})
if !errors.Is(err, execErr) {
t.Fatalf("error = %v, want wrapped executable resolver error", err)
}
}
func TestOpenFromManifestReturnsManifestLoaderError(t *testing.T) {
loadErr := errors.New("cannot parse manifest")
_, err := OpenFromManifest(OpenFromManifestOptions{
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(string) (manifest.File, string, error) {
return manifest.File{}, "", loadErr
},
})
if !errors.Is(err, loadErr) {
t.Fatalf("error = %v, want wrapped manifest loader error", err)
}
}

214
secretstore/runtime.go Normal file
View file

@ -0,0 +1,214 @@
package secretstore
import (
"errors"
"fmt"
"strings"
)
const DefaultManifestSource = "default:auto (manifest not found)"
type DescribeRuntimeOptions struct {
ServiceName string
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
CheckReady bool
Shell string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
}
type RuntimeDescription struct {
ManifestSource string
DeclaredPolicy BackendPolicy
EffectivePolicy BackendPolicy
DisplayName string
Ready bool
ReadyError error
}
type PreflightStatus string
const (
PreflightStatusReady PreflightStatus = "ready"
PreflightStatusFail PreflightStatus = "fail"
)
type PreflightOptions = DescribeRuntimeOptions
type PreflightReport struct {
Status PreflightStatus
Summary string
Remediation string
Runtime RuntimeDescription
}
func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) {
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
ServiceName: options.ServiceName,
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
DisableBitwardenCache: options.DisableBitwardenCache,
Shell: options.Shell,
ManifestLoader: options.ManifestLoader,
ExecutableResolver: options.ExecutableResolver,
})
if err != nil {
return RuntimeDescription{}, err
}
desc := RuntimeDescription{
ManifestSource: manifestSourceLabel(resolution.Source),
DeclaredPolicy: resolution.Policy,
EffectivePolicy: resolution.Policy,
DisplayName: BackendDisplayName(resolution.Policy),
}
store, openErr := Open(Options{
ServiceName: options.ServiceName,
BackendPolicy: resolution.Policy,
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, resolution.BitwardenCache),
Shell: options.Shell,
})
if openErr != nil {
desc.Ready = false
desc.ReadyError = openErr
return desc, nil
}
desc.Ready = true
if effective := EffectiveBackendPolicy(store); strings.TrimSpace(string(effective)) != "" {
desc.EffectivePolicy = effective
desc.DisplayName = BackendDisplayName(effective)
}
if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI {
if err := verifyBitwardenCLIReady(Options{
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
LookupEnv: options.LookupEnv,
Shell: options.Shell,
}); err != nil {
desc.Ready = false
desc.ReadyError = err
}
}
return desc, nil
}
func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) {
options.CheckReady = true
desc, err := DescribeRuntime(options)
if err != nil {
return PreflightReport{}, err
}
if desc.Ready {
return PreflightReport{
Status: PreflightStatusReady,
Summary: "secret backend is ready",
Runtime: desc,
}, nil
}
summary, remediation := summarizePreflightFailure(desc.ReadyError)
return PreflightReport{
Status: PreflightStatusFail,
Summary: summary,
Remediation: remediation,
Runtime: desc,
}, nil
}
func BackendDisplayName(policy BackendPolicy) string {
switch policy {
case BackendBitwardenCLI:
return "Bitwarden CLI"
case BackendEnvOnly:
return "Environment variables"
case BackendKWalletOnly:
return "KWallet"
case BackendAuto:
return "automatic backend selection"
case BackendKeyringAny:
return BackendName()
default:
trimmed := strings.TrimSpace(string(policy))
if trimmed == "" {
return "unknown backend"
}
return trimmed
}
}
func FormatBackendStatus(desc RuntimeDescription) string {
source := manifestSourceLabel(desc.ManifestSource)
display := strings.TrimSpace(desc.DisplayName)
if display == "" {
display = BackendDisplayName(desc.EffectivePolicy)
}
effective := desc.EffectivePolicy
if strings.TrimSpace(string(effective)) == "" {
effective = desc.DeclaredPolicy
}
parts := []string{
fmt.Sprintf("declared=%s", normalizeStatusPolicy(desc.DeclaredPolicy)),
fmt.Sprintf("effective=%s", normalizeStatusPolicy(effective)),
fmt.Sprintf("display=%s", display),
fmt.Sprintf("ready=%t", desc.Ready),
fmt.Sprintf("source=%s", source),
}
if desc.ReadyError != nil {
parts = append(parts, fmt.Sprintf("error=%s", strings.TrimSpace(desc.ReadyError.Error())))
}
return strings.Join(parts, " ")
}
func summarizePreflightFailure(err error) (string, string) {
if err == nil {
return "secret backend is unavailable", ""
}
switch {
case errors.Is(err, ErrBWNotLoggedIn):
return "bitwarden login is required", strings.TrimSpace(err.Error())
case errors.Is(err, ErrBWLocked):
return "bitwarden vault is locked or BW_SESSION is missing", strings.TrimSpace(err.Error())
case errors.Is(err, ErrBWUnavailable):
return "bitwarden CLI is unavailable", strings.TrimSpace(err.Error())
case errors.Is(err, ErrBackendUnavailable):
return "secret backend is unavailable", strings.TrimSpace(err.Error())
default:
return "secret backend preflight failed", strings.TrimSpace(err.Error())
}
}
func manifestSourceLabel(source string) string {
trimmed := strings.TrimSpace(source)
if trimmed == "" {
return DefaultManifestSource
}
return trimmed
}
func normalizeStatusPolicy(policy BackendPolicy) string {
trimmed := strings.TrimSpace(string(policy))
if trimmed == "" {
return string(BackendAuto)
}
return trimmed
}

145
secretstore/runtime_test.go Normal file
View file

@ -0,0 +1,145 @@
package secretstore
import (
"errors"
"path/filepath"
"strings"
"testing"
"forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
desc, err := DescribeRuntime(DescribeRuntimeOptions{
ServiceName: "graylog-mcp",
LookupEnv: func(string) (string, bool) { return "", false },
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
return manifest.File{
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("DescribeRuntime returned error: %v", err)
}
if desc.ManifestSource == "" {
t.Fatal("ManifestSource should not be empty")
}
if desc.DeclaredPolicy != BackendEnvOnly {
t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendEnvOnly)
}
if desc.EffectivePolicy != BackendEnvOnly {
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendEnvOnly)
}
if desc.DisplayName == "" {
t.Fatal("DisplayName should not be empty")
}
if !desc.Ready {
t.Fatalf("Ready = %v, want true", desc.Ready)
}
if desc.ReadyError != nil {
t.Fatalf("ReadyError = %v, want nil", desc.ReadyError)
}
}
func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) {
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
return nil, errors.New("unexpected bitwarden invocation")
})
desc, err := DescribeRuntime(DescribeRuntimeOptions{
ServiceName: "graylog-mcp",
Shell: "fish",
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
return manifest.File{
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("DescribeRuntime returned error: %v", err)
}
if desc.DeclaredPolicy != BackendBitwardenCLI {
t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendBitwardenCLI)
}
if desc.EffectivePolicy != BackendBitwardenCLI {
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI)
}
if !desc.Ready {
t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready)
}
if desc.ReadyError != nil {
t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError)
}
}
func TestPreflightFromManifestReturnsTypedStatusAndRemediation(t *testing.T) {
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
switch {
case len(args) == 1 && args[0] == "--version":
return []byte("2026.1.0\n"), nil
case len(args) == 1 && args[0] == "status":
return []byte(`{"status":"locked"}`), nil
default:
return nil, errors.New("unexpected bitwarden invocation")
}
})
report, err := PreflightFromManifest(PreflightOptions{
ServiceName: "graylog-mcp",
Shell: "fish",
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
return manifest.File{
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("PreflightFromManifest returned error: %v", err)
}
if report.Status != PreflightStatusFail {
t.Fatalf("Status = %q, want %q", report.Status, PreflightStatusFail)
}
if !strings.Contains(strings.ToLower(report.Summary), "locked") {
t.Fatalf("Summary = %q, want lock hint", report.Summary)
}
if !strings.Contains(report.Remediation, "set -x BW_SESSION (bw unlock --raw)") {
t.Fatalf("Remediation = %q, want fish remediation", report.Remediation)
}
}
func TestFormatBackendStatusIncludesDeclaredEffectiveAndReadiness(t *testing.T) {
line := FormatBackendStatus(RuntimeDescription{
ManifestSource: "/opt/graylog-mcp/mcp.toml",
DeclaredPolicy: BackendBitwardenCLI,
EffectivePolicy: BackendBitwardenCLI,
DisplayName: "Bitwarden CLI",
Ready: false,
ReadyError: ErrBWLocked,
})
for _, needle := range []string{
"declared=bitwarden-cli",
"effective=bitwarden-cli",
"display=Bitwarden CLI",
"ready=false",
"source=/opt/graylog-mcp/mcp.toml",
"error=",
} {
if !strings.Contains(line, needle) {
t.Fatalf("line = %q, want substring %q", line, needle)
}
}
}

View file

@ -15,22 +15,31 @@ import (
var ErrNotFound = errors.New("secret not found")
var ErrBackendUnavailable = errors.New("secret backend unavailable")
var ErrReadOnly = errors.New("secret backend is read-only")
var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy")
var ErrBWNotLoggedIn = errors.New("bitwarden is not logged in")
var ErrBWLocked = errors.New("bitwarden vault is locked or BW_SESSION is missing")
var ErrBWUnavailable = errors.New("bitwarden CLI unavailable")
type BackendPolicy string
const (
BackendAuto BackendPolicy = "auto"
BackendKWalletOnly BackendPolicy = "kwallet-only"
BackendKeyringAny BackendPolicy = "keyring-any"
BackendEnvOnly BackendPolicy = "env-only"
BackendAuto BackendPolicy = "auto"
BackendKWalletOnly BackendPolicy = "kwallet-only"
BackendKeyringAny BackendPolicy = "keyring-any"
BackendEnvOnly BackendPolicy = "env-only"
BackendBitwardenCLI BackendPolicy = "bitwarden-cli"
)
type Options struct {
ServiceName string
BackendPolicy BackendPolicy
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
ServiceName string
BackendPolicy BackendPolicy
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
DisableBitwardenCache bool
Shell string
}
type Store interface {
@ -77,15 +86,9 @@ var (
)
func Open(options Options) (Store, error) {
policy := options.BackendPolicy
if policy == "" {
policy = BackendAuto
}
switch policy {
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
default:
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
policy, err := normalizeBackendPolicy(options.BackendPolicy)
if err != nil {
return nil, err
}
if policy == BackendEnvOnly {
@ -97,6 +100,10 @@ func Open(options Options) (Store, error) {
return nil, errors.New("service name must not be empty")
}
if policy == BackendBitwardenCLI {
return newBitwardenStore(options, policy, serviceName)
}
available := availableKeyringPolicy()
allowed, err := allowedBackends(policy, available)
if err != nil {
@ -139,6 +146,40 @@ func BackendName() string {
}
}
func EffectiveBackendPolicy(store Store) BackendPolicy {
switch store.(type) {
case *bitwardenStore:
return BackendBitwardenCLI
case *envStore:
return BackendEnvOnly
case *keyringStore:
return BackendKeyringAny
default:
return ""
}
}
func SetSecretVerified(store Store, name, label, secret string) error {
if store == nil {
return errors.New("secret store must not be nil")
}
if err := store.SetSecret(name, label, secret); err != nil {
return err
}
verified, err := store.GetSecret(name)
if err != nil {
return fmt.Errorf("verify secret %q after write: %w", name, err)
}
if verified != secret {
return fmt.Errorf("verify secret %q after write: read-back mismatch", name)
}
return nil
}
func SetJSON[T any](store Store, name, label string, value T) error {
data, err := json.Marshal(value)
if err != nil {
@ -249,7 +290,7 @@ func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]k
}
return []keyring.BackendType{keyring.KWalletBackend}, nil
default:
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
return nil, invalidBackendPolicyError(policy)
}
}
@ -260,3 +301,21 @@ func backendNames(backends []keyring.BackendType) []string {
}
return names
}
func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) {
trimmed := BackendPolicy(strings.TrimSpace(string(policy)))
if trimmed == "" {
return BackendAuto, nil
}
switch trimmed {
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI:
return trimmed, nil
default:
return "", invalidBackendPolicyError(trimmed)
}
}
func invalidBackendPolicyError(policy BackendPolicy) error {
return fmt.Errorf("%w %q", ErrInvalidBackendPolicy, policy)
}

View file

@ -80,6 +80,9 @@ func TestOpenRejectsInvalidPolicy(t *testing.T) {
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrInvalidBackendPolicy) {
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
}
}
func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) {
@ -245,3 +248,88 @@ func TestJSONHelpersRoundTrip(t *testing.T) {
t.Fatalf("GetJSON = %#v, want %#v", output, input)
}
}
func TestEffectiveBackendPolicyReportsConcreteBackend(t *testing.T) {
t.Run("env-only", func(t *testing.T) {
store, err := Open(Options{
BackendPolicy: BackendEnvOnly,
LookupEnv: func(string) (string, bool) { return "", false },
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if got := EffectiveBackendPolicy(store); got != BackendEnvOnly {
t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendEnvOnly)
}
})
t.Run("keyring", func(t *testing.T) {
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
return &stubKeyring{}, nil
})
store, err := Open(Options{
ServiceName: "mcp-framework-test",
BackendPolicy: BackendAuto,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if got := EffectiveBackendPolicy(store); got != BackendKeyringAny {
t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendKeyringAny)
}
})
}
func TestSetSecretVerifiedWritesThenReadsBack(t *testing.T) {
ring := &stubKeyring{}
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
return ring, nil
})
store, err := Open(Options{
ServiceName: "mcp-framework-test",
BackendPolicy: BackendAuto,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if err := SetSecretVerified(store, "token", "API token", "secret-value"); err != nil {
t.Fatalf("SetSecretVerified returned error: %v", err)
}
}
func TestSetSecretVerifiedFailsOnReadBackMismatch(t *testing.T) {
store := &mismatchSecretStore{}
err := SetSecretVerified(store, "token", "API token", "secret-value")
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrNotFound) {
t.Fatalf("error = %v, want wrapped ErrNotFound", err)
}
if store.setCalls != 1 {
t.Fatalf("setCalls = %d, want 1", store.setCalls)
}
}
type mismatchSecretStore struct {
setCalls int
}
func (s *mismatchSecretStore) SetSecret(name, label, secret string) error {
s.setCalls++
return nil
}
func (s *mismatchSecretStore) GetSecret(name string) (string, error) {
return "", ErrNotFound
}
func (s *mismatchSecretStore) DeleteSecret(name string) error {
return nil
}

View file

@ -1,7 +1,12 @@
package update
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -9,31 +14,64 @@ import (
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}"
const defaultMaxDownloadBytes int64 = 200 * 1024 * 1024
const downloadedArtifactSniffBytes = 4096
type Options struct {
Client *http.Client
CurrentVersion string
ExecutablePath string
LatestReleaseURL string
Stdout io.Writer
BinaryName string
ReleaseSource ReleaseSource
GOOS string
GOARCH string
Client *http.Client
CurrentVersion string
ExecutablePath string
LatestReleaseURL string
Stdout io.Writer
BinaryName string
AssetNameTemplate string
ReleaseSource ReleaseSource
GOOS string
GOARCH string
MaxDownloadBytes int64
ValidateDownloaded ValidateDownloadedFunc
ReplaceExecutable ReplaceExecutableFunc
}
type ReplaceExecutableFunc func(downloadPath, targetPath string) error
type ValidateDownloadedFunc func(context.Context, ValidationInput) error
type ValidationInput struct {
DownloadPath string
TargetPath string
AssetName string
ReleaseTag string
ReleaseURL string
Source ReleaseSource
}
type ReleaseSource struct {
Name string
BaseURL string
LatestReleaseURL string
Token string
TokenHeader string
TokenEnvNames []string
Name string
Driver string
Repository string
BaseURL string
LatestReleaseURL string
AssetNameTemplate string
ChecksumAssetName string
ChecksumRequired bool
SignatureAssetName string
SignatureRequired bool
SignaturePublicKey string
SignaturePublicKeyEnvNames []string
Token string
TokenHeader string
TokenPrefix string
TokenEnvNames []string
}
type Auth struct {
@ -53,6 +91,33 @@ type ReleaseLink struct {
URL string `json:"url"`
}
type releasePayload struct {
TagName string `json:"tag_name"`
Assets json.RawMessage `json:"assets"`
}
type releaseAssetsPayload struct {
Links []releaseLinkPayload `json:"links"`
}
type releaseLinkPayload struct {
Name string `json:"name"`
URL string `json:"url"`
BrowserDownloadURL string `json:"browser_download_url"`
DirectAssetURL string `json:"direct_asset_url"`
}
func (r *Release) UnmarshalJSON(data []byte) error {
var payload releasePayload
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
r.TagName = strings.TrimSpace(payload.TagName)
r.Assets.Links = parseReleaseLinks(payload.Assets)
return nil
}
func Run(ctx context.Context, opts Options) error {
if opts.Stdout == nil {
opts.Stdout = io.Discard
@ -69,16 +134,16 @@ func Run(ctx context.Context, opts Options) error {
if strings.TrimSpace(opts.GOARCH) == "" {
opts.GOARCH = runtime.GOARCH
}
if opts.MaxDownloadBytes <= 0 {
opts.MaxDownloadBytes = defaultMaxDownloadBytes
}
source := normalizeSource(opts.ReleaseSource)
auth := ResolveAuth(source.Token, source)
releaseURL := opts.LatestReleaseURL
if strings.TrimSpace(releaseURL) == "" {
releaseURL = strings.TrimSpace(source.LatestReleaseURL)
}
if releaseURL == "" {
return errors.New("latest release URL must not be empty")
releaseURL, err := ResolveLatestReleaseURL(opts.LatestReleaseURL, source)
if err != nil {
return err
}
targetPath, err := ResolveUpdateTarget(opts.ExecutablePath)
@ -86,7 +151,12 @@ func Run(ctx context.Context, opts Options) error {
return err
}
assetName, err := AssetName(opts.BinaryName, opts.GOOS, opts.GOARCH)
assetTemplate := strings.TrimSpace(opts.AssetNameTemplate)
if assetTemplate == "" {
assetTemplate = source.AssetNameTemplate
}
assetName, err := AssetNameWithTemplate(opts.BinaryName, opts.GOOS, opts.GOARCH, assetTemplate)
if err != nil {
return err
}
@ -105,13 +175,41 @@ func Run(ctx context.Context, opts Options) error {
return err
}
downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source)
downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source, opts.MaxDownloadBytes)
if err != nil {
return err
}
defer os.Remove(downloadPath)
if err := ReplaceExecutable(downloadPath, targetPath); err != nil {
if err := validateDownloadedArtifact(downloadPath, assetName); err != nil {
return err
}
if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil {
return err
}
if err := VerifyReleaseAssetSignature(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil {
return err
}
if opts.ValidateDownloaded != nil {
if err := opts.ValidateDownloaded(ctx, ValidationInput{
DownloadPath: downloadPath,
TargetPath: targetPath,
AssetName: assetName,
ReleaseTag: release.TagName,
ReleaseURL: releaseURL,
Source: source,
}); err != nil {
return fmt.Errorf("validate downloaded artifact: %w", err)
}
}
replaceExecutable := opts.ReplaceExecutable
if replaceExecutable == nil {
replaceExecutable = ReplaceExecutable
}
if err := replaceExecutable(downloadPath, targetPath); err != nil {
return err
}
@ -119,16 +217,101 @@ func Run(ctx context.Context, opts Options) error {
return nil
}
func validateDownloadedArtifact(path, assetName string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("validate downloaded artifact %q: %w", path, err)
}
defer file.Close()
head := make([]byte, downloadedArtifactSniffBytes)
n, readErr := file.Read(head)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return fmt.Errorf("validate downloaded artifact %q: %w", path, readErr)
}
if n == 0 {
return fmt.Errorf("downloaded artifact %q is empty", assetName)
}
if looksLikeHTMLDocument(head[:n]) {
return fmt.Errorf(
"downloaded artifact %q looks like an HTML page (possible auth/forbidden response)",
assetName,
)
}
return nil
}
func looksLikeHTMLDocument(content []byte) bool {
if len(content) == 0 {
return false
}
detectedContentType := strings.ToLower(http.DetectContentType(content))
if strings.HasPrefix(detectedContentType, "text/html") {
return true
}
trimmed := bytes.TrimSpace(content)
trimmed = bytes.TrimPrefix(trimmed, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM
if len(trimmed) == 0 {
return false
}
lower := strings.ToLower(string(trimmed))
return strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html")
}
func ResolveLatestReleaseURL(explicit string, source ReleaseSource) (string, error) {
if releaseURL := strings.TrimSpace(explicit); releaseURL != "" {
return releaseURL, nil
}
source = normalizeSource(source)
if source.LatestReleaseURL != "" {
return source.LatestReleaseURL, nil
}
if source.Driver == "" {
return "", errors.New("latest release URL must not be empty (set latest_release_url or configure driver+repository)")
}
if source.Repository == "" {
return "", fmt.Errorf("release source %q requires repository when driver is set", source.Driver)
}
switch source.Driver {
case "gitea":
if source.BaseURL == "" {
return "", errors.New("release source gitea requires base_url")
}
return fmt.Sprintf("%s/api/v1/repos/%s/releases/latest", source.BaseURL, source.Repository), nil
case "gitlab":
projectPath := url.PathEscape(source.Repository)
return fmt.Sprintf("%s/api/v4/projects/%s/releases/permalink/latest", source.BaseURL, projectPath), nil
case "github":
return fmt.Sprintf("%s/repos/%s/releases/latest", source.BaseURL, source.Repository), nil
default:
return "", fmt.Errorf("unsupported release driver %q (expected gitea, gitlab or github)", source.Driver)
}
}
func ResolveAuth(explicitToken string, source ReleaseSource) Auth {
source = normalizeSource(source)
if token := strings.TrimSpace(explicitToken); token != "" {
return Auth{Header: source.TokenHeader, Token: token}
return Auth{
Header: source.TokenHeader,
Token: withTokenPrefix(token, source.TokenPrefix),
}
}
for _, envName := range source.TokenEnvNames {
if token := strings.TrimSpace(os.Getenv(envName)); token != "" {
return Auth{Header: source.TokenHeader, Token: token}
return Auth{
Header: source.TokenHeader,
Token: withTokenPrefix(token, source.TokenPrefix),
}
}
}
@ -153,23 +336,46 @@ func ResolveUpdateTarget(explicitPath string) (string, error) {
}
func AssetName(binaryName, goos, goarch string) (string, error) {
return AssetNameWithTemplate(binaryName, goos, goarch, defaultAssetNameTemplate)
}
func AssetNameWithTemplate(binaryName, goos, goarch, template string) (string, error) {
name := strings.TrimSpace(binaryName)
if name == "" {
return "", errors.New("binary name must not be empty")
}
switch {
case goos == "darwin" && goarch == "amd64":
return name + "-darwin-amd64", nil
case goos == "darwin" && goarch == "arm64":
return name + "-darwin-arm64", nil
case goos == "linux" && goarch == "amd64":
return name + "-linux-amd64", nil
case goos == "windows" && goarch == "amd64":
return name + "-windows-amd64.exe", nil
default:
return "", fmt.Errorf("no release artifact for %s/%s", goos, goarch)
osName := strings.ToLower(strings.TrimSpace(goos))
archName := strings.ToLower(strings.TrimSpace(goarch))
if osName == "" || archName == "" {
return "", errors.New("goos and goarch must not be empty")
}
assetTemplate := strings.TrimSpace(template)
if assetTemplate == "" {
assetTemplate = defaultAssetNameTemplate
}
ext := ""
if osName == "windows" {
ext = ".exe"
}
replaced := strings.NewReplacer(
"{binary}", name,
"{os}", osName,
"{arch}", archName,
"{ext}", ext,
).Replace(assetTemplate)
replaced = strings.TrimSpace(replaced)
if replaced == "" {
return "", errors.New("asset name template resolved to an empty value")
}
if strings.ContainsRune(replaced, '/') || strings.ContainsRune(replaced, '\\') {
return "", fmt.Errorf("asset name %q must not contain path separators", replaced)
}
return replaced, nil
}
func FetchLatestRelease(ctx context.Context, client *http.Client, releaseURL string, auth Auth, source ReleaseSource) (Release, error) {
@ -232,10 +438,40 @@ func (r Release) AssetURL(assetName, releaseURL string) (string, error) {
}
}
return "", fmt.Errorf("latest release does not contain asset %q", assetName)
availableAssets := make([]string, 0, len(r.Assets.Links))
for _, link := range r.Assets.Links {
if name := strings.TrimSpace(link.Name); name != "" {
availableAssets = append(availableAssets, name)
}
}
sort.Strings(availableAssets)
if len(availableAssets) == 0 {
return "", fmt.Errorf("latest release does not contain asset %q", assetName)
}
preview := availableAssets
if len(preview) > 8 {
preview = preview[:8]
}
return "", fmt.Errorf(
"latest release does not contain asset %q (available: %s)",
assetName,
strings.Join(preview, ", "),
)
}
func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source ReleaseSource) (string, error) {
func DownloadReleaseAsset(
ctx context.Context,
client *http.Client,
assetURL, targetPath string,
auth Auth,
source ReleaseSource,
maxDownloadBytes int64,
) (string, error) {
if maxDownloadBytes <= 0 {
maxDownloadBytes = defaultMaxDownloadBytes
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil)
if err != nil {
return "", fmt.Errorf("build artifact download request: %w", err)
@ -260,6 +496,13 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta
strings.TrimSpace(string(body)),
)
}
if resp.ContentLength > 0 && resp.ContentLength > maxDownloadBytes {
return "", fmt.Errorf(
"download release artifact: content length %d exceeds limit %d bytes",
resp.ContentLength,
maxDownloadBytes,
)
}
existingInfo, err := os.Stat(targetPath)
if err != nil {
@ -278,9 +521,14 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta
return "", copyErr
}
if _, err := io.Copy(tempFile, resp.Body); err != nil {
limited := &io.LimitedReader{R: resp.Body, N: maxDownloadBytes + 1}
written, err := io.Copy(tempFile, limited)
if err != nil {
return cleanup(fmt.Errorf("write downloaded artifact: %w", err))
}
if written > maxDownloadBytes {
return cleanup(fmt.Errorf("write downloaded artifact: size exceeds limit %d bytes", maxDownloadBytes))
}
if err := tempFile.Chmod(existingInfo.Mode().Perm()); err != nil {
return cleanup(fmt.Errorf("set executable mode on downloaded artifact: %w", err))
}
@ -292,9 +540,121 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta
return tempPath, nil
}
func VerifyReleaseAssetChecksum(
ctx context.Context,
client *http.Client,
release Release,
releaseURL string,
assetName string,
artifactPath string,
auth Auth,
source ReleaseSource,
) error {
source = normalizeSource(source)
checksumAssetName := resolveChecksumAssetName(assetName, source.ChecksumAssetName)
checksumURL, err := release.AssetURL(checksumAssetName, releaseURL)
if err != nil {
if source.ChecksumRequired {
return fmt.Errorf("checksum verification: %w", err)
}
return nil
}
checksumBody, err := downloadAssetBytes(ctx, client, checksumURL, auth, source)
if err != nil {
return fmt.Errorf("checksum verification: %w", err)
}
expected, err := parseChecksum(string(checksumBody), assetName)
if err != nil {
return fmt.Errorf("checksum verification: %w", err)
}
actual, err := fileSHA256(artifactPath)
if err != nil {
return fmt.Errorf("checksum verification: %w", err)
}
if !strings.EqualFold(expected, actual) {
return fmt.Errorf(
"checksum mismatch for asset %q: expected %s, got %s",
assetName,
expected,
actual,
)
}
return nil
}
func VerifyReleaseAssetSignature(
ctx context.Context,
client *http.Client,
release Release,
releaseURL string,
assetName string,
artifactPath string,
auth Auth,
source ReleaseSource,
) error {
source = normalizeSource(source)
publicKey, hasPublicKey, err := resolveEd25519PublicKey(source.SignaturePublicKey, source.SignaturePublicKeyEnvNames)
if err != nil {
return fmt.Errorf("signature verification: %w", err)
}
if !hasPublicKey {
if source.SignatureRequired {
if len(source.SignaturePublicKeyEnvNames) > 0 {
return fmt.Errorf(
"signature verification: no Ed25519 public key configured (set %s)",
strings.Join(source.SignaturePublicKeyEnvNames, " or "),
)
}
return errors.New("signature verification: no Ed25519 public key configured")
}
return nil
}
signatureAssetName := resolveSignatureAssetName(assetName, source.SignatureAssetName)
signatureURL, err := release.AssetURL(signatureAssetName, releaseURL)
if err != nil {
if source.SignatureRequired {
return fmt.Errorf("signature verification: %w", err)
}
return nil
}
signatureBody, err := downloadAssetBytes(ctx, client, signatureURL, auth, source)
if err != nil {
return fmt.Errorf("signature verification: %w", err)
}
signature, err := parseEd25519Signature(string(signatureBody), assetName)
if err != nil {
return fmt.Errorf("signature verification: %w", err)
}
digestHex, err := fileSHA256(artifactPath)
if err != nil {
return fmt.Errorf("signature verification: %w", err)
}
digest, err := hex.DecodeString(digestHex)
if err != nil {
return fmt.Errorf("signature verification: decode local artifact digest: %w", err)
}
if !ed25519.Verify(publicKey, digest, signature) {
return fmt.Errorf("signature mismatch for asset %q", assetName)
}
return nil
}
func ReplaceExecutable(downloadPath, targetPath string) error {
if runtime.GOOS == "windows" {
return errors.New("self-update is not supported on windows")
return errors.New("self-update is not supported on windows without a custom ReplaceExecutable hook")
}
if err := os.Rename(downloadPath, targetPath); err != nil {
return fmt.Errorf("replace executable %q: %w", targetPath, err)
@ -304,9 +664,79 @@ func ReplaceExecutable(downloadPath, targetPath string) error {
func normalizeSource(source ReleaseSource) ReleaseSource {
source.Name = strings.TrimSpace(source.Name)
source.Driver = strings.ToLower(strings.TrimSpace(source.Driver))
source.Repository = strings.Trim(strings.TrimSpace(source.Repository), "/")
source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/")
source.LatestReleaseURL = strings.TrimSpace(source.LatestReleaseURL)
source.AssetNameTemplate = strings.TrimSpace(source.AssetNameTemplate)
source.ChecksumAssetName = strings.TrimSpace(source.ChecksumAssetName)
source.SignatureAssetName = strings.TrimSpace(source.SignatureAssetName)
source.SignaturePublicKey = strings.TrimSpace(source.SignaturePublicKey)
source.Token = strings.TrimSpace(source.Token)
source.TokenHeader = strings.TrimSpace(source.TokenHeader)
source.TokenPrefix = strings.TrimSpace(source.TokenPrefix)
envNames := source.TokenEnvNames[:0]
for _, envName := range source.TokenEnvNames {
if trimmed := strings.TrimSpace(envName); trimmed != "" {
envNames = append(envNames, trimmed)
}
}
source.TokenEnvNames = envNames
publicKeyEnvNames := source.SignaturePublicKeyEnvNames[:0]
for _, envName := range source.SignaturePublicKeyEnvNames {
if trimmed := strings.TrimSpace(envName); trimmed != "" {
publicKeyEnvNames = append(publicKeyEnvNames, trimmed)
}
}
source.SignaturePublicKeyEnvNames = publicKeyEnvNames
switch source.Driver {
case "gitea":
if source.Name == "" {
source.Name = "Gitea releases"
}
if source.TokenHeader == "" {
source.TokenHeader = "Authorization"
}
if source.TokenPrefix == "" {
source.TokenPrefix = "token "
}
if len(source.TokenEnvNames) == 0 {
source.TokenEnvNames = []string{"GITEA_TOKEN"}
}
case "gitlab":
if source.Name == "" {
source.Name = "GitLab releases"
}
if source.BaseURL == "" {
source.BaseURL = "https://gitlab.com"
}
if source.TokenHeader == "" {
source.TokenHeader = "PRIVATE-TOKEN"
}
if len(source.TokenEnvNames) == 0 {
source.TokenEnvNames = []string{"GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"}
}
case "github":
if source.Name == "" {
source.Name = "GitHub releases"
}
if source.BaseURL == "" {
source.BaseURL = "https://api.github.com"
}
if source.TokenHeader == "" {
source.TokenHeader = "Authorization"
}
if source.TokenPrefix == "" {
source.TokenPrefix = "Bearer "
}
if len(source.TokenEnvNames) == 0 {
source.TokenEnvNames = []string{"GITHUB_TOKEN"}
}
}
return source
}
@ -375,3 +805,338 @@ func (a Auth) maybeHint(statusCode int, body []byte, source ReleaseSource) error
source.TokenEnvNames[1],
)
}
func parseReleaseLinks(raw json.RawMessage) []ReleaseLink {
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
return nil
}
parseLinks := func(payload []releaseLinkPayload) []ReleaseLink {
links := make([]ReleaseLink, 0, len(payload))
for _, item := range payload {
name := strings.TrimSpace(item.Name)
assetURL := firstNonEmpty(item.DirectAssetURL, item.BrowserDownloadURL, item.URL)
if name == "" || strings.TrimSpace(assetURL) == "" {
continue
}
links = append(links, ReleaseLink{
Name: name,
URL: strings.TrimSpace(assetURL),
})
}
return links
}
var asObject releaseAssetsPayload
if err := json.Unmarshal(raw, &asObject); err == nil && len(asObject.Links) > 0 {
return parseLinks(asObject.Links)
}
var asArray []releaseLinkPayload
if err := json.Unmarshal(raw, &asArray); err == nil && len(asArray) > 0 {
return parseLinks(asArray)
}
return nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func withTokenPrefix(token, prefix string) string {
trimmedToken := strings.TrimSpace(token)
if trimmedToken == "" {
return ""
}
trimmedPrefix := strings.TrimSpace(prefix)
if trimmedPrefix == "" {
return trimmedToken
}
lowerToken := strings.ToLower(trimmedToken)
lowerPrefix := strings.ToLower(trimmedPrefix)
if strings.HasPrefix(lowerToken, lowerPrefix) {
return trimmedToken
}
return trimmedPrefix + " " + trimmedToken
}
func resolveChecksumAssetName(assetName, configured string) string {
value := strings.TrimSpace(configured)
if value == "" {
return assetName + ".sha256"
}
return strings.ReplaceAll(value, "{asset}", assetName)
}
func resolveSignatureAssetName(assetName, configured string) string {
value := strings.TrimSpace(configured)
if value == "" {
return assetName + ".sig"
}
return strings.ReplaceAll(value, "{asset}", assetName)
}
func downloadAssetBytes(ctx context.Context, client *http.Client, assetURL string, auth Auth, source ReleaseSource) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil)
if err != nil {
return nil, fmt.Errorf("build checksum download request: %w", err)
}
req.Header.Set("User-Agent", "mcp updater")
auth.apply(req)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("download checksum asset: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if hint := auth.maybeHint(resp.StatusCode, body, source); hint != nil {
return nil, fmt.Errorf("download checksum asset: %w", hint)
}
return nil, fmt.Errorf(
"download checksum asset: unexpected status %d: %s",
resp.StatusCode,
strings.TrimSpace(string(body)),
)
}
content, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return nil, fmt.Errorf("read checksum asset: %w", err)
}
return content, nil
}
func parseChecksum(content, assetName string) (string, error) {
lines := strings.Split(content, "\n")
fallbackSingle := ""
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(strings.ToUpper(line), "SHA256 (") {
openIndex := strings.Index(line, "(")
closeIndex := strings.LastIndex(line, ")")
equalIndex := strings.LastIndex(line, "=")
if openIndex >= 0 && closeIndex > openIndex && equalIndex > closeIndex {
name := strings.TrimSpace(line[openIndex+1 : closeIndex])
hash := strings.TrimSpace(line[equalIndex+1:])
if isSHA256Hex(hash) && matchesAssetName(name, assetName) {
return strings.ToLower(hash), nil
}
}
continue
}
fields := strings.Fields(line)
if len(fields) > 0 && isSHA256Hex(fields[0]) {
if len(fields) == 1 {
if fallbackSingle == "" {
fallbackSingle = strings.ToLower(fields[0])
}
continue
}
name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*"))
if matchesAssetName(name, assetName) {
return strings.ToLower(fields[0]), nil
}
continue
}
colonIndex := strings.Index(line, ":")
if colonIndex > 0 && colonIndex < len(line)-1 {
left := strings.TrimSpace(line[:colonIndex])
right := strings.TrimSpace(line[colonIndex+1:])
switch {
case isSHA256Hex(left) && matchesAssetName(right, assetName):
return strings.ToLower(left), nil
case isSHA256Hex(right) && matchesAssetName(left, assetName):
return strings.ToLower(right), nil
}
}
}
if fallbackSingle != "" {
return fallbackSingle, nil
}
return "", fmt.Errorf("checksum file does not contain a sha256 for asset %q", assetName)
}
func parseEd25519Signature(content, assetName string) ([]byte, error) {
lines := strings.Split(content, "\n")
var fallbackSingle []byte
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) > 0 {
if signature, ok := parseEd25519SignatureToken(fields[0]); ok {
if len(fields) == 1 {
if fallbackSingle == nil {
fallbackSingle = signature
}
continue
}
name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*"))
if matchesAssetName(name, assetName) {
return signature, nil
}
}
}
colonIndex := strings.Index(line, ":")
if colonIndex > 0 && colonIndex < len(line)-1 {
left := strings.TrimSpace(line[:colonIndex])
right := strings.TrimSpace(line[colonIndex+1:])
if signature, ok := parseEd25519SignatureToken(left); ok && matchesAssetName(right, assetName) {
return signature, nil
}
if signature, ok := parseEd25519SignatureToken(right); ok && matchesAssetName(left, assetName) {
return signature, nil
}
}
}
if fallbackSingle != nil {
return fallbackSingle, nil
}
return nil, fmt.Errorf("signature file does not contain a valid Ed25519 signature for asset %q", assetName)
}
func parseEd25519SignatureToken(value string) ([]byte, bool) {
decoded, err := decodeBinaryValue(value, ed25519.SignatureSize)
if err != nil {
return nil, false
}
return decoded, true
}
func resolveEd25519PublicKey(explicit string, envNames []string) (ed25519.PublicKey, bool, error) {
key := strings.TrimSpace(explicit)
if key != "" {
publicKey, err := parseEd25519PublicKey(key)
if err != nil {
return nil, false, fmt.Errorf("parse ed25519 public key: %w", err)
}
return publicKey, true, nil
}
for _, envName := range envNames {
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
publicKey, err := parseEd25519PublicKey(value)
if err != nil {
return nil, false, fmt.Errorf("parse ed25519 public key from %s: %w", envName, err)
}
return publicKey, true, nil
}
}
return nil, false, nil
}
func parseEd25519PublicKey(value string) (ed25519.PublicKey, error) {
decoded, err := decodeBinaryValue(value, ed25519.PublicKeySize)
if err != nil {
return nil, err
}
return ed25519.PublicKey(decoded), nil
}
func decodeBinaryValue(value string, expectedLength int) ([]byte, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, errors.New("value must not be empty")
}
decoders := []func(string) ([]byte, error){
hex.DecodeString,
base64.StdEncoding.DecodeString,
base64.RawStdEncoding.DecodeString,
base64.URLEncoding.DecodeString,
base64.RawURLEncoding.DecodeString,
}
lengthMismatch := false
for _, decode := range decoders {
decoded, err := decode(trimmed)
if err != nil {
continue
}
if len(decoded) == expectedLength {
return decoded, nil
}
lengthMismatch = true
}
if lengthMismatch {
return nil, fmt.Errorf("decoded value has invalid length (expected %d bytes)", expectedLength)
}
return nil, errors.New("value must be hex or base64 encoded")
}
func matchesAssetName(candidate, assetName string) bool {
name := strings.TrimSpace(strings.TrimPrefix(candidate, "*"))
name = strings.TrimPrefix(name, "./")
if name == assetName {
return true
}
name = strings.ReplaceAll(name, "\\", "/")
if path.Base(name) == assetName {
return true
}
return false
}
func isSHA256Hex(value string) bool {
if len(value) != 64 {
return false
}
for _, r := range value {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open downloaded artifact: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", fmt.Errorf("hash downloaded artifact: %w", err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}

File diff suppressed because it is too large Load diff