Compare commits
80 commits
v1.2.0-rc4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0e1f6de6 | ||
| 7c999d2aba | |||
| 846894c1a7 | |||
| 7c016e8c5e | |||
| 90dbed4d37 | |||
| 078aa17285 | |||
|
|
200674778b | ||
| 267b83bd0c | |||
| 92b63fe83d | |||
| 64671fc8b2 | |||
| 9ac814fda4 | |||
| 39b2bfbcf9 | |||
| ea3a37559a | |||
| 4e2bfbee02 | |||
| 3a61387215 | |||
| b9b729e439 | |||
| f8eb0d3449 | |||
| e6c372bffc | |||
| d23d79b6c1 | |||
| 9a52b5dce1 | |||
| 4a7248cfa9 | |||
| 955c96650a | |||
| cd0740c75f | |||
| 6bf9dd1866 | |||
| 6e85969cf4 | |||
| 1e11181c02 | |||
| 893600ffd5 | |||
| 0135b093a5 | |||
| 5552e63974 | |||
| fd08615950 | |||
| 85da274772 | |||
| 1a44a2ea35 | |||
| 9675490cd3 | |||
| e5f2244ad8 | |||
| e99a1c109a | |||
| afe4c681a1 | |||
| 17b1b99686 | |||
| a79f73825f | |||
| 20b5026f9d | |||
| ef22b1aa8a | |||
| 017005b0b1 | |||
| 6e80d3418e | |||
| 2920f5980a | |||
| 98bac84ab8 | |||
| 98f07f557d | |||
| 7d159bfdbd | |||
| 7072cb2038 | |||
| bba7aacedf | |||
| 973770ed78 | |||
| a9378885f2 | |||
| 01c0c7e1bc | |||
| f0e2e9304b | |||
| fbff660bcc | |||
| 0d266cd5cc | |||
| 3eeb2fe173 | |||
| 9d862c876f | |||
| b2eebf413e | |||
| f80eebb575 | |||
| 7c239a7e97 | |||
| 5e115893cc | |||
| f5e52463f2 | |||
| 845d20541b | |||
| 0e5bfb2d39 | |||
| 3d8a7dc84d | |||
| ea1768982e | |||
| 26238eb31b | |||
| 8c4f88ea93 | |||
| 1b603f552c | |||
| d42a790bc0 | |||
| c4c461105f | |||
| 46f48cb1f6 | |||
| bf8e1285d8 | |||
| 42e1345962 | |||
| cae689d0e4 | |||
| 17fe329ce9 | |||
| 89246d1581 | |||
| 1c40546f3f | |||
| fcf8d71349 | |||
| 3dab8000c1 | |||
| c0433d1cde |
56 changed files with 15676 additions and 643 deletions
25
.forgejo/workflows/ci.yml
Normal file
25
.forgejo/workflows/ci.yml
Normal 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 ./...
|
||||
|
|
@ -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}" \
|
||||
|
|
@ -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
240
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.13.0] — 2026-05-13
|
||||
|
||||
### Corrections
|
||||
|
||||
- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage.
|
||||
|
||||
## [v1.12.0] — 2026-05-13
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler`** : handler de login Bitwarden prêt à l'emploi avec confirmation, évitant de réimplémenter le même code dans chaque MCP.
|
||||
- **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`.
|
||||
- **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile.
|
||||
|
||||
### Changements cassants
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`.
|
||||
|
||||
## [v1.11.0] — 2026-05-12
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — masquage automatique des commandes non configurées** : les commandes dont aucun hook n'est défini dans le manifeste sont désormais automatiquement masquées de l'aide CLI, ce qui rend la sortie `--help` plus propre et adaptée à chaque projet.
|
||||
- **Bootstrap — option `DisabledCommands`** : il est maintenant possible de désactiver explicitement des commandes via l'option `DisabledCommands`, indépendamment de la configuration des hooks.
|
||||
|
||||
---
|
||||
|
||||
## [v1.10.0] — 2026-05-11
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Bootstrap — `DefaultLoginHandler`** : un handler de login générique est désormais disponible dans le package bootstrap, utilisable par défaut pour les projets qui n'ont pas besoin d'un flux de connexion personnalisé.
|
||||
|
||||
### Corrections
|
||||
|
||||
- Renommage du dossier `.forgejo` corrigé suite à une erreur de casse.
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.0] — 2026-05-05
|
||||
|
||||
### Changements internes
|
||||
|
||||
- **Migration vers Forgejo** : les workflows CI ont été migrés de GitHub Actions vers Forgejo.
|
||||
- **Mise à jour du module path** : le chemin du module Go a été mis à jour pour pointer vers la forge interne.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.2] — 2026-05-02
|
||||
|
||||
### Performances
|
||||
|
||||
- Suppression d'un appel de sonde Bitwarden inutile lors de la génération de la description runtime, ce qui réduit les appels CLI superflus au démarrage.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.1] — 2026-05-02
|
||||
|
||||
### Performances
|
||||
|
||||
- La vérification de disponibilité de Bitwarden est désormais chargée de manière paresseuse (lazy), évitant une initialisation coûteuse si le secret store n'est pas utilisé.
|
||||
|
||||
---
|
||||
|
||||
## [v1.8.0] — 2026-05-02
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Cache Bitwarden chiffré** : les lectures de secrets Bitwarden sont maintenant mises en cache localement sous forme chiffrée, ce qui réduit considérablement le nombre d'appels au CLI `bw` durant une session.
|
||||
- **Configuration du cache via `mcp.toml`** : les options de cache (durée, activation) sont configurables directement dans le manifeste du projet.
|
||||
- **Helpers générés** : les helpers de code générés exposent les contrôles de cache Bitwarden, permettant aux projets scaffoldés de bénéficier automatiquement du cache.
|
||||
|
||||
---
|
||||
|
||||
## [v1.7.0] — 2026-05-02
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Génération de code depuis le manifeste** : le framework peut désormais générer automatiquement du code Go à partir du `mcp.toml`, incluant les helpers de champs de configuration et le code de glue pour les helpers de manifeste. Cela réduit le boilerplate dans les projets utilisant le framework.
|
||||
|
||||
---
|
||||
|
||||
## [v1.6.0] — 2026-04-20
|
||||
|
||||
### Corrections
|
||||
|
||||
- L'invite de connexion Bitwarden s'affiche désormais en rouge lorsque la session est absente, rendant l'état d'erreur plus visible pour l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## [v1.5.1] — 2026-04-16
|
||||
|
||||
### Améliorations
|
||||
|
||||
- Le script d'installation généré par le scaffold récupère désormais les binaires depuis la dernière release disponible, plutôt qu'une version fixée en dur.
|
||||
|
||||
---
|
||||
|
||||
## [v1.5.0] — 2026-04-16
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Fallback runtime embarqué pour les apps scaffoldées** : si le binaire runtime n'est pas trouvé dans l'environnement, les applications générées par le scaffold peuvent désormais utiliser un runtime de fallback embarqué directement dans le manifeste.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.2] — 2026-04-15
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Vérification Ed25519 des artefacts de release** : les artefacts téléchargés lors des mises à jour automatiques sont désormais vérifiés par signature Ed25519, garantissant leur intégrité.
|
||||
|
||||
### Corrections
|
||||
|
||||
- Le mécanisme de mise à jour (`self-update`) rejette maintenant les artefacts HTML (erreurs de redirection ou pages d'erreur) pour éviter d'installer un binaire corrompu.
|
||||
- Durcissement du runtime scaffold et de la sécurité du processus de mise à jour.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Le README a été réorganisé et la documentation détaillée déplacée dans des fichiers séparés.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.1] — 2026-04-15
|
||||
|
||||
### Améliorations
|
||||
|
||||
- L'assistant d'installation généré par le scaffold est aligné avec le dernier flux TUI, garantissant la cohérence entre le code généré et le comportement attendu.
|
||||
|
||||
---
|
||||
|
||||
## [v1.4.0] — 2026-04-15
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Assistant d'installation TUI pour Claude et Codex** : le scaffold injecte désormais un wizard interactif (TUI) dans le script `install.sh` des projets générés, guidant l'utilisateur lors de la première installation avec des étapes de configuration pour Claude et Codex.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.2] — 2026-04-15
|
||||
|
||||
### Corrections
|
||||
|
||||
- Revert des fonctionnalités `build` unifiée et matrice CI introduites en cours de cycle, jugées non stables pour cette release.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.1] — 2026-04-14
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **CLI — vérification doctor sur les champs de profil** : un helper réutilisable permet de valider les champs de configuration résolus depuis plusieurs sources (env, fichier, défaut) lors du diagnostic `doctor`.
|
||||
- **CLI — lookup multi-sources** : ajout d'un helper de résolution de valeur avec traçabilité de la source (d'où vient la valeur résolue).
|
||||
- **Secretstore — helper de manifeste runtime** : ajout d'un helper facilitant l'ouverture du backend secret store depuis le manifeste runtime.
|
||||
- **Bootstrap — expansion d'alias de commandes** : les commandes bootstrap peuvent maintenant définir des alias qui sont développés automatiquement.
|
||||
|
||||
---
|
||||
|
||||
## [v1.3.0] — 2026-04-14
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Commande `scaffold init`** : nouvelle commande CLI pour initialiser un projet MCP depuis zéro via le scaffold.
|
||||
- **Générateur de scaffold MCP** : ajout d'un générateur de projet binaire MCP complet, produisant la structure de fichiers, le manifeste `mcp.toml`, et le code de démarrage.
|
||||
|
||||
---
|
||||
|
||||
## [v1.2.1] — 2026-04-14
|
||||
|
||||
### Améliorations
|
||||
|
||||
- Les commandes `config show` et `config test` générées par le bootstrap suivent désormais une structure standardisée cohérente entre les projets.
|
||||
|
||||
### Corrections
|
||||
|
||||
- La CI construit le changelog depuis le dernier tag de release stable (et non depuis un tag RC).
|
||||
|
||||
---
|
||||
|
||||
## [v1.2.0] — 2026-04-14
|
||||
|
||||
### Documentation
|
||||
|
||||
- Mise en place de la convention de nommage des branches d'amélioration dans les instructions agents du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## [v1.1.0] — 2026-04-13
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Migrations de configuration versionnées** : le framework gère désormais les migrations de configuration entre versions, permettant aux projets d'évoluer leur schéma de config sans casser les installations existantes.
|
||||
- **Secrets structurés et politiques de backend** : support des secrets structurés (objets, non plus uniquement des chaînes) et des politiques de sélection de backend secret store par champ.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Ajout des instructions de workflow du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## [v1.0.0] — 2026-04-13
|
||||
|
||||
Première release stable du framework.
|
||||
|
||||
### Fonctionnalités initiales
|
||||
|
||||
- **Framework MCP réutilisable** : socle commun pour construire des serveurs MCP en Go, avec gestion du cycle de vie, configuration, et intégration des outils.
|
||||
- **Loader de manifeste TOML** : chargement de la configuration projet depuis un fichier `mcp.toml`.
|
||||
- **Package de mise à jour** : mécanisme de self-update découplé, pilotable par des drivers de forge (GitLab, Forgejo, etc.) avec validation de checksum.
|
||||
- **Bootstrap CLI optionnel** : package permettant de bootstrapper rapidement une CLI pour un projet MCP, avec commandes `config`, `login`, `doctor`, et `update` préconfigurées.
|
||||
- **Workflow de release CI** : pipeline de release automatisée avec génération de changelog et publication des artefacts.
|
||||
|
||||
---
|
||||
|
||||
[v1.13.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.13.0
|
||||
[v1.12.0]: http://forgejo:3000/AI/mcp-framework/releases/tag/v1.12.0
|
||||
[v1.11.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.11.0
|
||||
[v1.10.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.10.0
|
||||
[v1.9.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.9.0
|
||||
[v1.8.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.2
|
||||
[v1.8.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.1
|
||||
[v1.8.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.8.0
|
||||
[v1.7.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.7.0
|
||||
[v1.6.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.6.0
|
||||
[v1.5.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.1
|
||||
[v1.5.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.5.0
|
||||
[v1.4.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.2
|
||||
[v1.4.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.1
|
||||
[v1.4.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.4.0
|
||||
[v1.3.2]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.2
|
||||
[v1.3.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.1
|
||||
[v1.3.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.3.0
|
||||
[v1.2.1]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.1
|
||||
[v1.2.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.2.0
|
||||
[v1.1.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.1.0
|
||||
[v1.0.0]: https://forge.lclr.dev/AI/mcp-framework/releases/tag/v1.0.0
|
||||
1
CLAUDE.md
Symbolic link
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
544
README.md
544
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
56
bootstrap/configtest.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
// StandardConfigTestOptions configure le handler de config test standard.
|
||||
// Aucun champ n'est obligatoire — omettez ceux qui ne s'appliquent pas à l'application.
|
||||
type StandardConfigTestOptions struct {
|
||||
// ConfigCheck vérifie que le fichier de configuration est lisible.
|
||||
// Construire avec cli.NewConfigCheck(store).
|
||||
ConfigCheck fwcli.DoctorCheck
|
||||
|
||||
// OpenStore ouvre le secret store pour vérifier sa disponibilité.
|
||||
// Si fourni, un SecretStoreAvailabilityCheck est automatiquement inclus.
|
||||
OpenStore func() (secretstore.Store, error)
|
||||
|
||||
// ConnectivityCheck vérifie la connectivité applicative (IMAP, HTTP, etc.).
|
||||
ConnectivityCheck fwcli.DoctorCheck
|
||||
|
||||
// ExtraChecks contient des vérifications supplémentaires spécifiques à l'application.
|
||||
ExtraChecks []fwcli.DoctorCheck
|
||||
}
|
||||
|
||||
// StandardConfigTestHandler retourne un Handler pour la commande config test.
|
||||
// Il inclut : config (si fourni), secret store (si fourni), connectivité (si fournie),
|
||||
// checks supplémentaires. Le ManifestCheck est intentionnellement absent : le manifest
|
||||
// est un artefact de build, pas une contrainte runtime.
|
||||
func StandardConfigTestHandler(opts StandardConfigTestOptions) Handler {
|
||||
return func(ctx context.Context, inv Invocation) error {
|
||||
doctorOpts := fwcli.DoctorOptions{
|
||||
ConfigCheck: opts.ConfigCheck,
|
||||
ConnectivityCheck: opts.ConnectivityCheck,
|
||||
ExtraChecks: opts.ExtraChecks,
|
||||
}
|
||||
|
||||
if opts.OpenStore != nil {
|
||||
doctorOpts.SecretStoreCheck = fwcli.SecretStoreAvailabilityCheck(opts.OpenStore)
|
||||
}
|
||||
|
||||
report := fwcli.RunDoctor(ctx, doctorOpts)
|
||||
|
||||
if err := fwcli.RenderDoctorReport(inv.Stdout, report); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if report.HasFailures() {
|
||||
return fmt.Errorf("config checks failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
155
bootstrap/configtest_test.go
Normal file
155
bootstrap/configtest_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
fwcli "forge.lclr.dev/AI/mcp-framework/cli"
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func TestStandardConfigTestHandlerRendersChecks(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ConfigCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "config", Status: fwcli.DoctorStatusOK, Summary: "config ok"}
|
||||
},
|
||||
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusOK, Summary: "reachable"}
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "[OK] config") {
|
||||
t.Fatalf("stdout = %q, want [OK] config", out)
|
||||
}
|
||||
if !strings.Contains(out, "[OK] connectivity") {
|
||||
t.Fatalf("stdout = %q, want [OK] connectivity", out)
|
||||
}
|
||||
if strings.Contains(out, "manifest") {
|
||||
t.Fatalf("stdout should not contain manifest check, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerIncludesSecretStoreCheck(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "secret-store") {
|
||||
t.Fatalf("stdout = %q, want secret-store check", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerReturnsErrorOnFailure(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ConnectivityCheck: func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "connectivity", Status: fwcli.DoctorStatusFail, Summary: "unreachable"}
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err == nil {
|
||||
t.Fatal("handler should return error when checks fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "config checks failed") {
|
||||
t.Fatalf("err = %q, want 'config checks failed'", err.Error())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[FAIL] connectivity") {
|
||||
t.Fatalf("stdout = %q, want [FAIL] connectivity", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerOmitsManifestCheck(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
ExtraChecks: []fwcli.DoctorCheck{
|
||||
func(context.Context) fwcli.DoctorResult {
|
||||
return fwcli.DoctorResult{Name: "custom", Status: fwcli.DoctorStatusOK, Summary: "ok"}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(stdout.String(), "manifest") {
|
||||
t.Fatalf("manifest check should not appear, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerRunsViaBootstrap(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
openCalled := false
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"config", "test"},
|
||||
Stdout: &stdout,
|
||||
Hooks: Hooks{
|
||||
ConfigTest: StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
openCalled = true
|
||||
return secretstore.Open(secretstore.Options{
|
||||
BackendPolicy: secretstore.BackendEnvOnly,
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if !openCalled {
|
||||
t.Fatal("OpenStore should have been called")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "secret-store") {
|
||||
t.Fatalf("stdout = %q, want secret-store in output", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardConfigTestHandlerSecretStoreFailurePropagates(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
storeErr := errors.New("bitwarden unavailable")
|
||||
|
||||
handler := StandardConfigTestHandler(StandardConfigTestOptions{
|
||||
OpenStore: func() (secretstore.Store, error) {
|
||||
return nil, storeErr
|
||||
},
|
||||
})
|
||||
|
||||
err := handler(context.Background(), Invocation{Command: CommandConfig, Stdout: &stdout})
|
||||
if err == nil {
|
||||
t.Fatal("handler should return error when store fails")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[FAIL] secret-store") {
|
||||
t.Fatalf("stdout = %q, want [FAIL] secret-store", stdout.String())
|
||||
}
|
||||
}
|
||||
34
bootstrap/login.go
Normal file
34
bootstrap/login.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
var loginBitwarden = secretstore.LoginBitwarden
|
||||
|
||||
// BitwardenLoginHandler retourne un Handler pour la commande login des MCPs
|
||||
// qui utilisent le backend Bitwarden. Il authentifie et déverrouille le vault,
|
||||
// persiste BW_SESSION, et confirme le résultat.
|
||||
//
|
||||
// N'utiliser que si le MCP déclare secret_store.backend_policy = "bitwarden-cli"
|
||||
// dans son manifest. Pour les backends env-only ou keyring, ne pas définir de
|
||||
// hook Login : la commande sera automatiquement masquée.
|
||||
func BitwardenLoginHandler(binaryName string) Handler {
|
||||
name := strings.TrimSpace(binaryName)
|
||||
return func(_ context.Context, inv Invocation) error {
|
||||
if _, err := loginBitwarden(secretstore.BitwardenLoginOptions{
|
||||
ServiceName: name,
|
||||
Stdin: inv.Stdin,
|
||||
Stdout: inv.Stdout,
|
||||
Stderr: inv.Stderr,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(inv.Stdout, "Session Bitwarden persistée pour %q.\n", name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
103
bootstrap/login_test.go
Normal file
103
bootstrap/login_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func withLoginBitwarden(t *testing.T, fn func(secretstore.BitwardenLoginOptions) (string, error)) {
|
||||
t.Helper()
|
||||
previous := loginBitwarden
|
||||
loginBitwarden = fn
|
||||
t.Cleanup(func() { loginBitwarden = previous })
|
||||
}
|
||||
|
||||
func TestBitwardenLoginHandlerPrintsConfirmation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
withLoginBitwarden(t, func(opts secretstore.BitwardenLoginOptions) (string, error) {
|
||||
if opts.ServiceName != "my-mcp" {
|
||||
t.Fatalf("ServiceName = %q, want %q", opts.ServiceName, "my-mcp")
|
||||
}
|
||||
return "session-token", nil
|
||||
})
|
||||
|
||||
handler := BitwardenLoginHandler("my-mcp")
|
||||
err := handler(context.Background(), Invocation{
|
||||
Command: CommandLogin,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"my-mcp"`) {
|
||||
t.Fatalf("stdout = %q, want mention of binary name", out)
|
||||
}
|
||||
if !strings.Contains(out, "persistée") {
|
||||
t.Fatalf("stdout = %q, want confirmation message", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenLoginHandlerPropagatesError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
loginErr := errors.New("vault locked")
|
||||
|
||||
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
|
||||
return "", loginErr
|
||||
})
|
||||
|
||||
handler := BitwardenLoginHandler("my-mcp")
|
||||
err := handler(context.Background(), Invocation{
|
||||
Command: CommandLogin,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if !errors.Is(err, loginErr) {
|
||||
t.Fatalf("err = %v, want %v", err, loginErr)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("stdout should be empty on error, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesBitwardenLoginHandlerWhenHookSet(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
withLoginBitwarden(t, func(_ secretstore.BitwardenLoginOptions) (string, error) {
|
||||
return "tok", nil
|
||||
})
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
Hooks: Hooks{
|
||||
Login: BitwardenLoginHandler("my-mcp"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "persistée") {
|
||||
t.Fatalf("stdout = %q, want confirmation", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoginAutoHiddenWithoutHook(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := Run(context.Background(), Options{
|
||||
BinaryName: "my-mcp",
|
||||
Args: []string{"login"},
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Fatalf("err = %v, want ErrUnknownCommand", err)
|
||||
}
|
||||
}
|
||||
234
cli/doctor.go
234
cli/doctor.go
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
87
cli/resolve_lookup.go
Normal 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
145
cli/resolve_lookup_test.go
Normal 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
76
cli/setup_secret.go
Normal 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
116
cli/setup_secret_test.go
Normal 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
269
cmd/mcp-framework/main.go
Normal 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
|
||||
}
|
||||
144
cmd/mcp-framework/main_test.go
Normal file
144
cmd/mcp-framework/main_test.go
Normal 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
20
docs/README.md
Normal 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
51
docs/auto-update.md
Normal 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
152
docs/bootstrap-cli.md
Normal 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
208
docs/cli-helpers.md
Normal 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
64
docs/config.md
Normal 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
150
docs/generate.md
Normal 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
48
docs/getting-started.md
Normal 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
3
docs/limitations.md
Normal 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
133
docs/manifest.md
Normal 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
63
docs/minimal-example.md
Normal 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
10
docs/packages.md
Normal 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
26
docs/scaffolding.md
Normal 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
273
docs/secrets.md
Normal 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`.
|
||||
1356
docs/superpowers/plans/2026-05-02-bitwarden-cache.md
Normal file
1356
docs/superpowers/plans/2026-05-02-bitwarden-cache.md
Normal file
File diff suppressed because it is too large
Load diff
217
docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md
Normal file
217
docs/superpowers/specs/2026-05-02-bitwarden-cache-design.md
Normal 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
617
generate/generate.go
Normal 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
499
generate/generate_test.go
Normal 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
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module gitea.lclr.dev/AI/mcp-framework
|
||||
module forge.lclr.dev/AI/mcp-framework
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2136
scaffold/scaffold.go
Normal file
File diff suppressed because it is too large
Load diff
243
scaffold/scaffold_test.go
Normal file
243
scaffold/scaffold_test.go
Normal 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
936
secretstore/bitwarden.go
Normal 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
|
||||
}
|
||||
}
|
||||
378
secretstore/bitwarden_cache.go
Normal file
378
secretstore/bitwarden_cache.go
Normal 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
|
||||
}
|
||||
}
|
||||
141
secretstore/bitwarden_cache_test.go
Normal file
141
secretstore/bitwarden_cache_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
229
secretstore/bitwarden_session.go
Normal file
229
secretstore/bitwarden_session.go
Normal 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
|
||||
}
|
||||
337
secretstore/bitwarden_session_test.go
Normal file
337
secretstore/bitwarden_session_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
1116
secretstore/bitwarden_test.go
Normal file
1116
secretstore/bitwarden_test.go
Normal file
File diff suppressed because it is too large
Load diff
127
secretstore/manifest_open.go
Normal file
127
secretstore/manifest_open.go
Normal 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
|
||||
}
|
||||
211
secretstore/manifest_open_test.go
Normal file
211
secretstore/manifest_open_test.go
Normal 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
214
secretstore/runtime.go
Normal 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
145
secretstore/runtime_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
847
update/update.go
847
update/update.go
|
|
@ -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
Loading…
Reference in a new issue